From d82743471a9334ae9d690b87c489fdcee83ef58e Mon Sep 17 00:00:00 2001 From: Michael Gilardi <11632652+mgilardi@users.noreply.github.com> Date: Sat, 14 Nov 2026 00:02:36 -0700 Subject: [PATCH] Tell Rewild what Voidwell owns, so it's never rewilded With Rewild installed and Voidwell respected (/rewild plugins), Voidwell reports every OneBlock island's chunks whole. Rewild's interface is reached by name, so neither plugin needs the other, and a Rewild before 0.3.0 gets whole chunks only. A setting turns it off. Part of the keep list every ExtantApe plugin that owns blocks offers Rewild (K346). Tested by unit tests against stand-ins shaped like Rewild 0.2 and 0.3.0. Not yet run live with Rewild; that's planned on the test copy. Co-Authored-By: Claude Opus 5.5 --- .../com/extantape/voidwell/RewildKeeps.java | 111 ++++++++++++++++++ .../extantape/voidwell/VoidwellPlugin.java | 11 ++ .../voidwell/oneblock/OneBlockBridge.java | 31 +++++ src/main/resources/config.yml | 5 + .../extantape/voidwell/RewildKeepsTest.java | 61 ++++++++++ 5 files changed, 219 insertions(+) create mode 100644 src/main/java/com/extantape/voidwell/RewildKeeps.java create mode 100644 src/test/java/com/extantape/voidwell/RewildKeepsTest.java diff --git a/src/main/java/com/extantape/voidwell/RewildKeeps.java b/src/main/java/com/extantape/voidwell/RewildKeeps.java new file mode 100644 index 0000000..8b74b1a --- /dev/null +++ b/src/main/java/com/extantape/voidwell/RewildKeeps.java @@ -0,0 +1,111 @@ +package com.extantape.voidwell; + +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.server.PluginEnableEvent; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.ServicePriority; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +/** + * Tells Rewild, if it's installed, what this plugin owns, so Rewild never rewilds it: whole chunks to leave alone, and + * block boxes to keep in place while the land around them rewilds. Rewild's interface is reached by name through its + * own class loader, so neither plugin needs the other. The same small class is in each ExtantApe plugin that owns + * blocks; keep the copies alike. + * + *

Rewild only listens once an admin respects this plugin (/rewild plugins). If anything here throws, Rewild resets + * nothing in that world, which is the safe side. + */ +public final class RewildKeeps implements Listener { + static final String PROVIDER = "com.extantape.rewild.api.KeepProvider"; + + /** A box of blocks, corners included, with the reason /rewild why shows. */ + public record Box(int minX, int minY, int minZ, int maxX, int maxY, int maxZ, String reason) {} + + /** A chunk to keep whole, with its reason. */ + public record Chunk(int x, int z, String reason) {} + + private final Plugin owner; + private final Function> chunks; + private final Function> boxes; + private boolean registered; + + public RewildKeeps(Plugin owner, Function> chunks, Function> boxes) { + this.owner = owner; + this.chunks = chunks; + this.boxes = boxes; + } + + /** Registers now if Rewild is already running; otherwise when it enables. */ + public void start() { + Plugin rewild = Bukkit.getPluginManager().getPlugin("Rewild"); + if (rewild != null && rewild.isEnabled()) register(rewild); + } + + @EventHandler + public void onEnable(PluginEnableEvent e) { + if (e.getPlugin().getName().equals("Rewild")) register(e.getPlugin()); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private void register(Plugin rewild) { + if (registered) return; + try { + ClassLoader cl = rewild.getClass().getClassLoader(); + Class iface = Class.forName(PROVIDER, true, cl); + Object provider = provider(iface, chunks, boxes); + Bukkit.getServicesManager().register((Class) iface, provider, owner, ServicePriority.Normal); + registered = true; + owner.getLogger().info("Telling Rewild what " + owner.getName() + " owns, so it's never rewilded."); + } catch (ReflectiveOperationException | RuntimeException | LinkageError e) { + owner.getLogger().warning("Rewild is installed but its keep interface doesn't match what " + owner.getName() + + " expects (" + e + "). If an admin respects " + owner.getName() + ", Rewild will reset nothing."); + } + } + + /** A KeepProvider for the given interface. Package-private for tests, which pass a stand-in of the same shape. */ + static Object provider(Class iface, Function> chunks, Function> boxes) + throws ReflectiveOperationException { + Class keepClass = Class.forName(iface.getName() + "$Keep", true, iface.getClassLoader()); + Constructor keep = keepClass.getDeclaredConstructor(int.class, int.class, String.class); + Constructor area; + try { + Class areaClass = Class.forName(iface.getName() + "$Area", true, iface.getClassLoader()); + area = areaClass.getDeclaredConstructor(int.class, int.class, int.class, int.class, int.class, int.class, String.class); + } catch (ClassNotFoundException e) { + area = null; // Rewild before 0.3.0: whole chunks only. + } + Constructor makeArea = area; + return Proxy.newProxyInstance(iface.getClassLoader(), new Class[] {iface}, (proxy, method, args) -> switch (method.getName()) { + case "keeps" -> { + List out = new ArrayList<>(); + for (Chunk c : chunks.apply((World) args[0])) out.add(keep.newInstance(c.x(), c.z(), c.reason())); + yield out; + } + case "areas" -> { + List out = new ArrayList<>(); + if (makeArea != null) { + for (Box b : boxes.apply((World) args[0])) { + out.add(makeArea.newInstance(b.minX(), b.minY(), b.minZ(), b.maxX(), b.maxY(), b.maxZ(), b.reason())); + } + } + yield out; + } + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + case "toString" -> owner(proxy); + default -> throw new UnsupportedOperationException(method.getName()); + }); + } + + private static String owner(Object proxy) { + return "keeps for Rewild"; + } +} diff --git a/src/main/java/com/extantape/voidwell/VoidwellPlugin.java b/src/main/java/com/extantape/voidwell/VoidwellPlugin.java index 64d5e01..8f74540 100644 --- a/src/main/java/com/extantape/voidwell/VoidwellPlugin.java +++ b/src/main/java/com/extantape/voidwell/VoidwellPlugin.java @@ -70,6 +70,17 @@ public void onEnable() { new MenuListener(), new CommandGuard())) pm.registerEvents(l, this); + if (getConfig().getBoolean("rewild.keep-islands", true)) { + // OneBlock's world is void: a rewild there would erase every island the one block built. + RewildKeeps keeps = new RewildKeeps(this, w -> { + List out = new java.util.ArrayList<>(); + for (long[] c : bridge.islandChunks(w)) out.add(new RewildKeeps.Chunk((int) c[0], (int) c[1], "a OneBlock island")); + return out; + }, w -> List.of()); + pm.registerEvents(keeps, this); + keeps.start(); + } + getLifecycleManager().registerEventHandler(LifecycleEvents.COMMANDS, e -> e.registrar().register(VoidwellCommand.build(this), "Your void meter and rewards", List.of())); diff --git a/src/main/java/com/extantape/voidwell/oneblock/OneBlockBridge.java b/src/main/java/com/extantape/voidwell/oneblock/OneBlockBridge.java index 7401f8a..652d454 100644 --- a/src/main/java/com/extantape/voidwell/oneblock/OneBlockBridge.java +++ b/src/main/java/com/extantape/voidwell/oneblock/OneBlockBridge.java @@ -43,6 +43,37 @@ public Location oneBlock(int island) { return new Location(world(), xz[0], OneBlock.getY(), xz[1]); } + /** How many islands OneBlock has handed out, numbered from 0. */ + public int islandCount() { + return PlayerInfo.list.size(); + } + + /** + * Every chunk an island's square covers, for Rewild: OneBlock's world is void, so a rewild there would erase islands + * the one block built. Empty unless this is OneBlock's world. Package-free math is in {@link #squareChunks}. + */ + public java.util.List islandChunks(World w) { + java.util.List out = new java.util.ArrayList<>(); + World ob = world(); + if (ob == null || !ob.getUID().equals(w.getUID())) return out; + java.util.Set seen = new java.util.HashSet<>(); + for (int i = 0; i < islandCount(); i++) { + int[] xz = OneBlock.plugin.getIslandCoordinates(i); + for (long[] c : squareChunks(xz[0], xz[1], spacing())) if (seen.add((c[0] << 32) ^ (c[1] & 0xffffffffL))) out.add(c); + } + return out; + } + + /** The chunks of the square of side {@code spacing} centred on an island's one block. */ + public static java.util.List squareChunks(int x, int z, int spacing) { + int half = Math.max(16, spacing / 2); + java.util.List out = new java.util.ArrayList<>(); + for (int cx = (x - half) >> 4; cx <= (x + half) >> 4; cx++) { + for (int cz = (z - half) >> 4; cz <= (z + half) >> 4; cz++) out.add(new long[]{cx, cz}); + } + return out; + } + /** Distance between islands, in blocks. */ public int spacing() { return Math.abs(OneBlock.getOffset()); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index c85569f..01ee493 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -67,6 +67,11 @@ social: max-team-size: 4 # Saves after a death or a bell trip, so a crash can't roll a player back into a duplicate or a loss. +# With Rewild installed, tells it to leave every OneBlock island alone. OneBlock's world is void, so a rewild there +# would erase what the one block built. Rewild listens once an admin respects Voidwell with /rewild plugins. +rewild: + keep-islands: true + crash-safety: # Also save the OneBlock world after a death on an island and after a trip out by the bell, so the world on disk # always matches the saved inventories. Without it, a crash could bring back items a player had already picked up. diff --git a/src/test/java/com/extantape/voidwell/RewildKeepsTest.java b/src/test/java/com/extantape/voidwell/RewildKeepsTest.java new file mode 100644 index 0000000..5ae7509 --- /dev/null +++ b/src/test/java/com/extantape/voidwell/RewildKeepsTest.java @@ -0,0 +1,61 @@ +package com.extantape.voidwell; + +import org.bukkit.World; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** What this plugin tells Rewild, through stand-ins shaped exactly like Rewild's KeepProvider before and after 0.3.0. */ +class RewildKeepsTest { + /** Same shape as com.extantape.rewild.api.KeepProvider in Rewild 0.3.0. */ + public interface Provider030 { + record Keep(int chunkX, int chunkZ, String reason) {} + record Area(int minX, int minY, int minZ, int maxX, int maxY, int maxZ, String reason) {} + Collection keeps(World world); + default Collection areas(World world) { + return List.of(); + } + } + + /** Rewild 0.2: whole chunks only. */ + public interface Provider020 { + record Keep(int chunkX, int chunkZ, String reason) {} + Collection keeps(World world); + } + + static World world(String name) { + return (World) Proxy.newProxyInstance(World.class.getClassLoader(), new Class[] {World.class}, + (p, m, a) -> m.getName().equals("getName") ? name : null); + } + + static final List CHUNKS = List.of(new RewildKeeps.Chunk(1, -2, "the station")); + static final List BOXES = List.of(new RewildKeeps.Box(1, 60, 1, 5, 70, 3, "a gate")); + + @Test void chunksAndBoxesReachRewild030() throws Exception { + Provider030 p = (Provider030) RewildKeeps.provider(Provider030.class, w -> CHUNKS, w -> BOXES); + assertEquals(List.of(new Provider030.Keep(1, -2, "the station")), List.copyOf(p.keeps(world("w")))); + assertEquals(List.of(new Provider030.Area(1, 60, 1, 5, 70, 3, "a gate")), List.copyOf(p.areas(world("w")))); + } + + @Test void olderRewildGetsChunksOnly() throws Exception { + Provider020 p = (Provider020) RewildKeeps.provider(Provider020.class, w -> CHUNKS, w -> BOXES); + assertEquals(List.of(new Provider020.Keep(1, -2, "the station")), List.copyOf(p.keeps(world("w")))); + } + + @Test void aFailureReachesRewildSoItResetsNothing() throws Exception { + Provider030 p = (Provider030) RewildKeeps.provider(Provider030.class, w -> { throw new IllegalStateException("broken"); }, w -> BOXES); + assertThrows(IllegalStateException.class, () -> p.keeps(world("w"))); + } + + @Test void anIslandsSquareCoversItsChunks() { + var c = com.extantape.voidwell.oneblock.OneBlockBridge.squareChunks(0, 0, 100); + // 100-block spacing: 50 each way from 0,0, so chunks -4..3 on each axis. + assertEquals(64, c.size()); + assertTrue(c.stream().anyMatch(a -> a[0] == -4 && a[1] == 3)); + assertTrue(c.stream().noneMatch(a -> a[0] == 4)); + } +}