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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
15 changes: 3 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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-<version>.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-<version>.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

Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion 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.2.1"
version = "0.3.0"

java {
toolchain.languageVersion.set(JavaLanguageVersion.of(25))
Expand Down
1 change: 1 addition & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pack> <url> --json <id>`, 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:<name>, so clients download each pack once more.
14 changes: 14 additions & 0 deletions docs/releases/v0.3.0.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 2 additions & 3 deletions docs/test-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" |
Expand All @@ -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.

Expand Down
48 changes: 0 additions & 48 deletions src/main/java/com/extantape/packmule/Migration.java

This file was deleted.

31 changes: 0 additions & 31 deletions src/main/java/com/extantape/packmule/OldPermissions.java

This file was deleted.

11 changes: 2 additions & 9 deletions src/main/java/com/extantape/packmule/Packmule.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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<String> dead = new ArrayList<>();
for (String name : config.packs().keySet()) {
Outcome outcome = outcome(name);
Expand Down Expand Up @@ -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); }
}
Loading
Loading