diff --git a/CLAUDE.md b/CLAUDE.md index 82a2064..3c472a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Packmule -Registered in the owner's ops process (`~/Code/ops`). Area: `minecraft-plugins` (lead: Minecraft: Plugins Lead). Added 2026-11-09 from the ops starter kit; the repo itself dates from 2026-11-07 (as ResourcePackButler). +Registered in the owner's ops process (`~/Code/ops`). Area: `minecraft-plugins` (lead: Minecraft: Plugins Lead). Added 2026-11-09 from the ops starter kit; the repo itself dates from 2026-11-07. **Before doing anything here, run `~/Code/ops/scripts/repo-context`.** It shows who leads this repo, whether that session is active, what's pending, and which briefs apply. If it says the repo has no active owner, tell the director ("Helm") before starting work. diff --git a/README.md b/README.md index fa712e4..5443034 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A Paper plugin that sends each world its own required resource packs, and warns - Each world has an ordered list of packs. They're sent together with the multi-pack API, and later packs sit on top of earlier ones. A world with an empty list gets no packs from Packmule. - Moving between worlds with the same packs sends nothing. Moving to a world with different packs swaps only Packmule's packs. -- Packmule never removes or replaces a pack it didn't send. Another plugin's pack, such as BlueMinimap's, stays put. +- Packmule never removes or replaces a pack it didn't send. Another plugin's pack, such as BlueMapMini's, stays put. - Every pack is required. A player who declines is kicked. A client that doesn't answer the prompt in 60 seconds is kicked. - A failed download is sent again twice. If it still fails, the player stays in without that pack and admins are told which world and pack. Nobody is kicked over a hosting problem. - At startup, Packmule downloads every pack and checks its SHA-1. A 404, a timeout, a wrong hash or a malformed address is reported with the worlds it affects. That pack is skipped until it's fixed, and the rest keep working. Startup doesn't wait for the check. @@ -90,15 +90,6 @@ If `config.yml` has a mistake, Packmule says what it is. On `/packmule reload` i `packmule.notify` (default op) shows dead-link and failed-download warnings in chat. -## Upgrading from ResourcePackButler - -Packmule was called ResourcePackButler up to 0.1.0, and renamed in 0.2.0. Nothing else changed in how it works. - -- With the server stopped, swap `ResourcePackButler-0.1.0.jar` for `Packmule-0.2.0.jar`. -- On its first start, Packmule copies `plugins/ResourcePackButler/` to `plugins/Packmule/` if `plugins/Packmule/` doesn't exist yet, so your config and last good hashes come across unchanged. The old folder is left as it was; delete it once you're happy. If `plugins/Packmule/` already exists, nothing is copied. -- For 0.2.x only, the old names still work and log a line saying what to change: `/butler`, `/resourcepackbutler` and `/rpb`, and the `resourcepackbutler.*` permissions. They go in the next release. Grant `packmule.*` instead. -- Pack IDs sent to clients are the same as before, so players don't re-download anything. - ## Build Java 25. @@ -122,7 +113,7 @@ cd test && npm install PACKMULE_TEST_WORK=/tmp/rpb PACKMULE_TEST_JARS=/path/to/jars PACKMULE_TEST_JAVA=/path/to/java25 node suite.mjs 26.3 ``` -`PACKMULE_TEST_JARS` holds `paper-.jar` for each version, Multiverse-Core 5.8.2, Multiverse-Inventories 5.3.6, `bluemap-5.29-paper.jar`, and ViaVersion and ViaBackwards 5.12.3. BlueMinimap and Waystation come from their own builds in `~/Code`. +`PACKMULE_TEST_JARS` holds `paper-.jar` for each version, Multiverse-Core 5.8.2, Multiverse-Inventories 5.3.6, `bluemap-5.29-paper.jar`, and ViaVersion and ViaBackwards 5.12.3. BlueMapMini and Waystation come from their own builds in `~/Code`. ## What hasn't been tested @@ -132,7 +123,7 @@ Only a real client can tell: - Whether a player who clicks Disconnect on the prompt sees Packmule's kick message, or the game's own screen. The client may leave before the kick arrives. - How the prompt and kick text read on screen, line breaks included. - Whether switching feels acceptable when worlds use large packs. -- Whether packs stack in the right order next to BlueMinimap's, and whether its font still draws. +- Whether packs stack in the right order next to BlueMapMini's, and whether its font still draws. - What happens across a server transfer. - How older clients connected through ViaVersion treat the pack format. diff --git a/build.gradle.kts b/build.gradle.kts index 4864332..6e5ea09 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } group = "com.extantape" -version = "0.2.1" +version = "0.3.0" java { toolchain.languageVersion.set(JavaLanguageVersion.of(25)) diff --git a/docs/decisions.md b/docs/decisions.md index b72a7cb..f7d2118 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -29,3 +29,4 @@ Owner decisions that shape Butler, with the date each was made. Change these onl - **Going public:** the owner said yes to making this repo public at its first release, after the first-public-push audit passed. - **Name (2026-11-08, owner):** ResourcePackButler is renamed Packmule, one word in every place: repo, plugin name, jar, package and prose. Released as 0.2.0. Existing servers get their `plugins/ResourcePackButler/` folder copied on first start (never overwritten), and the old commands and permissions keep working for one release with a log warning. Pack IDs keep the old `resourcepackbutler:pack:` namespace for good, because clients see them. - **pack set for Leaf Juice (2026-11-14, D189):** each pack build is published at a new unguessable path on the join host, and Leaf Juice hands Packmule the new link through `/packmule pack set --json `, as the Leaf Juice App session proposed and Helm assigned (K644). It takes https only, with the SHA-1 as the file name. Packmule downloads the file and checks the SHA-1 before writing `config.yml` (comments kept) and reloading, and a failure keeps the old address. Released as 0.2.1, so the old ResourcePackButler names stay for this 0.2.x release as promised. +- **Old names (2026-11-14, owner):** the ResourcePackButler commands, permissions, folder copy and pack ID namespace are gone; pack IDs are now packmule:pack:, so clients download each pack once more. diff --git a/docs/releases/v0.3.0.md b/docs/releases/v0.3.0.md new file mode 100644 index 0000000..b0273e8 --- /dev/null +++ b/docs/releases/v0.3.0.md @@ -0,0 +1,14 @@ +Packmule no longer carries any of its old ResourcePackButler names. + +## Changed + +- **Removed** the `/butler`, `/resourcepackbutler` and `/rpb` commands, the `resourcepackbutler.*` permissions, and the copy of `plugins/ResourcePackButler/` into `plugins/Packmule/` on first start. +- **Pack IDs** now start with `packmule:pack:` instead of the old namespace. Clients see these IDs, so **every player downloads each pack once more** after this update. After that, nothing changes. + +## Upgrading + +Replace the jar and restart. If a server still grants the old `resourcepackbutler.*` permission nodes, grant `packmule.*` instead (`packmule.recheck`, `packmule.reload`, `packmule.status`, `packmule.pack.set`, `packmule.notify`). Nothing else is needed. + +## Tested + +40 unit tests pass. The unit test for pack IDs checks the new namespace. The bot test suite on a Paper server was updated to match but has not been run for this release, and nothing has been seen in a real game client. diff --git a/docs/test-audit.md b/docs/test-audit.md index 4b0a59c..1b6d874 100644 --- a/docs/test-audit.md +++ b/docs/test-audit.md @@ -10,7 +10,7 @@ Each README promise and the test that holds it. "Bots" is `test/suite.mjs` on th | --- | --- | | Ordered per-world list, sent together; empty list sends nothing | unit `PackPlanTest` (order, inserted pack, empty list); bots "a world with two packs gets both, in order", "an empty list removes Packmule's" | | Same packs across worlds send nothing; different packs swap only Packmule's | unit `samePacksChangeNothing`, `swapReplacesTheWholeList`, `sharedStartIsKept`; bots teleport, same pack, End, swap | -| Never removes or replaces another plugin's pack | unit `emptyListRemovesOnlyPackmulesPacks`; bots "Packmule never removed a pack it didn't send", BlueMinimap's pack through switches, reloads and hopping | +| Never removes or replaces another plugin's pack | unit `emptyListRemovesOnlyPackmulesPacks`; bots "Packmule never removed a pack it didn't send", BlueMapMini's pack through switches, reloads and hopping | | Every pack required: decline kicked, no answer kicked after 60 s | bots "a decline is kicked", "kicked after the timeout"; the 60 s default in the long run (`PACKMULE_TEST_LONG`) | | Failed download retried twice, then the player stays in; admins told | bots retry, "the player stays in without it", admin warning, quiet once retries run out, recovery | | **Players never see pack names, world names or addresses** | bots "the player is told once for the world… without internal names" | @@ -20,8 +20,7 @@ Each README promise and the test that holds it. "Bots" is `test/suite.mjs` on th | `server.properties` pack warns | bots | | **Fail closed on a broken config:** reload keeps the old one; startup sends no packs and players still get in | bots "a reload with broken config warns and keeps the old config", "fails safe: players get in, with no packs from Packmule and no kick" | | Commands need permission | bots "a player without permission can't…", "an op can" | -| Pack IDs never change (clients see them) | unit `packIdsAreTheSameAsBeforeTheRename` | -| Upgrade from ResourcePackButler | unit `MigrationTest`, `OldPermissionsTest`; bots old commands and folder copy | +| Pack IDs are stable (clients see them) | unit `packIdsUseThePackmuleNamespace` | **Gaps added this audit:** none. Every promise already had a test. diff --git a/src/main/java/com/extantape/packmule/Migration.java b/src/main/java/com/extantape/packmule/Migration.java deleted file mode 100644 index 3675442..0000000 --- a/src/main/java/com/extantape/packmule/Migration.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.extantape.packmule; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.util.stream.Stream; - -/** Carries a server's files over from the old plugin folder after the rename from ResourcePackButler (2026-11-09). */ -final class Migration { - private Migration() {} - - /** - * Copies {@code old} to {@code now} if {@code now} doesn't exist and {@code old} is a folder. Copies into a - * temporary sibling first and renames it into place, so a crash halfway leaves no half-filled folder. - * - * @return a line for the log saying what happened, or null if there was nothing to do - */ - static String copyIfMissing(Path old, Path now) { - if (Files.exists(now) || !Files.isDirectory(old)) return null; - Path tmp = now.resolveSibling(now.getFileName() + ".migrating"); - try { - if (Files.exists(tmp)) deleteTree(tmp); - try (Stream all = Files.walk(old)) { - all.forEach(p -> { - Path to = tmp.resolve(old.relativize(p).toString()); - try { - if (Files.isDirectory(p)) Files.createDirectories(to); - else Files.copy(p, to, StandardCopyOption.COPY_ATTRIBUTES); - } catch (IOException e) { throw new UncheckedIOException(e); } - }); - } - Files.move(tmp, now, StandardCopyOption.ATOMIC_MOVE); - return "Copied plugins/" + old.getFileName() + " to plugins/" + now.getFileName() - + " (ResourcePackButler is now called Packmule). The old folder was left untouched; delete it once you're happy."; - } catch (IOException | UncheckedIOException e) { - return "Couldn't copy plugins/" + old.getFileName() + " to plugins/" + now.getFileName() + ": " + e.getMessage() - + ". Copy it by hand while the server is stopped, or Packmule starts with a fresh config."; - } - } - - private static void deleteTree(Path root) throws IOException { - try (Stream all = Files.walk(root)) { - for (Path p : all.sorted(java.util.Comparator.reverseOrder()).toList()) Files.delete(p); - } - } -} diff --git a/src/main/java/com/extantape/packmule/OldPermissions.java b/src/main/java/com/extantape/packmule/OldPermissions.java deleted file mode 100644 index 431917d..0000000 --- a/src/main/java/com/extantape/packmule/OldPermissions.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.extantape.packmule; - -import org.bukkit.command.CommandSender; -import org.bukkit.permissions.Permissible; - -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Consumer; - -/** - * Permission checks that still accept the names from before the rename to Packmule (2026-11-09), for one release - * (0.2.x): {@code resourcepackbutler.recheck} counts as {@code packmule.recheck}, and so on. Each old name logs once - * per sender, saying what to grant instead. - */ -public final class OldPermissions { - private final Consumer warn; - private final Set warned = ConcurrentHashMap.newKeySet(); - - public OldPermissions(Consumer warn) { this.warn = warn; } - - public boolean allowed(Permissible who, String node) { - if (who.hasPermission(node)) return true; - if (!node.startsWith("packmule.")) return false; - String old = "resourcepackbutler." + node.substring("packmule.".length()); - if (!who.hasPermission(old)) return false; - String name = who instanceof CommandSender cs ? cs.getName() : String.valueOf(who); - if (warned.add(name + " " + old)) - warn.accept(name + " uses the old permission " + old + ". It still works in 0.2.x but goes in the next release: grant " + node + " instead."); - return true; - } -} diff --git a/src/main/java/com/extantape/packmule/Packmule.java b/src/main/java/com/extantape/packmule/Packmule.java index ce637d9..c99f489 100644 --- a/src/main/java/com/extantape/packmule/Packmule.java +++ b/src/main/java/com/extantape/packmule/Packmule.java @@ -52,8 +52,6 @@ private record Checked(PackSource source, Outcome outcome) { @Override public void onEnable() { - String moved = Migration.copyIfMissing(getDataFolder().toPath().resolveSibling("ResourcePackButler"), getDataFolder().toPath()); - if (moved != null) getLogger().warning(moved); saveDefaultConfig(); checks = Executors.newFixedThreadPool(4, runnable -> { Thread thread = new Thread(runnable, "Packmule-check"); @@ -264,14 +262,14 @@ public void warnAdmins(String key, String message, boolean force) { getLogger().warning(message); Component line = Component.text("[Packmule] " + message, NamedTextColor.RED); for (Player player : Bukkit.getOnlinePlayers()) { - if (allowed(player, "packmule.notify")) player.sendMessage(line); + if (player.hasPermission("packmule.notify")) player.sendMessage(line); } } @EventHandler public void onAdminJoin(PlayerJoinEvent event) { Player player = event.getPlayer(); - if (!allowed(player, "packmule.notify") || config == null) return; + if (!player.hasPermission("packmule.notify") || config == null) return; List dead = new ArrayList<>(); for (String name : config.packs().keySet()) { Outcome outcome = outcome(name); @@ -325,9 +323,4 @@ private void warnAboutWorlds() { if (worldsUsing(config, pack).isEmpty()) getLogger().info("Pack '" + pack + "' isn't used by any world."); } } - - private final OldPermissions oldPermissions = new OldPermissions(line -> getLogger().warning(line)); - - /** True for {@code node}, or its old {@code resourcepackbutler.*} name for one release. See {@link OldPermissions}. */ - public boolean allowed(org.bukkit.permissions.Permissible who, String node) { return oldPermissions.allowed(who, node); } } diff --git a/src/main/java/com/extantape/packmule/command/PackmuleCommand.java b/src/main/java/com/extantape/packmule/command/PackmuleCommand.java index 83904d4..675e2a7 100644 --- a/src/main/java/com/extantape/packmule/command/PackmuleCommand.java +++ b/src/main/java/com/extantape/packmule/command/PackmuleCommand.java @@ -40,30 +40,28 @@ public PackmuleCommand(Packmule plugin) { } public void register(Commands commands) { - commands.register(tree("packmule", false), "Re-check pack links, reload the config, or show what Packmule is sending."); - // The old names, for one release (0.2.x) after the rename from ResourcePackButler. Each use says where to move. - commands.register(tree("butler", true), "Old name for /packmule. Goes in the next release.", List.of("resourcepackbutler", "rpb")); + commands.register(tree("packmule"), "Re-check pack links, reload the config, or show what Packmule is sending."); } - private com.mojang.brigadier.tree.LiteralCommandNode tree(String label, boolean old) { + private com.mojang.brigadier.tree.LiteralCommandNode tree(String label) { return Commands.literal(label) .requires(source -> has(source, RECHECK) || has(source, RELOAD) || has(source, STATUS)) - .executes(ctx -> run(old, ctx, () -> reply(ctx.getSource().getSender(), "Usage: /packmule |describe>"))) + .executes(ctx -> run(ctx, () -> reply(ctx.getSource().getSender(), "Usage: /packmule |describe>"))) .then(Commands.literal("recheck").requires(source -> has(source, RECHECK)) - .executes(ctx -> run(old, ctx, () -> recheck(ctx.getSource().getSender())))) + .executes(ctx -> run(ctx, () -> recheck(ctx.getSource().getSender())))) .then(Commands.literal("reload").requires(source -> has(source, RELOAD)) - .executes(ctx -> run(old, ctx, () -> reload(ctx.getSource().getSender())))) + .executes(ctx -> run(ctx, () -> reload(ctx.getSource().getSender())))) .then(Commands.literal("pack").requires(source -> has(source, PACK_SET)) .then(Commands.literal("set") - .executes(ctx -> run(old, ctx, () -> packSet(ctx.getSource().getSender(), new String[0]))) + .executes(ctx -> run(ctx, () -> packSet(ctx.getSource().getSender(), new String[0]))) .then(Commands.argument("args", StringArgumentType.greedyString()) - .executes(ctx -> run(old, ctx, () -> packSet(ctx.getSource().getSender(), split(StringArgumentType.getString(ctx, "args")))))))) + .executes(ctx -> run(ctx, () -> packSet(ctx.getSource().getSender(), split(StringArgumentType.getString(ctx, "args")))))))) .then(Commands.literal("describe").requires(source -> has(source, STATUS)) - .executes(ctx -> run(old, ctx, () -> describe(ctx.getSource().getSender(), new String[0]))) + .executes(ctx -> run(ctx, () -> describe(ctx.getSource().getSender(), new String[0]))) .then(Commands.argument("args", StringArgumentType.greedyString()) - .executes(ctx -> run(old, ctx, () -> describe(ctx.getSource().getSender(), split(StringArgumentType.getString(ctx, "args"))))))) + .executes(ctx -> run(ctx, () -> describe(ctx.getSource().getSender(), split(StringArgumentType.getString(ctx, "args"))))))) .then(Commands.literal("status").requires(source -> has(source, STATUS)) - .executes(ctx -> run(old, ctx, () -> status(ctx.getSource().getSender()))) + .executes(ctx -> run(ctx, () -> status(ctx.getSource().getSender()))) .then(Commands.argument("player", StringArgumentType.word()) .suggests((ctx, builder) -> { String typed = builder.getRemainingLowerCase(); @@ -71,21 +69,16 @@ private com.mojang.brigadier.tree.LiteralCommandNode tree(St .filter(name -> name.toLowerCase(Locale.ROOT).startsWith(typed)).forEach(builder::suggest); return builder.buildFuture(); }) - .executes(ctx -> run(old, ctx, () -> playerStatus(ctx.getSource().getSender(), StringArgumentType.getString(ctx, "player")))))) + .executes(ctx -> run(ctx, () -> playerStatus(ctx.getSource().getSender(), StringArgumentType.getString(ctx, "player")))))) .build(); } - private int run(boolean old, com.mojang.brigadier.context.CommandContext ctx, java.util.function.IntSupplier body) { - if (old) { - String line = "/butler, /resourcepackbutler and /rpb are the old names for /packmule. They still work in 0.2.x but go in the next release."; - plugin.getLogger().warning(line); - ctx.getSource().getSender().sendMessage(Component.text(line, NamedTextColor.YELLOW)); - } + private int run(com.mojang.brigadier.context.CommandContext ctx, java.util.function.IntSupplier body) { return body.getAsInt(); } private boolean has(CommandSourceStack source, String permission) { - return plugin.allowed(source.getSender(), permission); + return source.getSender().hasPermission(permission); } private static int reply(CommandSender sender, String text) { diff --git a/src/main/java/com/extantape/packmule/send/PackPlan.java b/src/main/java/com/extantape/packmule/send/PackPlan.java index 8f18da5..115e929 100644 --- a/src/main/java/com/extantape/packmule/send/PackPlan.java +++ b/src/main/java/com/extantape/packmule/send/PackPlan.java @@ -32,7 +32,7 @@ public boolean none() { public static UUID idFor(String packName) { // Keeps the namespace from before the rename to Packmule (2026-11-09) on purpose: these IDs go to clients, // and changing them would make every player's client drop and re-download every pack once. - return UUID.nameUUIDFromBytes(("resourcepackbutler:pack:" + packName).getBytes(StandardCharsets.UTF_8)); + return UUID.nameUUIDFromBytes(("packmule:pack:" + packName).getBytes(StandardCharsets.UTF_8)); } public static Change between(List sent, List wanted) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index f81adec..7e75c9d 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -22,7 +22,7 @@ # sit on top of earlier ones. An empty list [] means Packmule sends that world nothing. # A world that isn't listed also gets nothing, and Packmule warns about it at startup. # -# Packmule only ever removes packs it sent. Other plugins' packs (BlueMinimap's, say) are left alone. +# Packmule only ever removes packs it sent. Other plugins' packs (BlueMapMini's, say) are left alone. # Leave resource-pack= empty in server.properties: a pack there fights Packmule's. packs: {} diff --git a/src/main/resources/paper-plugin.yml b/src/main/resources/paper-plugin.yml index b36cf16..a8572f7 100644 --- a/src/main/resources/paper-plugin.yml +++ b/src/main/resources/paper-plugin.yml @@ -20,15 +20,3 @@ permissions: packmule.notify: description: See warnings about dead links and failed downloads in chat. default: op - resourcepackbutler.recheck: - description: The old name for packmule.recheck, from before the rename. Still works in 0.2.x and is removed in the next release. - default: false - resourcepackbutler.reload: - description: The old name for packmule.reload, from before the rename. Still works in 0.2.x and is removed in the next release. - default: false - resourcepackbutler.status: - description: The old name for packmule.status, from before the rename. Still works in 0.2.x and is removed in the next release. - default: false - resourcepackbutler.notify: - description: The old name for packmule.notify, from before the rename. Still works in 0.2.x and is removed in the next release. - default: false diff --git a/src/test/java/com/extantape/packmule/MigrationTest.java b/src/test/java/com/extantape/packmule/MigrationTest.java deleted file mode 100644 index 9b54cb4..0000000 --- a/src/test/java/com/extantape/packmule/MigrationTest.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.extantape.packmule; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.*; - -class MigrationTest { - - @Test - void copiesTheOldFolderWhenTheNewOneIsMissing(@TempDir Path plugins) throws Exception { - Path old = plugins.resolve("ResourcePackButler"); - Files.createDirectories(old.resolve("sub")); - Files.writeString(old.resolve("config.yml"), "profiles: {}\n"); - Files.writeString(old.resolve("changes.log"), "{\"a\":1}\n"); - Files.writeString(old.resolve("sub/x.json"), "[]"); - - String msg = Migration.copyIfMissing(old, plugins.resolve("Packmule")); - - assertNotNull(msg); - assertEquals("profiles: {}\n", Files.readString(plugins.resolve("Packmule/config.yml"))); - assertEquals("{\"a\":1}\n", Files.readString(plugins.resolve("Packmule/changes.log"))); - assertEquals("[]", Files.readString(plugins.resolve("Packmule/sub/x.json"))); - assertTrue(Files.exists(old.resolve("config.yml")), "the old folder is left as a backup"); - assertFalse(Files.exists(plugins.resolve("Packmule.migrating"))); - } - - @Test - void neverOverwritesAnExistingNewFolder(@TempDir Path plugins) throws Exception { - Files.createDirectories(plugins.resolve("ResourcePackButler")); - Files.writeString(plugins.resolve("ResourcePackButler/config.yml"), "old"); - Files.createDirectories(plugins.resolve("Packmule")); - Files.writeString(plugins.resolve("Packmule/config.yml"), "new"); - - assertNull(Migration.copyIfMissing(plugins.resolve("ResourcePackButler"), plugins.resolve("Packmule"))); - assertEquals("new", Files.readString(plugins.resolve("Packmule/config.yml"))); - } - - @Test - void doesNothingOnAFreshServer(@TempDir Path plugins) { - assertNull(Migration.copyIfMissing(plugins.resolve("ResourcePackButler"), plugins.resolve("Packmule"))); - assertFalse(Files.exists(plugins.resolve("Packmule"))); - } - - @Test - void clearsAHalfFinishedCopyFromACrash(@TempDir Path plugins) throws Exception { - Files.createDirectories(plugins.resolve("ResourcePackButler")); - Files.writeString(plugins.resolve("ResourcePackButler/config.yml"), "real"); - Files.createDirectories(plugins.resolve("Packmule.migrating")); - Files.writeString(plugins.resolve("Packmule.migrating/junk"), "half"); - - assertNotNull(Migration.copyIfMissing(plugins.resolve("ResourcePackButler"), plugins.resolve("Packmule"))); - assertEquals("real", Files.readString(plugins.resolve("Packmule/config.yml"))); - assertFalse(Files.exists(plugins.resolve("Packmule/junk"))); - } -} diff --git a/src/test/java/com/extantape/packmule/OldPermissionsTest.java b/src/test/java/com/extantape/packmule/OldPermissionsTest.java deleted file mode 100644 index 1d30f2a..0000000 --- a/src/test/java/com/extantape/packmule/OldPermissionsTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.extantape.packmule; - -import org.bukkit.permissions.Permissible; -import org.junit.jupiter.api.Test; - -import java.lang.reflect.Proxy; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.*; - -class OldPermissionsTest { - - /** A Permissible that has exactly the given nodes. */ - private static Permissible with(String... nodes) { - Set has = Set.of(nodes); - return (Permissible) Proxy.newProxyInstance(Permissible.class.getClassLoader(), new Class[]{Permissible.class}, - (proxy, method, args) -> switch (method.getName()) { - case "hasPermission" -> args[0] instanceof String s && has.contains(s); - case "toString" -> "someone"; - case "hashCode" -> 1; - case "equals" -> proxy == args[0]; - default -> throw new UnsupportedOperationException(method.getName()); - }); - } - - @Test - void theNewNodeWorksAndLogsNothing() { - List log = new ArrayList<>(); - assertTrue(new OldPermissions(log::add).allowed(with("packmule.status"), "packmule.status")); - assertTrue(log.isEmpty()); - } - - @Test - void theOldNodeStillWorksAndLogsOnce() { - List log = new ArrayList<>(); - OldPermissions perms = new OldPermissions(log::add); - Permissible old = with("resourcepackbutler.recheck"); - assertTrue(perms.allowed(old, "packmule.recheck")); - assertTrue(perms.allowed(old, "packmule.recheck")); - assertEquals(1, log.size()); - assertTrue(log.get(0).contains("grant packmule.recheck"), log.get(0)); - } - - @Test - void anOldNodeOnlyCountsForItsOwnNewNode() { - OldPermissions perms = new OldPermissions(line -> {}); - assertFalse(perms.allowed(with("resourcepackbutler.status"), "packmule.reload")); - assertFalse(perms.allowed(with(), "packmule.status")); - } -} diff --git a/src/test/java/com/extantape/packmule/send/PackPlanTest.java b/src/test/java/com/extantape/packmule/send/PackPlanTest.java index 28d8a2a..2fd1aeb 100644 --- a/src/test/java/com/extantape/packmule/send/PackPlanTest.java +++ b/src/test/java/com/extantape/packmule/send/PackPlanTest.java @@ -65,8 +65,8 @@ void idsAreStableAndDistinct() { } @Test - void packIdsAreTheSameAsBeforeTheRename() { - // Clients see these IDs. If they changed, every player would drop and re-download every pack once. - assertEquals(java.util.UUID.fromString("5eac6b96-e786-39ea-badf-55cc15c67d05"), PackPlan.idFor("main")); + void packIdsUseThePackmuleNamespace() { + // Clients see these IDs. If they change, every player re-downloads every pack once (they did in 0.3.0, with the move to packmule:pack:). + assertEquals(java.util.UUID.fromString("7a4d3d03-22bb-36c5-8e61-df5f60b72d5f"), PackPlan.idFor("main")); } } diff --git a/test/harness.mjs b/test/harness.mjs index e407e89..acf2bac 100644 --- a/test/harness.mjs +++ b/test/harness.mjs @@ -231,7 +231,7 @@ export class Bot { /** Packmule's pack ID for a pack name: Java's UUID.nameUUIDFromBytes, a version 3 UUID. */ export function packmuleId(name) { - const h = createHash('md5').update('resourcepackbutler:pack:' + name).digest() + const h = createHash('md5').update('packmule:pack:' + 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)}` diff --git a/test/suite.mjs b/test/suite.mjs index b4139f4..46cea74 100644 --- a/test/suite.mjs +++ b/test/suite.mjs @@ -12,10 +12,10 @@ if (VERSION === '26.3') process.env.PACKMULE_TEST_STILL = '1' // see Bot: 26.3 k const LONG = process.env.PACKMULE_TEST_LONG !== '0' const jar = n => path.join(JARS, n) const plugins = [jar('multiverse-core-5.8.2.jar'), jar('multiverse-inventories-5.3.6.jar'), jar('bluemap-5.29-paper.jar'), - (() => { const d = path.join(process.env.HOME, 'Code/BlueMinimap/build/libs'); const f = readdirSync(d).filter(n => /^BlueMinimap-[\d.]+\.jar$/.test(n)); if (f.length !== 1) throw new Error('expected one BlueMinimap jar in ' + d); return path.join(d, f[0]) })()] + (() => { const d = path.join(process.env.HOME, 'Code/BlueMapMini/build/libs'); const f = readdirSync(d).filter(n => /^BlueMapMini-[\d.]+\.jar$/.test(n)); if (f.length !== 1) throw new Error('expected one BlueMapMini jar in ' + d); return path.join(d, f[0]) })()] plugins.push(jar('ViaVersion-5.12.3-SNAPSHOT.jar'), jar('ViaBackwards-5.12.3-SNAPSHOT.jar')) -const HUD_ID = (() => { // BlueMinimap's fixed pack ID (unchanged from its BlueMapHUD days): UUID.nameUUIDFromBytes("bluemaphud:pack") +const HUD_ID = (() => { // BlueMapMini's fixed pack ID (unchanged from its BlueMapHUD days): UUID.nameUUIDFromBytes("bluemaphud:pack") const h = createHash('md5').update('bluemaphud:pack').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)}` })() @@ -58,11 +58,11 @@ const TEST_TIMEOUT = 'answer-timeout-seconds: 6\n' // ---------- server ---------- const dir = freshServer(`suite-${VERSION}`, { paper: jar(`paper-${VERSION}.jar`), plugins, packmuleConfig: goodConfig(TEST_TIMEOUT) }) -// BlueMinimap hosts its pack on port 8164 by default, which other sessions' test servers may hold. Give it one of ours. -{ const hud = readFileSync(path.join(process.env.HOME, 'Code/BlueMinimap/src/main/resources/config.yml'), 'utf8') - if (!/^ port: 8164$/m.test(hud)) throw new Error("BlueMinimap's default config no longer has port: 8164") - const { mkdirSync } = await import('node:fs'); mkdirSync(path.join(dir, 'plugins/BlueMinimap'), { recursive: true }) - writeFileSync(path.join(dir, 'plugins/BlueMinimap/config.yml'), hud.replace(/^ port: 8164$/m, ' port: 25615')) } +// BlueMapMini hosts its pack on port 8164 by default, which other sessions' test servers may hold. Give it one of ours. +{ const hud = readFileSync(path.join(process.env.HOME, 'Code/BlueMapMini/src/main/resources/config.yml'), 'utf8') + if (!/^ port: 8164$/m.test(hud)) throw new Error("BlueMapMini's default config no longer has port: 8164") + const { mkdirSync } = await import('node:fs'); mkdirSync(path.join(dir, 'plugins/BlueMapMini'), { recursive: true }) + writeFileSync(path.join(dir, 'plugins/BlueMapMini/config.yml'), hud.replace(/^ port: 8164$/m, ' port: 25615')) } const s = new Server({ dir, java: JAVA }) process.on('exit', () => { if (s.proc && s.proc.exitCode === null) s.proc.kill('SIGKILL') }) const packmuleLog = from => s.since(from).filter(l => /\[Packmule\]/.test(l)) @@ -98,8 +98,8 @@ check('no warning about server.properties', !log.some(l => /server\.properties/. for (const w of ['alpha', 'beta', 'gamma', 'bare', 'delta']) await s.ask(`mv create ${w} normal --world-type FLAT --no-structures`, 2500) await s.waitLog(/delta/, 15000) await s.ask('difficulty peaceful', 500) // flat worlds spawn slimes, which kill bots and drop their items -const hudOn = s.log.some(l => /Enabling BlueMinimap/.test(l)) && !s.log.some(l => /Error occurred while enabling BlueMinimap|Could not load.*BlueMinimap/.test(l)) -check('control: BlueMinimap is enabled', hudOn, s.log.filter(l => /BlueMinimap|BlueMap\]/.test(l)).slice(-5).join(' | ')) +const hudOn = s.log.some(l => /Enabling BlueMapMini/.test(l)) && !s.log.some(l => /Error occurred while enabling BlueMapMini|Could not load.*BlueMapMini/.test(l)) +check('control: BlueMapMini is enabled', hudOn, s.log.filter(l => /BlueMapMini|BlueMap\]/.test(l)).slice(-5).join(' | ')) section('Basics') const a = await newBot('Alder') @@ -107,16 +107,11 @@ check('a bot joins', a.spawned) check('on join it gets the world pack, at its per-build address', await settled(a, [['one', one.sha1]]), showStack(a)) check('the address sent is the per-build one', packmuleStack(a)[0]?.url.endsWith(`/b/${one.sha1}.zip`)) const hudSeen = await until(() => a.stack.some(p => p.uuid === HUD_ID && p.state === 'loaded'), 8000) -check('control: BlueMinimap sent and the bot loaded its own pack', hudSeen, showStack(a)) +check('control: BlueMapMini sent and the bot loaded its own pack', hudSeen, showStack(a)) a.chat.length = 0; a.bot.chat('/packmule status'); await sleep(1500) check('a player without permission can\'t use /packmule', !a.chat.some(l => /^Packs/.test(l)), a.chat.join(' | ')) await s.ask(`op ${a.username}`, 800); a.chat.length = 0; a.bot.chat('/packmule status'); await sleep(1500) check('an op can, through the Brigadier command', a.chat.some(l => /^Packs/.test(l)) && a.chat.some(l => /one: live/.test(l)), a.chat.join(' | ')) -for (const old of ['butler', 'resourcepackbutler', 'rpb']) { - let mk = s.mark(); a.chat.length = 0; a.bot.chat(`/${old} status`); await sleep(1500) - check(`the old command /${old} still works for one release`, a.chat.some(l => /^Packs/.test(l)), a.chat.join(' | ')) - check(`and /${old} says it's the old name, in chat and in the log`, a.chat.some(l => /old names for \/packmule/.test(l)) && s.log.slice(mk).some(l => /old names for \/packmule/.test(l))) -} await s.ask(`deop ${a.username}`, 500) let m = a.mark() await s.ask(`tp ${a.username} 20 -60 20`, 1500) @@ -132,7 +127,7 @@ await mvtp(a, 'beta') check('a world with two packs gets both, in order', await settled(a, [['one', one.sha1], ['two', two.sha1]]), showStack(a)) await mvtp(a, 'bare') check('an empty list removes Packmule\'s packs', await until(() => packmuleStack(a).length === 0, 5000), showStack(a)) -check('and leaves BlueMinimap\'s pack alone', !hudSeen || a.stack.some(p => p.uuid === HUD_ID), showStack(a)) +check('and leaves BlueMapMini\'s pack alone', !hudSeen || a.stack.some(p => p.uuid === HUD_ID), showStack(a)) let mark = s.mark() a.chat.length = 0 await mvtp(a, 'delta') @@ -157,22 +152,22 @@ await s.ask(`kill ${a.username}`, 3000) check('respawning in the same world sends nothing', a.since(m).filter(e => e.type !== 'sent').length === 0) check('Packmule never removed a pack it didn\'t send', foreignRemovals(a, 0).length === 0, JSON.stringify(foreignRemovals(a, 0))) -section('BlueMinimap alongside') +section('BlueMapMini alongside') if (hudOn && hudSeen) { m = a.mark() for (const w of ['alpha', 'beta', 'bare', 'world']) await mvtp(a, w, 900) await settled(a, [['one', one.sha1]]) - check('switching worlds never removes or reloads BlueMinimap\'s pack', !a.since(m).some(e => e.uuid === HUD_ID && e.type !== 'sent'), JSON.stringify(a.since(m).filter(e => e.uuid === HUD_ID))) + check('switching worlds never removes or reloads BlueMapMini\'s pack', !a.since(m).some(e => e.uuid === HUD_ID && e.type !== 'sent'), JSON.stringify(a.since(m).filter(e => e.uuid === HUD_ID))) check('its pack is still loaded', a.stack.some(p => p.uuid === HUD_ID && p.state === 'loaded'), showStack(a)) m = a.mark() - await s.ask('blueminimap reload', 3000) + await s.ask('bluemapmini reload', 3000) const hudResent = a.since(m).some(e => e.type === 'add' && e.uuid === HUD_ID) - console.log(` note: /blueminimap reload ${hudResent ? 're-sent' : 'did not re-send'} its pack`) + console.log(` note: /bluemapmini reload ${hudResent ? 're-sent' : 'did not re-send'} its pack`) for (const w of ['alpha', 'world']) await mvtp(a, w, 1500) - check('after BlueMinimap reloads, Packmule\'s packs still follow the world', await settled(a, [['one', one.sha1]]), showStack(a)) - check('and BlueMinimap\'s pack is still there', a.stack.some(p => p.uuid === HUD_ID), showStack(a)) - check('Packmule sent no removal for BlueMinimap\'s pack', foreignRemovals(a, 0).length === 0) -} else check('BlueMinimap test skipped: it isn\'t enabled', false) + check('after BlueMapMini reloads, Packmule\'s packs still follow the world', await settled(a, [['one', one.sha1]]), showStack(a)) + check('and BlueMapMini\'s pack is still there', a.stack.some(p => p.uuid === HUD_ID), showStack(a)) + check('Packmule sent no removal for BlueMapMini\'s pack', foreignRemovals(a, 0).length === 0) +} else check('BlueMapMini test skipped: it isn\'t enabled', false) section('Hash updates') await mvtp(a, 'world'); await settled(a, [['one', one.sha1]]) @@ -231,7 +226,7 @@ await sleep(4000) check('a bot that never answers isn\'t kicked early', !e.ended) await until(() => e.ended, 8000) check('and is kicked after the timeout, with a clear message', e.ended && /prompt timed out/.test(e.kicked ?? '') && /within 6 seconds/.test(e.kicked ?? ''), e.kicked) -const f = await newBot('Fir', { mode: 'fail', only: PACK_IDS }) // BlueMinimap kicks on its own failed downloads +const f = await newBot('Fir', { mode: 'fail', only: PACK_IDS }) // BlueMapMini kicks on its own failed downloads await sleep(9000) check('a bot whose downloads fail is retried, then let in', !f.ended && f.since(0, 'add').filter(x => x.uuid === ID.one).length === 3, `${f.since(0, 'add').filter(x => x.uuid === ID.one).length} sends, ended ${f.ended}, ${f.kicked}`) check('and is told, once, that the pack couldn\'t be downloaded', f.chat.filter(l => /couldn't be downloaded/.test(l)).length === 1, f.chat.join(' | ')) @@ -248,7 +243,7 @@ check('after hopping faster than a pack loads, the final packs match the final w for (let i = 0; i < 10; i++) { s.cmd(`mvtp ${a.username} ${i % 2 ? 'alpha' : 'world'}`); await sleep(100) } check('back and forth, too', await settled(a, [['two', two.sha1]], 15000), showStack(a)) check('nobody was kicked for it', !a.ended) -check('the BlueMinimap pack survived the hopping', !hudSeen || a.stack.some(p => p.uuid === HUD_ID), showStack(a)) +check('the BlueMapMini pack survived the hopping', !hudSeen || a.stack.some(p => p.uuid === HUD_ID), showStack(a)) a.loadMs = 150 section('Timing') @@ -324,26 +319,6 @@ for (const x of bots) await x.quit() await s.stop() writeProps(dir) -section('Upgrade from ResourcePackButler') -{ - const now = path.join(dir, 'plugins/Packmule'), old = path.join(dir, 'plugins/ResourcePackButler') - const sha = f => createHash('sha256').update(readFileSync(f)).digest('hex') - const files = readdirSync(now).filter(f => !statSync(path.join(now, f)).isDirectory()) - const before = Object.fromEntries(files.map(f => [f, sha(path.join(now, f))])) - renameSync(now, old) - await s.start(); await s.waitLog(/packs checked/, 20000) - check('plugins/ResourcePackButler is copied to plugins/Packmule on first start, byte for byte', - files.length > 0 && files.every(f => existsSync(path.join(now, f)) && sha(path.join(now, f)) === before[f]), files.join(',')) - check('the old folder is left as it was', files.every(f => sha(path.join(old, f)) === before[f])) - check('the log says what was copied', s.log.some(l => /Copied plugins\/ResourcePackButler to plugins\/Packmule/.test(l))) - check('the copied config is the one in use', !s.log.some(l => /sends no packs until it's fixed/.test(l)) && s.log.some(l => /packs checked/.test(l))) - await s.stop() - writeFileSync(path.join(old, 'config.yml'), 'packs: [broken') - await s.start(); await s.waitLog(/packs checked/, 20000) - check('a second start never copies again', !s.log.some(l => /Copied plugins\/ResourcePackButler/.test(l)) && sha(path.join(now, 'config.yml')) === before['config.yml']) - await s.stop() -} - section('Errors') check('no exceptions from Packmule in the log', s.exceptions().length === 0, s.exceptions().slice(0, 5).join('\n')) console.log(`\nPaper ${VERSION}, ${Math.round((Date.now() - t0) / 1000)} s`)