From bc8263ca62ee5c96fa24f8469035ade256a136c8 Mon Sep 17 00:00:00 2001 From: Michael Gilardi <11632652+mgilardi@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:50:41 -0700 Subject: [PATCH] Release 0.4.1: page attempts the spec's way Leaf Juice found 0.4.0's attempts list under its own key with a numeric cursor; the spec says items and a string cursor or null. Uses Signalbox 0.3.0's Answer.page. 51 unit tests; the bot suite checks the shape. Co-Authored-By: Claude Opus 5.5 --- RELEASE_NOTES.md | 36 ++++++------------- build.gradle.kts | 4 +-- .../com/extantape/usher/ConsoleCommands.java | 17 +++++---- .../extantape/usher/ConsoleCommandsTest.java | 6 ++++ test/acceptance.mjs | 9 ++--- 5 files changed, 34 insertions(+), 38 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 8f6ff61..35c573c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 …` and gets plain words. - - A tool adds `--json ` and gets exactly one line back, `ea-result `. - - 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 [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 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. diff --git a/build.gradle.kts b/build.gradle.kts index 4193bb0..93a4a19 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } group = "com.extantape" -version = "0.4.0" +version = "0.4.1" java { toolchain.languageVersion.set(JavaLanguageVersion.of(25)) @@ -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) diff --git a/src/main/java/com/extantape/usher/ConsoleCommands.java b/src/main/java/com/extantape/usher/ConsoleCommands.java index bf56c71..8d69405 100644 --- a/src/main/java/com/extantape/usher/ConsoleCommands.java +++ b/src/main/java/com/extantape/usher/ConsoleCommands.java @@ -59,7 +59,7 @@ static List 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-", "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)); @@ -291,7 +291,8 @@ private void answer(CommandSender sender, Request r, ChangeService.Result result private void attempts(CommandSender sender, Request r, List 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 all = new ArrayList<>(plugin.attempts().list()); List page = all.subList(Math.min(cursor, all.size()), Math.min(cursor + limit, all.size())); @@ -310,10 +311,8 @@ private void attempts(CommandSender sender, Request r, List rest) throws j.put("last", a.last); list.add(j); } - Map 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 rest) throws Request.Bad, Refusal { @@ -335,6 +334,12 @@ private void log(CommandSender sender, Request r, List 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) { diff --git a/src/test/java/com/extantape/usher/ConsoleCommandsTest.java b/src/test/java/com/extantape/usher/ConsoleCommandsTest.java index 39db825..a4be3eb 100644 --- a/src/test/java/com/extantape/usher/ConsoleCommandsTest.java +++ b/src/test/java/com/extantape/usher/ConsoleCommandsTest.java @@ -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); diff --git a/test/acceptance.mjs b/test/acceptance.mjs index 1fb11b9..126bc4b 100644 --- a/test/acceptance.mjs +++ b/test/acceptance.mjs @@ -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 ---------- @@ -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(' | ')) @@ -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)