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
36 changes: 10 additions & 26 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,20 @@
# Usher 0.4.0
# Usher 0.4.1

Usher's console commands move to the shared Signalbox format, so Leaf Juice and scripts talk to Usher the same way as to Latchkey. The old Stationmaster names are gone, as 0.3.x said they would be.
A fix to how `attempts` pages, so it matches the shared command format. Nothing else changes.

This is an early 0.x release. It has passed bot tests on throwaway servers, but it hasn't been used on a real server or seen in a real game client.

## Changed
## Fixed

- **Console commands use [Signalbox](https://github.com/ExtantApe/Signalbox) format 1.**
- A person types `usher <command> …` and gets plain words.
- A tool adds `--json <id>` and gets exactly one line back, `ea-result <id> <json>`.
- Questions that need a yes come back as `needs` and change nothing; answer them with `--yes`, such as `--yes bridge`.
- `describe` lists every command with its typed arguments, marking the optional ones.
- The README's "Console commands" section has the full list.
- **`apply` takes plain positional arguments:** `apply <uuid> <name> <add|none> <remove|none> [whitelist] [via]`, with profile lists comma-separated. The old `+profile`, `-profile`, `confirm=`, `whitelist=` and `via=` forms are gone.
- **The request id is the `--json` id** and still makes a change safe to resend: it's applied once and answered again with `replayed: true`.
- **Error codes use dashes:** `unknown-profile`, `luckperms-missing` and so on.
- **A profile can't be called `none`,** since commands use `none` for an empty list. A config that has one leaves that profile out and says why.
- **`usher attempts --json` answers in the spec's list shape:** `items`, and `next` as a string cursor, or `null` on the last page. 0.4.0 sent the list under `attempts`, with a numeric `next`, which Leaf Juice found. The cursor is still a position, written as a string. One that Usher didn't hand out is refused with `bad-argument`.
- `describe` gives the cursor's type as `string`.
- Signalbox 0.3.0, whose `Answer.page` makes this shape the default.

## Removed
## Upgrading from 0.4.0

- `usher-result` lines. Nothing published read them.
- The `stationmaster` and `smaster` commands, and the `stationmaster.approve` permission. Grant `usher.approve` instead: `lp group <group> permission set usher.approve true`.

## Upgrading from 0.3.x

- Swap the jar with the server stopped.
- If anything still grants `stationmaster.approve`, grant `usher.approve` too, or that admin can no longer approve.
- Scripts that call `usher` need the new form.
- The old `plugins/Stationmaster/` folder is still copied across on first start if `plugins/Usher/` doesn't exist.
Swap the jar with the server stopped. Scripts that read `attempts` from the answer read `items` instead.

## Tested

- 50 unit tests pass, including the argument rules for `apply`, the reserved `none` and the optional markers in `describe`.
- The bot suite drives every console command in the new format and passes 120/120 on throwaway Paper 26.1.2, 26.2 and 26.3 servers. It covers the raw `ea-result` line, `unknown-command` and `bad-argument` answers, `describe`, questions answered with `--yes`, resending an id, a kill -9 mid-change, and the old Stationmaster names being gone.
- Not tested yet: a real server or client.
- 51 unit tests pass, including the cursor rules.
- The bot suite passes 121/121 on throwaway Paper 26.1.2, 26.2 and 26.3, including a check of the list's shape.
4 changes: 2 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ plugins {
}

group = "com.extantape"
version = "0.4.0"
version = "0.4.1"

java {
toolchain.languageVersion.set(JavaLanguageVersion.of(25))
Expand All @@ -22,7 +22,7 @@ val luckPermsApi = "net.luckperms:api:5.4"
dependencies {
compileOnly(paperApi)
compileOnly(luckPermsApi)
implementation("com.github.ExtantApe:Signalbox:v0.2.0")
implementation("com.github.ExtantApe:Signalbox:v0.3.0")

testImplementation(paperApi)
testImplementation(luckPermsApi)
Expand Down
17 changes: 11 additions & 6 deletions src/main/java/com/extantape/usher/ConsoleCommands.java
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ static List<Describe.Command> described() {
new Describe.Command("apply", "console", List.of(Describe.Arg.of("uuid", "uuid"), Describe.Arg.of("name", "player"),
Describe.Arg.listOf("add", "string", List.of("none")), Describe.Arg.listOf("remove", "string", List.of("none")),
Describe.Arg.of("whitelist", "bool").asOptional(), Describe.Arg.of("via", "string").asOptional()), List.of("confirm-<profile>", "bridge"), false),
new Describe.Command("attempts", "console", List.of(Describe.Arg.of("limit", "int").asOptional(), Describe.Arg.of("cursor", "int").asOptional()), List.of(), false),
new Describe.Command("attempts", "console", List.of(Describe.Arg.of("limit", "int").asOptional(), Describe.Arg.of("cursor", "string").asOptional()), List.of(), false),
new Describe.Command("log", "console", List.of(Describe.Arg.of("limit", "int").asOptional()), List.of(), false),
new Describe.Command("reload", a, List.of(), List.of(), false),
new Describe.Command("describe", "console", List.of(), List.of(), false));
Expand Down Expand Up @@ -291,7 +291,8 @@ private void answer(CommandSender sender, Request r, ChangeService.Result result

private void attempts(CommandSender sender, Request r, List<String> rest) throws Request.Bad, Refusal {
if (rest.size() > 2) throw new Refusal(Errors.BAD_ARGUMENT, "Use: usher attempts [limit] [cursor]");
int limit = rest.isEmpty() ? 50 : Args.integer(rest.get(0)), cursor = rest.size() < 2 ? 0 : Args.integer(rest.get(1));
int limit = rest.isEmpty() ? 50 : Args.integer(rest.get(0));
int cursor = rest.size() < 2 ? 0 : cursorOf(rest.get(1));
if (limit < 1 || limit > MAX_LIMIT || cursor < 0) throw new Refusal(Errors.BAD_ARGUMENT, "limit is 1 to " + MAX_LIMIT + " and cursor 0 or more.");
List<JoinAttempts.Attempt> all = new ArrayList<>(plugin.attempts().list());
List<JoinAttempts.Attempt> page = all.subList(Math.min(cursor, all.size()), Math.min(cursor + limit, all.size()));
Expand All @@ -310,10 +311,8 @@ private void attempts(CommandSender sender, Request r, List<String> rest) throws
j.put("last", a.last);
list.add(j);
}
Map<String, Object> o = new LinkedHashMap<>();
o.put("attempts", list);
o.put("next", cursor + page.size() < all.size() ? cursor + page.size() : null);
send(sender, Answer.ok(r.id(), o));
// The spec's list shape: items, and next as an opaque string cursor, null on the last page.
send(sender, Answer.page(r.id(), list, cursor + page.size() < all.size() ? Integer.toString(cursor + page.size()) : null));
}

private void log(CommandSender sender, Request r, List<String> rest) throws Request.Bad, Refusal {
Expand All @@ -335,6 +334,12 @@ private void log(CommandSender sender, Request r, List<String> rest) throws Requ

// --- helpers ---

/** The cursor a page handed out: a string to the caller, a position here. Anything else is refused. */
static int cursorOf(String token) throws Refusal {
if (!token.matches("\\d{1,9}")) throw new Refusal(Errors.BAD_ARGUMENT, "That cursor isn't one Usher handed out. Use the next value from the last page.");
return Integer.parseInt(token);
}

private void send(CommandSender sender, String line) { plugin.runMain(() -> Answer.send(sender::sendMessage, line)); }

private void fail(CommandSender sender, String id, String code, String message) {
Expand Down
6 changes: 6 additions & 0 deletions src/test/java/com/extantape/usher/ConsoleCommandsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ static ConsoleCommands.ApplyArgs apply(String line) throws Exception {
assertTrue(line.contains("{\"name\":\"limit\",\"type\":\"int\",\"optional\":true}"));
}

@Test void cursorsAreOnlyOnesUsherHandedOut() throws Exception {
assertEquals(50, ConsoleCommands.cursorOf("50"));
assertEquals(0, ConsoleCommands.cursorOf("0"));
for (String bad : List.of("-1", "abc", "1.5", "9999999999")) assertThrows(ConsoleCommands.Refusal.class, () -> ConsoleCommands.cursorOf(bad), bad);
}

@Test void describeListsEveryCommand() {
var names = ConsoleCommands.described().stream().map(d -> d.name()).toList();
assertEquals(ConsoleCommands.SUBS, names);
Expand Down
9 changes: 5 additions & 4 deletions test/acceptance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,9 @@ try {
check('control: a stranger is turned away by the whitelist', kicked)
await join('Stranger1').catch(() => {})
const att = await s.sm('attempts')
const st1 = att.attempts.find(a => a.name === 'Stranger1')
check('the attempt is listed for Leaf Juice, with a count and no duplicate', st1 && st1.attempts === 2 && att.attempts.filter(a => a.name === 'Stranger1').length === 1, JSON.stringify(att.attempts))
const st1 = att.items.find(a => a.name === 'Stranger1')
check('the attempt is listed for Leaf Juice, with a count and no duplicate', st1 && st1.attempts === 2 && att.items.filter(a => a.name === 'Stranger1').length === 1, JSON.stringify(att.items))
check('the list pages the spec\'s way: items, and next as a string cursor or null', Array.isArray(att.raw.items) && 'next' in att.raw && (att.raw.next === null || typeof att.raw.next === 'string'), JSON.stringify(att.raw).slice(0, 120))
check('a JoinAttemptEvent fired once, on the main thread, despite two tries', spy(from, /SPY attempt name=Stranger1 .*main=true/).length === 1, spy(from, /attempt/).join(' | '))

// ---------- Approving with each single profile, with several and with none ----------
Expand Down Expand Up @@ -380,7 +381,7 @@ try {
await click(adminA, SLOT.CONFIRM); await sleep(1500)
const ct = await join('CaseTest').then(() => true, e => e.message)
check('and then CaseTest can join', ct === true, String(ct))
check('approving removes them from the attempts list', !(await s.sm('attempts')).attempts.some(a => a.name === 'CaseTest'))
check('approving removes them from the attempts list', !(await s.sm('attempts')).items.some(a => a.name === 'CaseTest'))
adminA.chat_log.length = 0
adminA.chat('/approve bad-name!'); adminA.chat('/approve way_too_long_for_a_name'); await sleep(1500)
check('impossible names fail clearly', adminA.chat_log.filter(m => /can't be a Minecraft name/.test(m)).length === 2, adminA.chat_log.join(' | '))
Expand All @@ -396,7 +397,7 @@ try {
await s.stop(); await sleep(1500)
s = new Server(dir); await s.start()
check('profiles survive a restart', same(await profilesOf('P_two'), ['young', 'young_sky', 'builders']) && same(await profilesOf('P_several'), ['young', 'builders', 'young_sky']))
check('the attempts list survives a restart', (await s.sm('attempts')).attempts.some(a => a.name === 'Stranger2'))
check('the attempts list survives a restart', (await s.sm('attempts')).items.some(a => a.name === 'Stranger2'))
const after2 = await join('P_several')
check('and the worlds are still right after it', same(await openWorlds(after2), expected(['young', 'builders', 'young_sky'])))
await quit(after2)
Expand Down
Loading