From 2233cf14cbf547f0f39fe8f4c8dd393429af9e2d Mon Sep 17 00:00:00 2001 From: Michael Gilardi <11632652+mgilardi@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:51:30 -0700 Subject: [PATCH 1/2] Rewild block by block: fresh terrain, with every placed block kept The owner's redesign (approved 2026-11-12): everything rewilds except what's kept. A chunk that's mined out and abandoned is rebuilt from a fresh copy of the seed, block by block, and every player-placed block, filled container, dirt path, keep-blocks type and kept area stays in place with the block holding it up. Player builds no longer keep a whole chunk; claims, spawn, respawn points, pets and nearby players still do. Nothing resets until an admin has answered for every installed plugin with /rewild plugins. Respected plugins report through KeepProvider (now with block areas) or /rewild keep. Rewild's own tracking finds placed blocks, so CoreProtect isn't needed. Chunks tracked by 0.2.x stay unknown. /wr and the old permission are removed, as announced. Tested: 82 unit tests; the bot acceptance suite on throwaway Paper 26.3 (build 249) and 26.2 servers, 113 checks each; the provider test on 26.3, 9 checks. The first 26.3 run found two save-format bugs in the merge, fixed here with tests. Not seen in a real client; not run on 26.1.2. Co-Authored-By: Claude Opus 5.5 --- README.md | 143 +++++---- build.gradle.kts | 2 +- docs/decisions.md | 7 + docs/releases/v0.3.0.md | 17 ++ docs/test-audit.md | 26 ++ .../com/extantape/rewild/RewildPlugin.java | 38 ++- .../extantape/rewild/api/KeepProvider.java | 41 ++- .../java/com/extantape/rewild/cmd/Json.java | 25 ++ .../extantape/rewild/cmd/RewildCommand.java | 220 +++++++++++-- .../extantape/rewild/config/RegenConfig.java | 21 +- .../extantape/rewild/keep/CommandKeeps.java | 116 +++++++ .../com/extantape/rewild/keep/KeepPlan.java | 82 +++++ .../com/extantape/rewild/keep/Keepers.java | 46 ++- .../com/extantape/rewild/keep/Respect.java | 120 ++++++++ .../com/extantape/rewild/region/KeepMask.java | 176 +++++++++++ .../com/extantape/rewild/region/Merge.java | 289 ++++++++++++++++++ .../java/com/extantape/rewild/region/Nbt.java | 4 + .../extantape/rewild/region/Underground.java | 109 +------ .../com/extantape/rewild/run/Applier.java | 81 +++-- .../java/com/extantape/rewild/run/Run.java | 5 + .../com/extantape/rewild/run/RunService.java | 27 +- .../rewild/scan/ContextSnapshot.java | 13 + .../extantape/rewild/scan/IntentRules.java | 47 --- .../extantape/rewild/scan/ScanService.java | 5 +- .../com/extantape/rewild/scan/Scanner.java | 127 ++------ .../extantape/rewild/store/ChunkRecord.java | 17 +- .../rewild/track/TrackerListener.java | 39 ++- .../com/extantape/rewild/track/Tracking.java | 25 +- src/main/resources/config.yml | 59 +--- src/main/resources/paper-plugin.yml | 5 +- .../rewild/config/RegenConfigTest.java | 17 +- .../rewild/keep/RespectAndKeepsTest.java | 105 +++++++ .../extantape/rewild/region/KeepMaskTest.java | 92 ++++++ .../extantape/rewild/region/MergeTest.java | 264 ++++++++++++++++ .../com/extantape/rewild/run/ApplierTest.java | 31 +- .../extantape/rewild/scan/ScannerTest.java | 78 +++-- test/acceptance.mjs | 124 +++++--- test/fixtures/keepstub/src/stub/KeepStub.java | 31 +- test/harness.mjs | 3 +- test/keep.mjs | 47 ++- 40 files changed, 2138 insertions(+), 586 deletions(-) create mode 100644 docs/releases/v0.3.0.md create mode 100644 src/main/java/com/extantape/rewild/cmd/Json.java create mode 100644 src/main/java/com/extantape/rewild/keep/CommandKeeps.java create mode 100644 src/main/java/com/extantape/rewild/keep/KeepPlan.java create mode 100644 src/main/java/com/extantape/rewild/keep/Respect.java create mode 100644 src/main/java/com/extantape/rewild/region/KeepMask.java create mode 100644 src/main/java/com/extantape/rewild/region/Merge.java delete mode 100644 src/main/java/com/extantape/rewild/scan/IntentRules.java create mode 100644 src/test/java/com/extantape/rewild/keep/RespectAndKeepsTest.java create mode 100644 src/test/java/com/extantape/rewild/region/KeepMaskTest.java create mode 100644 src/test/java/com/extantape/rewild/region/MergeTest.java diff --git a/README.md b/README.md index 9bc0639..eda2985 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,81 @@ # Rewild -A Paper plugin that quietly resets abandoned, mined-out chunks so survival worlds stay wild. It never removes anything that looks intentional. When in doubt, it keeps the chunk. +A Paper plugin that rewilds abandoned, mined-out land back to fresh terrain from the world's seed, and keeps every block players placed exactly where it is. Paths, builds, chests and protected places stay; the holes, quarries and craters around them fill back in. -**Status:** pre-release. It has passed its tests on throwaway Paper servers with a bot as the player (see [Testing](#testing)). It hasn't run on a real server, and nobody has looked at a reset area in a real game client yet. +**Status:** pre-release. 0.3.0 is a redesign and has passed its tests on throwaway Paper 26.3 servers with a bot as the player (see [Testing](#testing)). It hasn't run on a real server, and nobody has looked at a rewilded area in a real game client yet. - Paper 26.1 and newer, Java 25. Not Spigot, and not 1.21.x. - Off in every world until you turn it on. Dry run by default. Worlds in `never-reset-worlds`, such as kids' worlds, can never be turned on by mistake. -- Nothing resets until an admin approves a run, and every run can be undone. +- Nothing resets until an admin has said which plugins to respect, an admin approves a run, and every run can be undone. ## How it works -1. **Track.** From the moment a world is enabled, the plugin records what players change in each chunk: blocks they break and place, paths they make with a shovel, containers they put things in, and explosion damage. Growth, decay and fire don't count. -2. **Scan.** `/rewild scan` scores every chunk the tracker has seen damaged and checks it against the keep rules below. Chunks that pass go into a proposed run. -3. **Approve.** An admin checks the run with `/rewild preview` and `/rewild why`, then approves it with `/rewild approve`. -4. **Reset.** At the next restart, before any world loads, the plugin checks every chunk again with the server stopped. Then it removes the chunks from the world's files, so they generate fresh from the seed. Players in the world get a vague warning when a run is approved, which doesn't say where. +1. **Track.** From the moment a world is enabled, the plugin records what players change in each chunk: every block they place, blocks they break, paths they make with a shovel, containers they put things in, and explosion damage. Growth, decay and fire don't count. +2. **Scan.** `/rewild scan` checks every chunk the tracker has seen damaged. Chunks that are mined out and abandoned go into a proposed run. +3. **Approve.** An admin checks the run with `/rewild preview` and `/rewild why`, then approves it with `/rewild approve`. Rewild then generates a fresh copy of each chunk in a temporary world with the same seed and generator, saves it with the run, and deletes that world. +4. **Rewild.** At the next restart, before any world loads, the plugin checks every chunk again with the server stopped. Then it rebuilds each one from the fresh copy, block by block, except what's kept, which it copies over exactly as it was, contents and all. Players in the world get a vague warning when a run is approved, which doesn't say where. -A chunk generated before the world was enabled is **unknown**. The plugin can't tell what a player built there from what was always there, so it never resets an unknown chunk. On a world that's already been played, only land explored after install can ever reset. +A chunk generated before the world was enabled is **unknown**. The plugin can't tell what a player built there from what was always there, so it never rewilds an unknown chunk. On a world that's already been played, only land explored after install can ever rewild. -### What keeps a chunk +### What stays in place -Any one of these keeps a chunk, plus one chunk around it: +When a chunk rewilds, these stay exactly as they are, and the rest of the chunk becomes fresh terrain: + +- Every block a player placed: builds, chests, beds, signs, torches, farms, a bridge. +- Containers a player put items in, even natural ones. +- Every block of a type in `keep-blocks`. That's dirt paths by default, so paths stay wherever they are. Add your own, such as `OAK_FENCE` or `*LANTERN`. +- Block areas an admin keeps with `/rewild keep`, and areas a respected plugin reports. +- What holds each of those up: the block under it, the block a lantern hangs from or a torch is fixed to, and the other half of a door or bed. Only one block deep, because the fresh terrain usually brings the rest back. + +### What keeps a whole chunk + +These leave the whole chunk alone, plus one chunk around it: - A GriefPrevention claim, or within one chunk of one. -- A player-made dirt path, once about 3 path blocks connect. Paths across a chunk border keep both chunks. A lone accidental path doesn't count, and neither do village paths. -- A player-placed bed, chest, barrel, sign, banner, door, rail, redstone, portal frame and the like. Natural ones, such as village beds and mineshaft rails, don't count. -- Player-placed building blocks adding up to a score of 8. A small cabin passes. A stray torch or crafting table doesn't. -- A container a player put items in, even a natural one. - Named, tamed or leashed animals, villagers who have traded, allays, armor stands and item frames. - World spawn, every player's bed or respawn point, and any player online nearby. - Strongholds, and the End's main island. -- Areas an admin marked with `/rewild protect`, Multiverse portals, and anything another plugin reports (see [For plugin authors](#for-plugin-authors)). -- A neighbour generated before tracking began. A reset chunk only blends with neighbours made by the same generator. +- Areas an admin marked with `/rewild protect`, Multiverse portals, and whole chunks a respected plugin reports. +- A neighbour generated before tracking began. A rewilded chunk only blends with neighbours made by the same generator. + +### Which plugins are respected + +On first start Rewild lists every installed plugin and resets nothing until an admin has answered for each one: `/rewild plugins` shows them with buttons. Respect a plugin and everything it reports stays. Ignore it and its reports don't count. `/rewild plugins ignore-rest` answers the rest at once. Answers are kept in `plugins/Rewild/keep-plugins.yml`. + +A plugin installed later has no answer, so resets stop again until an admin looks at it. A respected plugin that isn't running stops resets too, because Rewild can't ask it what to keep. -### When a chunk can reset +### When a chunk can rewild All of these must hold, and every number is in `config.yml`: -- At least 40 blocks removed by players, or an explosion crater of 12 blocks. +- At least 40 blocks removed by players, or an explosion crater of 12 blocks. Removing your own placed blocks doesn't count. - No player changes for 30 days. - No player in or near it for 7 days. -- At least 30 days since it last reset. +- At least 30 days since it last rewilded. Admins in spectator or creative mode don't count as visitors, so looking around before approving doesn't hold a run back. ### Below-ground repair -Sometimes the surface is kept, because someone lives there, but the ground under it is mined out and abandoned. Then only the 16-block sections below a cut reset, and the surface stays exactly as it is. The cut sits at least 8 blocks below the lowest point of the surface, rounded down to a section. +Sometimes the chunk is kept whole, by a pet or a respawn point, or someone still uses the surface, but the ground under it is mined out and abandoned. Then only the ground below a cut rewilds, and everything above it stays exactly as it is. The cut sits at least 8 blocks below the lowest point of the surface, rounded down to a 16-block section. Player-placed blocks below the cut stay in place too. -When the run is approved, the plugin generates a fresh copy of each such chunk in a temporary world with the same seed and generator, then deletes that world. At the restart it swaps in the sections below the cut. Before it does, it checks the bedrock pattern of the copy against the real chunk, and it refuses if they differ. A repair doesn't happen if anything kept reaches below the cut, if players changed or visited anything down there recently, or if the chunk is in a claim, at spawn or in an admin-protected area. It only runs in overworld-type worlds. +Before either kind of change, the plugin checks the bedrock pattern of the fresh copy against the real chunk, and refuses if they differ. A repair doesn't happen if anything that keeps the chunk whole reaches below the cut, if players changed or visited anything down there recently, or if the chunk is in a claim, at spawn or in an admin-protected area. It only runs in overworld-type worlds. ## Safety - **Dry run by default.** With `dry-run: true`, an approved run is checked at the next restart and reported in the log, and no world file changes. -- **Fail closed.** If the claim plugin isn't running, the tracker can't save, config.yml has an error, or another plugin can't report what it needs kept, nothing resets, and `/rewild scan` says why. -- **A final check at shutdown.** Claims, respawn points and other plugins are read when the server stops, and the startup step uses that reading. If the server didn't shut down cleanly, the run waits for a clean restart. +- **Fail closed.** If the claim plugin isn't running, the tracker can't save, config.yml has an error, a plugin has no answer, or a respected plugin can't report what it keeps, nothing resets, and `/rewild scan` says why. +- **A final check at shutdown.** Claims, respawn points, kept areas and other plugins are read when the server stops, and the startup step uses that reading. If the server didn't shut down cleanly, the run waits for a clean restart. - **Generator fingerprint.** The plugin saves the game version, seed, generator and a hash of the datapacks. If any of them changes, resets stop and the log names the change with a sample of affected chunks. After checking, `/rewild approve generator ` carries on, and chunks generated before the change stay unknown. - **Capped.** At most 200 chunks a run. Connected groups go first. - **Trash and undo.** Every region file a run touches is copied to `plugins/Rewild/trash///` first, at the same path it has under the server folder. Copies are kept 30 days. `/rewild undo ` puts the run's chunks back at the next restart, including their entities and points of interest, and discards anything built there since. -- **Crash-safe.** Each file is rewritten through a temporary file and an atomic rename. A journal records progress, so a crash at any point either finishes the run at the next start or rolls it back from the trash copies. +- **Crash-safe.** Each file is rewritten through a temporary file and an atomic rename. A journal records progress, so a crash at any point either finishes the run at the next start or rolls it back from the trash copies. What's kept is worked out again from the trash copy, so a resumed run keeps exactly the same blocks. ## Install 1. Put `Rewild-.jar` in `plugins/`, next to GriefPrevention. -2. Start the server once, then enable a world in `plugins/Rewild/config.yml`: +2. Start the server once. Answer for each plugin with `/rewild plugins`. +3. Enable a world in `plugins/Rewild/config.yml`: ```yaml worlds: @@ -70,75 +83,93 @@ When the run is approved, the plugin generates a fresh copy of each such chunk i enabled: true ``` -3. Restart. Tracking starts then. Nothing can reset until the tracker has watched a chunk from the moment it was generated, and the idle times have passed. -4. When you're ready, set `dry-run: false`. +4. Restart. Tracking starts then. Nothing can rewild until the tracker has watched a chunk from the moment it was generated, and the idle times have passed. +5. When you're ready, set `dry-run: false`. If the server has no claim plugin at all, set `claims.plugin: none`. +**Upgrading from 0.2.x:** 0.2.x only recorded some placed blocks, so chunks it tracked stay unknown and never rewild. Only land generated after the upgrade can. The `intent` settings in config.yml do nothing now and can be deleted. `/wr`, `/wildernessregen` and the `wildernessregen.admin` permission are gone; use `/rewild` and `rewild.admin`. + ## Commands All commands need `rewild.admin`, which ops have. Players get no commands. | Command | What it does | | --- | --- | -| `/rewild status` | Shows each world's tracked and unknown chunks, its health, and recent runs. | -| `/rewild why [world] [x z]` | Explains why a chunk is kept, waiting, unknown or queued. It defaults to the chunk you're standing in, and takes chunk coordinates. | -| `/rewild preview [radius]` | Outlines nearby chunks in particles for 30 seconds: red resets, orange resets below ground, green kept, yellow waiting, grey unknown. | +| `/rewild status` | Shows each world's tracked and unknown chunks, its health, recent runs, and whether plugins still need an answer. | +| `/rewild why [world] [x z]` | Explains why a chunk is kept, waiting, unknown or queued, and what stays in place if it rewilds. It defaults to the chunk you're standing in, and takes chunk coordinates. | +| `/rewild preview [radius]` | Outlines nearby chunks in particles for 30 seconds: red rewilds, orange rewilds below ground, green kept, yellow waiting, grey unknown. | | `/rewild scan [world]` | Scans and proposes a run. | -| `/rewild approve ` | Approves a run for the next restart. | +| `/rewild approve ` | Makes fresh terrain for the run and approves it for the next restart. | | `/rewild approve generator ` | Accepts a changed world generator. | | `/rewild undo ` | Cancels an approved run, or undoes an applied one at the next restart. | -| `/rewild protect [radius] [note]` | Keeps the chunks around you forever. `/rewild protect list` and `/rewild protect remove` manage them. | -| `/rewild reload` | Reloads config.yml. | +| `/rewild protect [radius] [note]` | Keeps the chunks around you whole, forever. `/rewild protect list` and `/rewild protect remove` manage them. | +| `/rewild keep [] [plugin:] [note]` | Keeps a block, or a box of blocks, exactly as it is when the land around it rewilds. | +| `/rewild keep list [world] [--json]` | Lists kept areas, with who kept them and whether they count. | +| `/rewild unkeep ` | Removes a kept area. `/rewild unkeep [] [plugin:]` removes one by its exact corners. | +| `/rewild plugins [--json]` | Lists installed plugins and which Rewild respects. `respect ...`, `ignore ...` and `ignore-rest` answer. | +| `/rewild reload` | Reloads config.yml, keep-plugins.yml and the kept areas. | With `bluemap.enabled: true`, the last scan also appears as a BlueMap layer. It's off by default, because kept chunks show where people build and sleep. Only turn it on for a map that only admins can open. ## For plugin authors -Another plugin can keep chunks by registering a `KeepProvider` with Bukkit's services manager: +Another plugin can keep what it owns in two ways, and Rewild listens once an admin respects it. + +**A service.** Register a `KeepProvider` with Bukkit's services manager. `keeps` reports whole chunks to leave alone; `areas` reports block boxes to keep in place while the rest of the chunk rewilds: ```java -getServer().getServicesManager().register(KeepProvider.class, - world -> graves.chunksIn(world).stream().map(c -> new KeepProvider.Keep(c.x(), c.z(), "an active grave")).toList(), - this, ServicePriority.Normal); +getServer().getServicesManager().register(KeepProvider.class, new KeepProvider() { + public Collection keeps(World world) { + return graves.chunksIn(world).stream().map(c -> new Keep(c.x(), c.z(), "an active grave")).toList(); + } + public Collection areas(World world) { + return homes.in(world).stream().map(h -> new Area(h.minX(), h.minY(), h.minZ(), h.maxX(), h.maxY(), h.maxZ(), "a homestead")).toList(); + } +}, this, ServicePriority.Normal); ``` -If a provider throws, nothing in that world resets. If Lantern Graves is running and hasn't registered one, nothing resets either. +**A command**, with no compile-time dependency: `Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "rewild keep world 10 64 10 20 80 20 plugin:MyPlugin a homestead")`, and `rewild unkeep` with the same corners to undo it. + +If a respected provider throws, nothing in that world resets. If LanternGraves is respected and running but hasn't registered a provider, nothing resets either. ## Known limits -- Only chunks explored after install can ever reset. History from CoreProtect can't be imported yet. +- Only chunks explored after install (and, for 0.2.x worlds, after the upgrade) can ever rewild. History from CoreProtect can't be imported, and Rewild doesn't use CoreProtect. - A chunk at the edge of explored land waits until the land around it is explored too. -- A reset chunk doesn't come back block-for-block identical to a fresh world. The shape of the ground is the same (in tests, more than 99.9% of blocks agree on solid or open), and it meets its neighbours at the same height. What differs is the features the game scatters through terrain, because some of them spread in from neighbouring chunks when those generated, and they don't again. In tests, about 4 to 6% of a reset chunk's underground blocks were plain stone or a different ore or stone blob from a fresh world: granite, diorite, andesite, tuff, dirt, gravel or ore. At the surface, a tree from the next chunk can be cut off at the edge. Below-ground repairs don't have this problem, because their terrain comes from a fresh copy generated along with its neighbours. +- A rewilded chunk's terrain comes from a copy generated in a temporary world, so it isn't block-for-block identical to a fresh world. In tests the shape of the ground matched exactly and it met its neighbours at the same height; a few leaves and a stone blob differed, from features neighbouring chunks spread into it. +- A player-placed block whose ground was dug out from under it gets back only its one supporting block. If the fresh terrain there is air (a ravine, say), the support floats. +- Trees grown from a player's saplings weren't placed, so they rewild. Water a player poured stays as a source but doesn't flow again until something nearby updates. - A below-ground repair drops mobs and items under the cut, and doesn't bring back mineshaft minecarts. - GriefPrevention is the only claim plugin supported. Animated Architecture has no hook, but its builds are made of player-placed blocks, which the tracker sees. - Undo restores a chunk as it was before the run, so anything built in it since is lost. ## Testing -`./gradlew build` runs 52 unit tests on made-up world files: region and NBT round trips, the tracking store's crash log, every keep rule, and the startup step's crash, rollback, undo and dry-run paths. +`./gradlew build` runs 82 unit tests on made-up world files: region and NBT round trips, the block-by-block merge (in both the old save format and 26.x's), what holds a kept block up, the tracking store's crash log, the respected-plugin answers and kept areas, every keep rule, and the startup step's crash, rollback, undo and dry-run paths. + +`test/acceptance.mjs` runs the plugin on a throwaway Paper server, in a temp folder on 127.0.0.1, with a world generated fresh from a fixed seed. A mineflayer bot plays: it digs, builds, places chests, torches and a bed, makes paths with a shovel and claims land. `test/keep.mjs` adds a stand-in plugin named LanternGraves that reports chunks and block areas. + +0.3.0 passed on **Paper 26.3 build 249** and **26.2**, with GriefPrevention, Multiverse-Core, Multiverse-Inventories and BlueMap installed: all 113 acceptance checks on each, and the 9 provider checks on 26.3. (On 26.3, one check, that a kept hole stays empty, was loosened after the run to accept water flowing into it from fresh terrain next door; that run's readings pass the loosened check, and 26.2 ran with it from the start.) -`test/acceptance.mjs` runs the same plugin on throwaway Paper servers, in a temp folder on 127.0.0.1, with worlds generated fresh from a fixed seed. A mineflayer bot plays: it digs, builds, makes paths with a shovel and claims land. The full suite passed on Paper **26.1.2, 26.2 and 26.3**, with GriefPrevention, Multiverse-Core, Multiverse-Inventories and BlueMap installed: 90 checks on 26.3, and 88 on 26.1.2 and 26.2. Those two ran one build earlier, which listed repeated config errors more than once and checked the dry-run report only in the log. +**Passed on 26.3 and 26.2:** -**Passed on all three:** +- **Plugins first:** before any plugin had an answer, a scan refused to propose and said why, and the log asked for answers once every plugin had started. `/rewild plugins ignore-rest` and `--json` worked. A respected plugin's chunk was kept and `/rewild why` named it; ignored, the same report didn't count. A respected LanternGraves that registered nothing stopped every reset. +- **Kept in place:** in chunks that rewilded, the player's chest (and the block under it), a stray torch, every cabin wall block, a lone dirt path, three paths across a chunk border, a bed and a surface chest over a mined tunnel all stayed. A dug-out area kept with `/rewild keep`, and one reported by the stand-in plugin, stayed dug out, while the dug ground next to them came back as fresh grass. +- **Kept whole:** a named pet, a respawn point, world spawn, a GriefPrevention claim and a player standing nearby each kept their chunk byte for byte. +- **Terrain:** a mined-out chunk and an explosion crater matched a fresh copy of the seed on 100% of ground shape and at least 99.99% of blocks, and met their neighbours at the same height. The differences were a few leaves and a stone blob. A tunnel 40 blocks down came back as fresh terrain. +- **Undo, dry run, crashes:** undo restored the chunks byte for byte. A dry run changed no world file. A halt like `kill -9` before the backup, a region write and the store update each finished from the journal at the next start, with no chunk errors, and a real `kill -9` during startup left the run applied. +- **Failing safely:** a read-only world folder failed the run with no file changed. Missing GriefPrevention, a broken config, a tracker that can't save and a changed datapack each stopped resets and said why. The cap held. Multiverse and BlueMap still worked after the runs. A scan of the test world took about 130 ms, with tick time unchanged. -- **Keep rules:** a player-placed chest, bed, small build, named pet, respawn point, world spawn, GriefPrevention claim, and a player standing nearby each keep their chunk, and `/rewild why` names the reason. Three connected paths across a chunk border keep both chunks. A lone path, a stray torch, and a village's own paths, beds and villagers don't. -- **Resets:** a mined-out chunk and an explosion crater were removed at the restart, and kept chunks stayed identical, byte for byte. Chunks generated before install stay unknown, and a fresh install on an already-played world proposes nothing. -- **Seams:** reset chunks met their neighbours at exactly the same ground height. Ground shape agreed with a fresh copy of the seed on at least 99.98% of blocks. About 4 to 5% of underground blocks were a different stone or ore (see Known limits). -- **Below ground:** a tunnel 40 blocks down came back as fresh terrain, matching a fresh copy on 99.99% of blocks below the cut, while the chest on the surface stayed. -- **Undo:** restored the reset chunks byte for byte, including the below-ground one. -- **Dry run:** a world-file hash before and after the startup step showed no change. -- **Crashes:** the process was halted dead (like `kill -9`) before the backup, before a region write and before the store update. Each time, the next start finished the run from its journal, and the server loaded the world without chunk errors. A real `kill -9` during startup left the chunk either reset or untouched, never broken. -- **Failing safely:** a read-only world folder failed the run with no file changed. Missing GriefPrevention, a broken config, a tracker that can't save and a changed datapack each stopped resets, and said why. -- **Other:** the cap held, the temporary reference world was deleted, and Multiverse and BlueMap still worked after the runs. A scan of the test world took about 100 ms, with no change in tick time (about 6 ms average before and after). +**Found by these tests and fixed before release:** 26.3 saves block states in a newer shape. The first build read every plain block in a merged section as the same block, and could write a section mixing two list shapes that nothing could read back. Both are fixed, covered by unit tests, and the NBT writer now refuses a mixed list, so a mistake like that fails the run and rolls it back instead of saving. **Mocked or not tested:** -- **The client side.** The bot speaks 1.21.11 and reaches 26.x through ViaVersion and ViaBackwards, so no real 26.x client was involved. Whether seams look natural in game, whether `/rewild preview` is readable, and whether a reset area feels fresh all need a person in a real client. -- **Scale.** Scan speed and tick time were measured on a small test world with a few thousand tracked chunks, not a long-played one. +- **The client side.** The bot speaks 1.21.11 and reaches 26.x through ViaVersion and ViaBackwards, so no real 26.x client was involved. How a rewilded area looks around kept builds, whether seams look natural, and whether `/rewild preview` is readable need a person in a real client. +- **26.1.** 0.3.0 hasn't been run on 26.1.2. +- **Scale.** Measured on a small test world, not a long-played one. Approval generates fresh terrain for every chunk in the run, up to 200, in a temporary world; how long that takes on a busy server isn't measured. +- **Real plugins as providers.** LanternGraves 0.1.2 registers whole-chunk keeps but hasn't been run against 0.3.0. No other plugin reports block areas yet. Multiverse-Portals and the BlueMap layer weren't exercised. - **Disk full** was simulated by making the trash folder unwritable (a unit test), not by filling a disk. -- **LanternGraves** didn't exist when these tests were written. `test/keep.mjs` stands in for it with a tiny plugin of the same name. On 26.3, a chunk it reported was kept, and `/rewild why` named it. Its neighbour was buffered. When it registered nothing, every reset stopped. LanternGraves doesn't register a provider yet, so the real pairing is untested. CoreProtect has no 26.x release to test with, and the plugin doesn't use it yet. -- **Multiverse-Portals** and the BlueMap layer were not exercised. Only BlueMap loading alongside was checked. ## Building diff --git a/build.gradle.kts b/build.gradle.kts index 1a2a5b1..323981e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,7 +3,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 f31dfd5..9efd82b 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -15,3 +15,10 @@ Decisions the owner has made for WildernessRegen, with dates. Later work follows Decisions in the design brief that weren't reopened: off by default in every world; reset at restart before worlds load, by removing chunks from the world's files; chunk reset as the only repair besides below-ground; the keep rules and thresholds; unknown chunks never reset; the generator fingerprint; dry run by default; admin approval; the 200-chunk cap; 30-day trash and `/wr undo`; admin-only commands. - **Name (2026-11-08, owner):** WildernessRegen is renamed Rewild, one word in every place: repo, plugin name, jar, package and prose. Released as 0.2.0. On first start an existing `plugins/WildernessRegen/` folder is moved (not copied) to `plugins/Rewild/`, because it holds undo backups that can be large and an approved run must live in one place; if the move fails, Rewild turns itself off and nothing resets. The old command `/wr` (and `/wildernessregen`) and the permission `wildernessregen.admin` keep working for one release with a log warning. The world store's format marker (`WRS1`) stays. +- **Redesign (owner, 2026-11-10; spec approved 2026-11-12, "Draft OK (Recommended)", relayed by the director):** "everything is rewilded EXCEPT dirt paths to begin with (and protected places like homesteader, of course). So it should have the ability to have the rewild natively blocked by other plugins, and should be able to converse with them via commands. So could be like / items allowed - X - Y - Z / plugins allowed (all blocks and entities created by this plugin, or something like that) - Homesteader - etc". Where this conflicts with the rows above, this wins. Built in 0.3.0: + - A chunk that rewilds takes fresh terrain from a copy generated with the world's seed, block by block, except what's kept, which stays exactly as it was with its contents: every player-placed block, containers players filled, the types in `keep-blocks` (`DIRT_PATH` by default), areas kept with `/rewild keep` or by a respected plugin, and the blocks holding each of those up (the block under it, the block it hangs from or is fixed to, and the other half of a door or bed). + - Player builds no longer keep a whole chunk; the intent list and score are retired. Claims, spawn, respawn points, named or tamed animals, a player nearby, strongholds, the End island and `/rewild protect` still keep whole chunks. + - On first start Rewild lists the installed plugins and resets nothing until an admin has answered for each one with `/rewild plugins respect|ignore ` (or `ignore-rest`). Answers live in `plugins/Rewild/keep-plugins.yml`. A plugin installed later has no answer, so resets stop again until an admin looks at it. A respected plugin that isn't running stops resets. + - Plugins report through `KeepProvider` (whole chunks, as before, and now block areas) or with `/rewild keep [] plugin: [note]` and `/rewild unkeep`. An admin's keeps always count; a plugin's count while it's respected. `--json` on `keep list` and `plugins`. + - Player-placed blocks are found by Rewild's own tracking, which now records every placed block. CoreProtect stays optional and unused, so Rewild doesn't depend on it while it's off on 26.3. Chunks generated before 0.3.0's tracking began stay unknown, because 0.2.x recorded only some placed blocks. + - `/wr`, `/wildernessregen` and `wildernessregen.admin` are removed, as announced for the release after 0.2.x. diff --git a/docs/releases/v0.3.0.md b/docs/releases/v0.3.0.md new file mode 100644 index 0000000..324fff8 --- /dev/null +++ b/docs/releases/v0.3.0.md @@ -0,0 +1,17 @@ +A redesign. Rewilding now works block by block: a chunk that's mined out and abandoned goes back to fresh terrain from the world's seed, and everything players placed stays exactly where it is. Before, a player's chest or a small build kept the whole chunk, so a quarry next to a house never filled in. Now the quarry fills in and the house stays. + +**What stays in place:** every block a player placed, containers players filled, dirt paths (and any block types in the new `keep-blocks` list), areas kept with `/rewild keep` or by a plugin you respect, and the block holding each of those up. Claims, spawn, respawn points, named pets, nearby players, strongholds and `/rewild protect` still keep whole chunks. + +**New:** +- `/rewild plugins`: on first start Rewild lists your plugins and resets nothing until you've said which to respect. A plugin installed later stops resets again until you answer for it. +- `/rewild keep` and `/rewild unkeep`: keep a block or a box of blocks, for yourself or on a plugin's behalf (`plugin:`). `--json` on `keep list` and `plugins`. +- `KeepProvider.areas()`: plugins can report block areas as well as whole chunks. +- Approval now generates fresh terrain for every chunk in the run, not just below-ground repairs. + +**Upgrading from 0.2.x:** +- 0.2.x recorded only some placed blocks, so chunks it tracked stay unknown and never rewild. Only land generated after the upgrade can. +- The `intent` settings do nothing now and can be deleted. +- `/wr`, `/wildernessregen` and `wildernessregen.admin` are gone, as announced. Use `/rewild` and `rewild.admin`. +- Answer `/rewild plugins` before anything can reset. + +**Tested** on throwaway Paper 26.3 (build 249) and 26.2 servers with a bot (see the README). Not yet seen in a real game client, and not run on 26.1.2. diff --git a/docs/test-audit.md b/docs/test-audit.md index c4f74b7..76e3bc4 100644 --- a/docs/test-audit.md +++ b/docs/test-audit.md @@ -2,6 +2,32 @@ Kept by this repo's owning session (Minecraft: Plugins Lead), per `~/Code/CLAUDE.md`, "Test self-audits". Newest first. +## 2026-11-12: the 0.3.0 redesign + +The redesign changes what a reset does, so every keep and safety promise was mapped again. "Acceptance" is `test/acceptance.mjs` and `test/keep.mjs` on a throwaway Paper 26.3 (build 249) server; "unit" is `src/test`. + +| Promise | Covered by | +| --- | --- | +| **Every player-placed block stays in place when its chunk rewilds**, with the block holding it up | unit `keptBlocksAndTheirSupportsComeThroughEverythingElseIsFresh`, `KeepMaskTest` (below, above, behind, both halves of doors and beds); acceptance chest, torch, cabin walls, bed, surface chest over a tunnel | +| Dirt paths stay (`keep-blocks`) | unit merge tests; acceptance lone path and paths across a border | +| Areas kept with `/rewild keep` and by respected plugins stay exactly as they were, and the rest of the chunk rewilds | unit `anAreaKeepsEveryBlockInsideItEvenAir`; acceptance kept area; `keep.mjs` reported area | +| **Nothing resets until every installed plugin has an answer**, a later plugin stops resets again, and a respected plugin that isn't running stops them | unit `nothingResetsUntilEveryInstalledPluginHasAnAnswer`, `aRespectedPluginThatIsntRunningStopsResets`, `aBrokenOrContradictoryFileStopsResets`; acceptance before answering | +| An ignored plugin's reports don't count; a respected one's do | `keep.mjs` (both) | +| A plugin's `/rewild keep` doesn't count until it's respected; unkeep by exact corners | acceptance keep and unkeep | +| Kept areas carry through the shutdown check, so the restart keeps the same blocks | unit `areasSurviveTheShutdownSnapshot` | +| Chunks tracked by 0.2.x (partial placed-block records) stay unknown | unit `chunkBornBeforeEveryBlockWasRecordedIsUnknown` | +| **26.x save formats are read and written correctly** | unit `the26xPaletteShapesKeepEveryStateApart`, `aSectionMixingBareNamesAndPropertiesSavesAndReadsBack`, `theWriterRefusesAMixedList`, `encodeRoundTripsEveryPaletteSize` | +| Fresh terrain from a different world is refused | unit `differentTerrainIsRefused`, `referenceWithDifferentBedrockIsRefused` | +| A rewilded chunk stays known and its damage is cleared | unit `appliesThenUndoRestoresExactly` | +| Undo, dry run, crash recovery, read-only folder, cap, kids' worlds, fingerprint, claims | unchanged tests, rerun: all pass | + +**Gaps added in this pass:** all of the above are new. Two were found by the first 26.3 run: plain block states read as one state (the merge would have changed blocks around kept ones), and a merged section that couldn't be read back. Both have unit tests now. + +**Skipped, with reasons:** +- `/rewild keep` argument edge cases (a world name that looks like a number, notes containing `plugin:`): low risk, an admin sees the reply at once. +- How a rewilded area looks around kept builds in a real client: real client only, on the README's untested list. +- 26.1.2 run of 0.3.0: the server moves to 26.3; 0.2.x passed on 26.1.2 and the code paths are the same. + ## 2026-11-09: first full audit (at 0.2.0, fixed in 0.2.1) Rewild changes world files, so its safety promises come first. "Acceptance" is `test/acceptance.mjs` (with `keep.mjs`, `configs.mjs` and `seam.mjs`) on throwaway Paper 26.1.2, 26.2 and 26.3; "unit" is `src/test`. diff --git a/src/main/java/com/extantape/rewild/RewildPlugin.java b/src/main/java/com/extantape/rewild/RewildPlugin.java index 2268215..ab9fb5e 100644 --- a/src/main/java/com/extantape/rewild/RewildPlugin.java +++ b/src/main/java/com/extantape/rewild/RewildPlugin.java @@ -7,7 +7,6 @@ import com.extantape.rewild.map.Preview; import com.extantape.rewild.run.Applier; import com.extantape.rewild.run.RunService; -import com.extantape.rewild.scan.IntentRules; import com.extantape.rewild.scan.ScanService; import com.extantape.rewild.store.WorldStore; import com.extantape.rewild.track.TrackerListener; @@ -91,7 +90,7 @@ public void onEnable() { tracking = new Tracking(this, config); tracking.start(startupStores); startupStores = new HashMap<>(); - listener = new TrackerListener(this, tracking, IntentRules.from(config)); + listener = new TrackerListener(this, tracking); getServer().getPluginManager().registerEvents(listener, this); scans = new ScanService(this, tracking); runs = new RunService(this, tracking); @@ -107,11 +106,25 @@ public void onEnable() { getLifecycleManager().registerEventHandler(io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents.COMMANDS, event -> { event.registrar().register("rewild", "Rewild admin tools", wr); - // The old names, for one release (0.2.x) after the rename from WildernessRegen. - event.registrar().register("wr", "Old name for /rewild. Goes in the next release.", List.of("wildernessregen"), wr.deprecated()); }); if (config.dryRun) getLogger().info("Dry run is on: approved runs are checked at startup and nothing resets."); if (tracking.states().isEmpty()) getLogger().info("No world is enabled yet. Add one under worlds: in config.yml."); + // Once every plugin has started: say which still need an answer, so nothing is rewilded behind an admin's back. + getServer().getScheduler().runTask(this, () -> { + String p = pluginsProblem(); + if (p != null) getLogger().warning("Nothing resets yet: " + p + ". Answer with /rewild plugins respect ," + + " /rewild plugins ignore , or /rewild plugins ignore-rest."); + }); + } + + /** Why the respected-plugin answers stop resets, or null. */ + public String pluginsProblem() { + java.util.List installed = new java.util.ArrayList<>(); + for (org.bukkit.plugin.Plugin p : getServer().getPluginManager().getPlugins()) installed.add(p.getName()); + return tracking.respect().blocking(installed, n -> { + org.bukkit.plugin.Plugin p = getServer().getPluginManager().getPlugin(n); + return p != null && p.isEnabled(); + }); } @Override @@ -129,21 +142,22 @@ public List reload() { reloadConfig(); config = new RegenConfig(getConfig()); tracking.setConfig(config); - listener.setIntent(IntentRules.from(config)); + tracking.respect().load(); + tracking.commandKeeps().load(); for (var w : Bukkit.getWorlds()) tracking.open(w, null); warnUnknownMaterials(); return config.errors(); } private void warnUnknownMaterials() { - java.util.List patterns = new java.util.ArrayList<>(config.intentKeep); - patterns.addAll(config.intentScore.keySet()); - for (String p : patterns) { + for (String p : config.keepBlocks) { boolean any = false; - for (Material m : Material.values()) if (!m.isLegacy() && RegenConfig.matches(p, m.name())) any = true; - if (!any && !p.equals("ITEM_FRAME") && !p.equals("GLOW_ITEM_FRAME") && !p.equals("ARMOR_STAND")) { - getLogger().info("intent pattern " + p + " matches no block in this version; it's ignored here."); - } + for (Material m : Material.values()) if (!m.isLegacy() && m.isBlock() && RegenConfig.matches(p, m.name())) any = true; + if (!any) getLogger().warning("keep-blocks entry " + p + " matches no block in this version; it keeps nothing."); + } + for (String old : config.retired) { + getLogger().info(old + " in config.yml does nothing since 0.3.0: every player-placed block now stays where it is" + + " when the land around it rewilds. It can be deleted."); } } diff --git a/src/main/java/com/extantape/rewild/api/KeepProvider.java b/src/main/java/com/extantape/rewild/api/KeepProvider.java index 91f293a..94578d1 100644 --- a/src/main/java/com/extantape/rewild/api/KeepProvider.java +++ b/src/main/java/com/extantape/rewild/api/KeepProvider.java @@ -1,24 +1,51 @@ package com.extantape.rewild.api; import org.bukkit.World; +import org.bukkit.entity.Entity; import java.util.Collection; +import java.util.List; /** - * Lets another plugin keep chunks from ever being reset, such as a graves plugin keeping the chunk a grave sits in. - * Register an implementation with Bukkit's services manager: + * Lets another plugin keep what it owns when the land around it rewilds, such as a graves plugin keeping its graves or + * a homes plugin keeping homesteads. Register an implementation with Bukkit's services manager: * *
{@code
  * getServer().getServicesManager().register(KeepProvider.class, provider, this, ServicePriority.Normal);
  * }
* - * Rewild asks every registered provider on the main thread when it scans, previews, and shuts down before - * a restart that may apply a run. If a provider throws, nothing in that world resets. + * Rewild only listens to plugins an admin chose to respect (/rewild plugins). It asks every registered provider on the + * main thread when it scans, previews, and shuts down before a restart that may apply a run. If a respected provider + * throws, nothing in that world resets. + * + *

Two ways to report, and a provider may use both: + *

    + *
  • {@link #keeps(World)}: whole chunks, left exactly as they are. For anything that needs its surroundings too.
  • + *
  • {@link #areas(World)}: block boxes, kept in place while the rest of the chunk rewilds. A single block is a box + * of one. To keep an entity, report the blocks it stands in or hangs on.
  • + *
*/ public interface KeepProvider { - /** A chunk to keep, and the reason shown by /rewild why, such as "an active grave". */ - record Keep(int chunkX, int chunkZ, String reason) {} + /** A chunk to keep whole, and the reason shown by /rewild why, such as "an active grave". */ + record Keep(int chunkX, int chunkZ, String reason) { + /** The chunk an entity is in. */ + public static Keep of(Entity entity, String reason) { + return new Keep(entity.getLocation().getBlockX() >> 4, entity.getLocation().getBlockZ() >> 4, reason); + } + } + + /** A box of blocks, corners included, in world coordinates. */ + record Area(int minX, int minY, int minZ, int maxX, int maxY, int maxZ, String reason) { + public static Area block(int x, int y, int z, String reason) { + return new Area(x, y, z, x, y, z, reason); + } + } - /** Every chunk in this world that must be kept right now. */ + /** Every chunk in this world that must be kept whole right now. */ Collection keeps(World world); + + /** Every block area in this world to keep in place right now. None by default. */ + default Collection areas(World world) { + return List.of(); + } } diff --git a/src/main/java/com/extantape/rewild/cmd/Json.java b/src/main/java/com/extantape/rewild/cmd/Json.java new file mode 100644 index 0000000..3c505c8 --- /dev/null +++ b/src/main/java/com/extantape/rewild/cmd/Json.java @@ -0,0 +1,25 @@ +package com.extantape.rewild.cmd; + +/** The little JSON the --json outputs need: quoted, escaped strings. */ +final class Json { + private Json() {} + + static String str(String s) { + if (s == null) return "null"; + StringBuilder sb = new StringBuilder("\""); + for (char c : s.toCharArray()) { + switch (c) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (c < 0x20) sb.append(String.format("\\u%04x", (int) c)); + else sb.append(c); + } + } + } + return sb.append('"').toString(); + } +} diff --git a/src/main/java/com/extantape/rewild/cmd/RewildCommand.java b/src/main/java/com/extantape/rewild/cmd/RewildCommand.java index 7037718..e910de8 100644 --- a/src/main/java/com/extantape/rewild/cmd/RewildCommand.java +++ b/src/main/java/com/extantape/rewild/cmd/RewildCommand.java @@ -28,7 +28,7 @@ /** /rewild: admin-only, registered through Paper's command API. Players get no commands. */ public final class RewildCommand implements BasicCommand { public static final String PERMISSION = "rewild.admin"; - private static final List SUBS = List.of("status", "why", "preview", "scan", "approve", "undo", "protect", "reload"); + private static final List SUBS = List.of("status", "why", "preview", "scan", "approve", "undo", "protect", "keep", "unkeep", "plugins", "reload"); private final RewildPlugin plugin; public RewildCommand(RewildPlugin plugin) { @@ -46,18 +46,9 @@ static Component button(String label, String command, String hover) { .hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(Component.text(hover + "\n" + command, NamedTextColor.GRAY))); } - /** The node from before the rename (2026-11-09). Honoured for one release (0.2.x), then removed. */ - public static final String OLD_PERMISSION = "wildernessregen.admin"; - private final java.util.Set warnedOld = java.util.concurrent.ConcurrentHashMap.newKeySet(); - - /** True for admins. Accepts the old node too, for one release, and logs once per name that it should move. */ + /** True for admins. */ public boolean allowed(org.bukkit.permissions.Permissible who) { - if (who.hasPermission(PERMISSION)) return true; - if (!who.hasPermission(OLD_PERMISSION)) return false; - String name = who instanceof CommandSender cs ? cs.getName() : String.valueOf(who); - if (warnedOld.add(name)) - plugin.getLogger().warning(name + " uses the old permission " + OLD_PERMISSION + ". It still works in 0.2.x but goes in the next release: grant " + PERMISSION + " instead."); - return true; + return who.hasPermission(PERMISSION); } @Override @@ -65,21 +56,6 @@ public boolean canUse(CommandSender sender) { return allowed(sender); } - /** The same command under its old names, with a nudge each time it's used. */ - public BasicCommand deprecated() { - RewildCommand self = this; - return new BasicCommand() { - @Override public void execute(CommandSourceStack source, String[] args) { - String line = "/wr and /wildernessregen are the old names for /rewild. They still work in 0.2.x but go in the next release."; - plugin.getLogger().warning(line); - if (!(source.getSender() instanceof org.bukkit.command.ConsoleCommandSender)) say(source.getSender(), NamedTextColor.YELLOW, line); - self.execute(source, args); - } - @Override public java.util.Collection suggest(CommandSourceStack source, String[] args) { return self.suggest(source, args); } - @Override public boolean canUse(CommandSender sender) { return self.canUse(sender); } - }; - } - @Override public void execute(CommandSourceStack source, String[] args) { run(source.getSender(), args); @@ -100,12 +76,15 @@ void run(CommandSender sender, String[] args) { case "approve" -> approve(sender, args); case "undo" -> undo(sender, args); case "protect" -> protect(sender, args); + case "keep" -> keep(sender, args); + case "unkeep" -> unkeep(sender, args); + case "plugins" -> plugins(sender, args); case "reload" -> { List errors = plugin.reload(); if (errors.isEmpty()) say(sender, NamedTextColor.GREEN, "Config reloaded. New thresholds apply to the next scan."); else say(sender, NamedTextColor.RED, "Config reloaded with problems, so nothing will reset: " + String.join("; ", errors)); } - default -> say(sender, NamedTextColor.GRAY, "/rewild status | why [world] [x z] | preview [radius] | scan [world] | approve | approve generator | undo | protect [radius] [note] | protect remove | protect list | reload"); + default -> say(sender, NamedTextColor.GRAY, "/rewild status | why [world] [x z] | preview [radius] | scan [world] | approve | approve generator | undo | protect [radius] [note] | protect remove | protect list | keep [] [plugin:] [note] | keep list [world] [--json] | unkeep | plugins [respect|ignore ...] [ignore-rest] [--json] | reload"); } } catch (IOException e) { say(sender, NamedTextColor.RED, "That failed: " + e.getMessage()); @@ -121,6 +100,9 @@ private void status(CommandSender sender) { var cfg = plugin.tracking().config(); say(sender, NamedTextColor.GOLD, "Rewild " + plugin.getPluginMeta().getVersion() + (cfg.dryRun ? " (dry run: nothing resets)" : "")); if (!cfg.healthy()) say(sender, NamedTextColor.RED, "Config problems, so nothing resets: " + String.join("; ", cfg.errors())); + String plugins = plugin.pluginsProblem(); + if (plugins != null) sender.sendMessage(Component.text("Nothing resets yet: " + plugins + " ", NamedTextColor.YELLOW) + .append(button("Answer", "/rewild plugins", "List installed plugins and choose which to respect"))); if (plugin.tracking().states().isEmpty()) say(sender, NamedTextColor.GRAY, "No world is enabled. Add one under worlds: in config.yml."); for (WorldState s : plugin.tracking().states()) { Map recs = s.store.snapshot(); @@ -198,8 +180,8 @@ private void why(CommandSender sender, String[] args) { default -> NamedTextColor.GRAY; }; String what = switch (v.kind()) { - case QUEUED -> "would reset"; - case UNDERGROUND -> "would reset below y=" + v.cutY(); + case QUEUED -> "would rewild; kept blocks stay as they are"; + case UNDERGROUND -> "would rewild below y=" + v.cutY() + " only"; case KEPT -> "kept"; case WAITING -> "waiting"; case UNKNOWN -> "unknown, never reset automatically"; @@ -248,7 +230,7 @@ private void scan(CommandSender sender, String[] args) { int[] counts = new int[Scanner.Kind.values().length]; for (Scanner.Verdict v : r.verdicts().values()) counts[v.kind().ordinal()]++; say(sender, NamedTextColor.GOLD, "Scanned " + w.getName() + " in " + r.elapsedMs() + " ms: " + counts[Scanner.Kind.QUEUED.ordinal()] - + " to reset, " + counts[Scanner.Kind.UNDERGROUND.ordinal()] + " to reset below ground, " + + " to rewild, " + counts[Scanner.Kind.UNDERGROUND.ordinal()] + " to rewild below ground, " + counts[Scanner.Kind.KEPT.ordinal()] + " damaged but kept, " + counts[Scanner.Kind.WAITING.ordinal()] + " waiting."); if (r.problem() != null) { say(sender, NamedTextColor.RED, "Nothing can be approved in this world right now: " + r.problem()); @@ -332,6 +314,172 @@ private void undo(CommandSender sender, String[] args) throws IOException { plugin.getLogger().info(sender.getName() + " ran /rewild undo " + run.id + "; it is now " + run.state()); } + // ---- keep, unkeep, plugins ---- + + /** "plugin:Name" in the arguments, or the admin owner. Removes it from the list. */ + private static String owner(List words) { + for (int i = 0; i < words.size(); i++) { + if (words.get(i).toLowerCase(Locale.ROOT).startsWith("plugin:")) { + String name = words.remove(i).substring(7); + return name.isBlank() ? null : name; + } + } + return com.extantape.rewild.keep.CommandKeeps.ADMIN; + } + + /** Parses "world x y z [x y z]" from the front of the list, removing what it used. Null if it doesn't fit. */ + private static com.extantape.rewild.keep.KeepPlan.Area box(List w, String note) { + if (w.size() < 4 || Bukkit.getWorld(w.get(0)) == null) return null; + int[] n = new int[6]; + int count = 0; + for (int i = 1; i < Math.min(w.size(), 7); i++) { + try { + n[i - 1] = Integer.parseInt(w.get(i)); + count++; + } catch (NumberFormatException e) { + break; + } + } + if (count != 3 && count != 6) return null; + String world = w.get(0); + for (int i = 0; i <= count; i++) w.remove(0); + if (count == 3) return new com.extantape.rewild.keep.KeepPlan.Area(world, n[0], n[1], n[2], n[0], n[1], n[2], note); + return new com.extantape.rewild.keep.KeepPlan.Area(world, n[0], n[1], n[2], n[3], n[4], n[5], note); + } + + private void keep(CommandSender sender, String[] args) throws IOException { + var keeps = plugin.tracking().commandKeeps(); + List words = new ArrayList<>(List.of(args).subList(1, args.length)); + boolean json = words.remove("--json"); + if (!words.isEmpty() && words.get(0).equalsIgnoreCase("list")) { + String world = words.size() > 1 ? words.get(1) : null; + List list = keeps.all().stream() + .filter(e -> world == null || e.area().world().equals(world)).toList(); + if (json) { + StringBuilder sb = new StringBuilder("["); + for (var e : list) { + var a = e.area(); + if (sb.length() > 1) sb.append(','); + sb.append("{\"id\":").append(e.id()).append(",\"owner\":").append(Json.str(e.owner())) + .append(",\"counts\":").append(e.byAdmin() || plugin.tracking().respect().respected(e.owner())) + .append(",\"world\":").append(Json.str(a.world())).append(",\"min\":[").append(a.minX()).append(',') + .append(a.minY()).append(',').append(a.minZ()).append("],\"max\":[").append(a.maxX()).append(',') + .append(a.maxY()).append(',').append(a.maxZ()).append("],\"note\":").append(Json.str(a.reason())).append('}'); + } + sender.sendMessage(Component.text(sb.append(']').toString())); + return; + } + if (list.isEmpty()) say(sender, NamedTextColor.GRAY, "Nothing is kept with /rewild keep" + (world == null ? "" : " in " + world) + "."); + for (var e : list) { + var a = e.area(); + boolean counts = e.byAdmin() || plugin.tracking().respect().respected(e.owner()); + say(sender, counts ? NamedTextColor.GRAY : NamedTextColor.DARK_GRAY, "#" + e.id() + " " + a.world() + " " + a.minX() + " " + + a.minY() + " " + a.minZ() + " to " + a.maxX() + " " + a.maxY() + " " + a.maxZ() + ", by " + e.owner() + + (a.reason().isBlank() ? "" : ": " + a.reason()) + (counts ? "" : " (not counted: " + e.owner() + " isn't respected)")); + } + return; + } + String owner = owner(words); + if (owner == null) { + say(sender, NamedTextColor.RED, "plugin: needs a plugin name, such as plugin:Homesteader."); + return; + } + List rest = new ArrayList<>(words); + com.extantape.rewild.keep.KeepPlan.Area a = box(rest, ""); + if (a == null) { + say(sender, NamedTextColor.RED, "Usage: /rewild keep [ ] [plugin:] [note]. " + + "The blocks stay exactly as they are when the land around them rewilds."); + return; + } + a = new com.extantape.rewild.keep.KeepPlan.Area(a.world(), a.minX(), a.minY(), a.minZ(), a.maxX(), a.maxY(), a.maxZ(), String.join(" ", rest)); + var e = keeps.add(owner, a); + boolean counts = e.byAdmin() || plugin.tracking().respect().respected(owner); + say(sender, counts ? NamedTextColor.GREEN : NamedTextColor.YELLOW, "Kept #" + e.id() + ": " + a.volume() + " block(s) in " + a.world() + + (counts ? ". They stay as they are when the land around them rewilds." + : ". Saved, but it doesn't count until " + owner + " is respected (/rewild plugins).")); + plugin.getLogger().info(sender.getName() + " kept #" + e.id() + " for " + owner + " in " + a.world()); + } + + private void unkeep(CommandSender sender, String[] args) throws IOException { + var keeps = plugin.tracking().commandKeeps(); + List words = new ArrayList<>(List.of(args).subList(1, args.length)); + if (words.size() == 1) { + try { + var e = keeps.remove(Integer.parseInt(words.get(0).replace("#", ""))); + say(sender, e == null ? NamedTextColor.RED : NamedTextColor.GOLD, e == null ? "No keep #" + words.get(0) + "." : "Removed keep #" + e.id() + "."); + return; + } catch (NumberFormatException ignored) { + } + } + String owner = owner(words); + com.extantape.rewild.keep.KeepPlan.Area a = owner == null ? null : box(words, ""); + if (a == null) { + say(sender, NamedTextColor.RED, "Usage: /rewild unkeep , or /rewild unkeep [ ] [plugin:]"); + return; + } + int n = keeps.removeExact(owner, a.world(), a.minX(), a.minY(), a.minZ(), a.maxX(), a.maxY(), a.maxZ()); + say(sender, NamedTextColor.GOLD, n == 0 ? "No keep by " + owner + " has exactly those corners." : "Removed " + n + " keep(s) by " + owner + "."); + } + + private void plugins(CommandSender sender, String[] args) throws IOException { + var respect = plugin.tracking().respect(); + List words = new ArrayList<>(List.of(args).subList(1, args.length)); + boolean json = words.remove("--json"); + List installed = new ArrayList<>(); + for (org.bukkit.plugin.Plugin p : Bukkit.getPluginManager().getPlugins()) if (!p.getName().equals(plugin.getName())) installed.add(p.getName()); + installed.sort(String.CASE_INSENSITIVE_ORDER); + if (!words.isEmpty()) { + String verb = words.remove(0).toLowerCase(Locale.ROOT); + List names = new ArrayList<>(); + if (verb.equals("ignore-rest")) { + names.addAll(respect.unanswered(installed)); + verb = "ignore"; + } else { + for (String w : words) { + String match = installed.stream().filter(n -> n.equalsIgnoreCase(w)).findFirst().orElse(null); + if (match == null) { + say(sender, NamedTextColor.RED, w + " isn't installed. /rewild plugins lists what is."); + return; + } + names.add(match); + } + } + if (!verb.equals("respect") && !verb.equals("ignore") || names.isEmpty()) { + say(sender, NamedTextColor.RED, "Usage: /rewild plugins respect ..., /rewild plugins ignore ..., or /rewild plugins ignore-rest"); + return; + } + for (String n : names) respect.setRespected(n, verb.equals("respect")); + respect.save(); + say(sender, NamedTextColor.GREEN, (verb.equals("respect") ? "Respected: " : "Ignored: ") + String.join(", ", names) + "."); + plugin.getLogger().info(sender.getName() + " set " + String.join(", ", names) + " to " + verb); + } + java.util.Set providers = new java.util.HashSet<>(); + for (var reg : Bukkit.getServicesManager().getRegistrations(com.extantape.rewild.api.KeepProvider.class)) providers.add(reg.getPlugin().getName()); + if (json) { + StringBuilder sb = new StringBuilder("["); + for (String n : installed) { + if (sb.length() > 1) sb.append(','); + sb.append("{\"plugin\":").append(Json.str(n)).append(",\"answer\":").append(Json.str(respect.respected(n) ? "respect" + : respect.ignored(n) ? "ignore" : "none")).append(",\"reportsKeeps\":").append(providers.contains(n)).append('}'); + } + sender.sendMessage(Component.text(sb.append(']').toString())); + return; + } + List open = respect.unanswered(installed); + say(sender, NamedTextColor.GOLD, "Which plugins does Rewild respect? Everything a respected plugin reports stays when the land around it rewilds." + + (open.isEmpty() ? "" : " Nothing resets until every plugin has an answer.")); + for (String n : installed) { + String state = respect.respected(n) ? "respected" : respect.ignored(n) ? "ignored" : "no answer yet"; + NamedTextColor c = respect.respected(n) ? NamedTextColor.GREEN : respect.ignored(n) ? NamedTextColor.GRAY : NamedTextColor.YELLOW; + Component line = Component.text(n + ": " + state + (providers.contains(n) ? ", reports what it keeps" : ""), c); + if (!respect.respected(n)) line = line.append(Component.text(" ")).append(button("Respect", "/rewild plugins respect " + n, "Keep everything " + n + " reports")); + if (!respect.ignored(n)) line = line.append(Component.text(" ")).append(button("Ignore", "/rewild plugins ignore " + n, "Don't listen to " + n)); + sender.sendMessage(line); + } + if (!open.isEmpty()) sender.sendMessage(Component.text("Ignore the " + open.size() + " without an answer: ", NamedTextColor.GRAY) + .append(button("Ignore the rest", "/rewild plugins ignore-rest", "Answer ignore for every plugin without an answer"))); + } + private void protect(CommandSender sender, String[] args) throws IOException { if (!(sender instanceof Player p)) { say(sender, NamedTextColor.RED, "Run /rewild protect in game."); @@ -400,6 +548,16 @@ public java.util.Collection suggest(CommandSourceStack source, String[] return plugin.tracking().states().stream().map(s -> s.name).filter(n -> n.startsWith(args[2])).toList(); } if (args.length == 2 && sub.equals("protect")) return List.of("remove", "list", "0", "1", "2"); + if (args.length == 2 && sub.equals("plugins")) return List.of("respect", "ignore", "ignore-rest", "--json").stream().filter(s -> s.startsWith(args[1])).toList(); + if (args.length == 3 && sub.equals("plugins") && (args[1].equalsIgnoreCase("respect") || args[1].equalsIgnoreCase("ignore"))) { + return java.util.Arrays.stream(Bukkit.getPluginManager().getPlugins()).map(org.bukkit.plugin.Plugin::getName) + .filter(n -> !n.equals(plugin.getName()) && n.toLowerCase(Locale.ROOT).startsWith(args[2].toLowerCase(Locale.ROOT))).toList(); + } + if (args.length == 2 && sub.equals("keep")) { + List out = new ArrayList<>(List.of("list")); + Bukkit.getWorlds().forEach(w -> out.add(w.getName())); + return out.stream().filter(s -> s.startsWith(args[1])).toList(); + } return List.of(); } } diff --git a/src/main/java/com/extantape/rewild/config/RegenConfig.java b/src/main/java/com/extantape/rewild/config/RegenConfig.java index 77e9987..579ddae 100644 --- a/src/main/java/com/extantape/rewild/config/RegenConfig.java +++ b/src/main/java/com/extantape/rewild/config/RegenConfig.java @@ -21,7 +21,7 @@ public record WorldSettings( int minRemovedBlocks, int minExplosionBlocks, List damageIgnore, int daysWithoutChanges, int daysWithoutVisitors, int visitRadiusChunks, int daysSinceLastReset, int bufferChunks, int claimBufferChunks, int spawnRadiusChunks, int respawnRadiusChunks, - int pathMinConnected, int intentScore, List structures, int endMainIslandRadiusBlocks, + List structures, int endMainIslandRadiusBlocks, boolean undergroundEnabled, int undergroundMarginBlocks, int undergroundMinRemovedBlocks) {} public final boolean dryRun; @@ -35,8 +35,10 @@ public record WorldSettings( public final boolean blueMap; /** Worlds that never reset, overriding enabled. Kids' worlds belong here. */ public final java.util.Set neverReset = new java.util.HashSet<>(); - public final List intentKeep; - public final Map intentScore; + /** Block types kept wherever they are when a chunk rewilds, with what holds them up. Dirt paths by default. */ + public final List keepBlocks; + /** Settings from before 0.3.0 that no longer do anything, to mention once in the log. */ + public final List retired = new ArrayList<>(); private final Map worlds = new HashMap<>(); private final WorldSettings disabled; private final List errors = new ArrayList<>(); @@ -53,14 +55,9 @@ public RegenConfig(FileConfiguration c) { blueMap = bool(c, "bluemap.enabled", false); if (c.contains("never-reset-worlds") && !c.isList("never-reset-worlds")) errors.add("never-reset-worlds must be a list of world names"); neverReset.addAll(c.getStringList("never-reset-worlds")); - intentKeep = patterns(c, "intent.keep"); - intentScore = new LinkedHashMap<>(); - ConfigurationSection score = c.getConfigurationSection("intent.score"); - if (score != null) { - for (String k : score.getKeys(false)) { - if (!score.isInt(k) || score.getInt(k) < 0) errors.add("intent.score." + k + " must be a whole number, 0 or more"); - else intentScore.put(k.toUpperCase(Locale.ROOT), score.getInt(k)); - } + keepBlocks = c.contains("keep-blocks") ? patterns(c, "keep-blocks") : List.of("DIRT_PATH"); + for (String old : List.of("intent", "defaults.keep.path-min-connected", "defaults.keep.intent-score")) { + if (c.contains(old)) retired.add(old); } ConfigurationSection defaults = c.getConfigurationSection("defaults"); disabled = settings(defaults, null, "defaults", false); @@ -106,7 +103,7 @@ private WorldSettings settings(ConfigurationSection defaults, ConfigurationSecti l.nonNegative("idle.visit-radius-chunks", 2), l.nonNegative("idle.days-since-last-reset", 30), l.nonNegative("keep.buffer-chunks", 1), l.nonNegative("keep.claim-buffer-chunks", 1), l.nonNegative("keep.spawn-radius-chunks", 8), l.nonNegative("keep.respawn-radius-chunks", 1), - l.positive("keep.path-min-connected", 3), l.positive("keep.intent-score", 8), l.strings("keep.structures"), + l.strings("keep.structures"), l.nonNegative("keep.end-main-island-radius-blocks", 1100), l.bool("underground.enabled", true), l.nonNegative("underground.margin-blocks", 8), l.positive("underground.min-removed-blocks", 40)); diff --git a/src/main/java/com/extantape/rewild/keep/CommandKeeps.java b/src/main/java/com/extantape/rewild/keep/CommandKeeps.java new file mode 100644 index 0000000..8fc3be3 --- /dev/null +++ b/src/main/java/com/extantape/rewild/keep/CommandKeeps.java @@ -0,0 +1,116 @@ +package com.extantape.rewild.keep; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; + +/** + * Block areas kept with /rewild keep, by an admin or on behalf of a plugin. Stored one per line in keeps.txt, so the + * file stays readable without Rewild: id, owner, world, the two corners, then the note. + */ +public final class CommandKeeps { + public static final String FILE = "keeps.txt"; + public static final String ADMIN = "admin"; + /** Largest single area, in blocks, so a typo can't keep a whole world by accident. */ + public static final long MAX_VOLUME = 16L * 16 * 400 * 64; + + public record Entry(int id, String owner, KeepPlan.Area area) { + public boolean byAdmin() { + return ADMIN.equalsIgnoreCase(owner); + } + } + + private final Path file; + private final List entries = new ArrayList<>(); + private String problem; + + public CommandKeeps(Path dataFolder) { + this.file = dataFolder.resolve(FILE); + load(); + } + + public void load() { + entries.clear(); + problem = null; + if (!Files.exists(file)) return; + try { + for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { + if (line.isBlank() || line.startsWith("#")) continue; + String[] p = line.split(" ", 10); + if (p.length < 9) throw new IOException("short line: " + line); + entries.add(new Entry(Integer.parseInt(p[0]), p[1], new KeepPlan.Area(p[2], Integer.parseInt(p[3]), + Integer.parseInt(p[4]), Integer.parseInt(p[5]), Integer.parseInt(p[6]), Integer.parseInt(p[7]), + Integer.parseInt(p[8]), p.length > 9 ? p[9] : ""))); + } + } catch (IOException | RuntimeException e) { + entries.clear(); + problem = FILE + " can't be read (" + e.getMessage() + ")"; + } + } + + /** Null if the file is fine; otherwise why nothing can reset. */ + public String problem() { + return problem; + } + + public List all() { + return List.copyOf(entries); + } + + public Entry add(String owner, KeepPlan.Area a) throws IOException { + if (problem != null) throw new IOException(problem); + if (a.volume() > MAX_VOLUME) throw new IOException("that area is " + a.volume() + " blocks; one keep can be at most " + MAX_VOLUME + + ". Split it, or use /rewild protect for whole chunks"); + for (Entry e : entries) if (e.owner().equalsIgnoreCase(owner) && sameBox(e.area(), a)) return e; + int id = 1; + for (Entry e : entries) id = Math.max(id, e.id() + 1); + Entry e = new Entry(id, owner, a); + entries.add(e); + save(); + return e; + } + + static boolean sameBox(KeepPlan.Area a, KeepPlan.Area b) { + return a.world().equals(b.world()) && a.minX() == b.minX() && a.minY() == b.minY() && a.minZ() == b.minZ() + && a.maxX() == b.maxX() && a.maxY() == b.maxY() && a.maxZ() == b.maxZ(); + } + + /** Removes by id. Returns the removed entry, or null. */ + public Entry remove(int id) throws IOException { + for (Entry e : entries) { + if (e.id() == id) { + entries.remove(e); + save(); + return e; + } + } + return null; + } + + /** Removes an owner's entries with exactly these corners, so a plugin can undo its own keep without knowing ids. */ + public int removeExact(String owner, String world, int x1, int y1, int z1, int x2, int y2, int z2) throws IOException { + KeepPlan.Area match = new KeepPlan.Area(world, x1, y1, z1, x2, y2, z2, ""); + int before = entries.size(); + entries.removeIf(e -> e.owner().equalsIgnoreCase(owner) && sameBox(e.area(), match)); + if (entries.size() != before) save(); + return before - entries.size(); + } + + private void save() throws IOException { + StringBuilder sb = new StringBuilder("# Kept with /rewild keep: id owner world minX minY minZ maxX maxY maxZ note\n"); + for (Entry e : entries) { + KeepPlan.Area a = e.area(); + sb.append(e.id()).append(' ').append(e.owner()).append(' ').append(a.world()).append(' ').append(a.minX()).append(' ') + .append(a.minY()).append(' ').append(a.minZ()).append(' ').append(a.maxX()).append(' ').append(a.maxY()).append(' ') + .append(a.maxZ()).append(' ').append(a.reason().replace('\n', ' ')).append('\n'); + } + Files.createDirectories(file.getParent()); + Path tmp = file.resolveSibling(FILE + ".tmp"); + Files.writeString(tmp, sb.toString(), StandardCharsets.UTF_8); + Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } +} diff --git a/src/main/java/com/extantape/rewild/keep/KeepPlan.java b/src/main/java/com/extantape/rewild/keep/KeepPlan.java new file mode 100644 index 0000000..af2098e --- /dev/null +++ b/src/main/java/com/extantape/rewild/keep/KeepPlan.java @@ -0,0 +1,82 @@ +package com.extantape.rewild.keep; + +import com.extantape.rewild.config.RegenConfig; +import com.extantape.rewild.region.KeepMask; +import com.extantape.rewild.region.Merge; +import com.extantape.rewild.region.Nbt; +import com.extantape.rewild.store.ChunkRecord; + +import java.io.IOException; +import java.util.List; + +/** + * Works out exactly which blocks of a chunk stay when it rewilds: everything a player placed or filled, every block of + * a type admins listed in keep-blocks, every block inside an area kept by an admin or a respected plugin, and what holds + * each of those up. Pure logic over saved data, so it runs with the server stopped and in tests. + */ +public final class KeepPlan { + private KeepPlan() {} + + /** A box in world coordinates, kept by an admin or a respected plugin. */ + public record Area(String world, int minX, int minY, int minZ, int maxX, int maxY, int maxZ, String reason) { + public Area { + int ax = Math.min(minX, maxX), bx = Math.max(minX, maxX); + int ay = Math.min(minY, maxY), by = Math.max(minY, maxY); + int az = Math.min(minZ, maxZ), bz = Math.max(minZ, maxZ); + minX = ax; maxX = bx; minY = ay; maxY = by; minZ = az; maxZ = bz; + } + + public boolean touchesChunk(int cx, int cz) { + return maxX >= cx * 16 && minX <= cx * 16 + 15 && maxZ >= cz * 16 && minZ <= cz * 16 + 15; + } + + public long volume() { + return (long) (maxX - minX + 1) * (maxY - minY + 1) * (maxZ - minZ + 1); + } + } + + /** + * The mask for one chunk. {@code cutY} is Integer.MIN_VALUE for a whole-chunk rewild, or the height from which a + * below-ground repair leaves everything alone. + */ + public static KeepMask build(Nbt.Compound real, ChunkRecord record, List areas, List keepBlocks, int cutY) + throws IOException { + int[] range = blockRange(real); + KeepMask mask = new KeepMask(range[0], range[1] - range[0]); + int cx = real.getInt("xPos", 0), cz = real.getInt("zPos", 0); + if (cutY != Integer.MIN_VALUE) mask.keepFrom(cutY); + if (record != null) { + for (long p : record.placed.keySet()) mask.keep(ChunkRecord.posX(p), ChunkRecord.posY(p), ChunkRecord.posZ(p)); + for (long p : record.filledContainers) mask.keep(ChunkRecord.posX(p), ChunkRecord.posY(p), ChunkRecord.posZ(p)); + } + for (Area a : areas) { + if (a.touchesChunk(cx, cz)) mask.keepBox(cx, cz, a.minX(), a.minY(), a.minZ(), a.maxX(), a.maxY(), a.maxZ()); + } + KeepMask.Blocks blocks = Merge.blocks(real); + if (!keepBlocks.isEmpty()) { + mask.keepMatching(blocks, name -> RegenConfig.matchesAny(keepBlocks, materialName(name))); + } + mask.addSupports(blocks); + return mask; + } + + /** "minecraft:dirt_path" as the material name config.yml uses, "DIRT_PATH". */ + static String materialName(String id) { + String n = id.startsWith("minecraft:") ? id.substring(10) : id; + return n.toUpperCase(java.util.Locale.ROOT); + } + + /** The lowest block y and one past the highest, from the sections that hold blocks. */ + static int[] blockRange(Nbt.Compound real) throws IOException { + Nbt.NbtList sections = real.getList("sections"); + int lo = Integer.MAX_VALUE, hi = Integer.MIN_VALUE; + if (sections != null) for (Nbt.Compound s : sections.compounds()) { + if (s.getCompound("block_states") == null) continue; + int y = s.getInt("Y", 0); + lo = Math.min(lo, y); + hi = Math.max(hi, y); + } + if (lo == Integer.MAX_VALUE) throw new IOException("chunk has no block sections"); + return new int[]{lo * 16, (hi + 1) * 16}; + } +} diff --git a/src/main/java/com/extantape/rewild/keep/Keepers.java b/src/main/java/com/extantape/rewild/keep/Keepers.java index 2711017..8d85b62 100644 --- a/src/main/java/com/extantape/rewild/keep/Keepers.java +++ b/src/main/java/com/extantape/rewild/keep/Keepers.java @@ -37,12 +37,18 @@ public static final class Result { public final Set respawnChunks = new HashSet<>(); public final Map> external = new HashMap<>(); public String externalProblem; + /** Block areas from respected plugins and /rewild keep, kept in place while the land around them rewilds. */ + public final List areas = new ArrayList<>(); } private final String claimPlugin; + private final Respect respect; + private final CommandKeeps commandKeeps; - public Keepers(String claimPlugin) { + public Keepers(String claimPlugin, Respect respect, CommandKeeps commandKeeps) { this.claimPlugin = claimPlugin; + this.respect = respect; + this.commandKeeps = commandKeeps; } public Result read(World w) { @@ -50,6 +56,7 @@ public Result read(World w) { readClaims(w, r); readRespawns(w, r); readProviders(w, r); + readCommandKeeps(w, r); readMultiversePortals(w, r); return r; } @@ -109,30 +116,59 @@ private static void add(World w, Location l, Result r) { r.respawnChunks.add(ChunkRecord.key(l.getBlockX() >> 4, l.getBlockZ() >> 4)); } - private static void readProviders(World w, Result r) { + private void readProviders(World w, Result r) { + List installed = new ArrayList<>(); + for (Plugin p : Bukkit.getPluginManager().getPlugins()) installed.add(p.getName()); + String blocked = respect.blocking(installed, name -> { + Plugin p = Bukkit.getPluginManager().getPlugin(name); + return p != null && p.isEnabled(); + }); + if (blocked != null) r.externalProblem = blocked; Set reporting = new HashSet<>(); for (RegisteredServiceProvider reg : Bukkit.getServicesManager().getRegistrations(KeepProvider.class)) { String owner = reg.getPlugin().getName(); + if (!respect.respected(owner)) continue; reporting.add(owner); try { Collection keeps = reg.getProvider().keeps(w); - if (keeps == null) throw new IllegalStateException("returned null"); + if (keeps == null) throw new IllegalStateException("keeps returned null"); for (KeepProvider.Keep k : keeps) { r.external.computeIfAbsent(ChunkRecord.key(k.chunkX(), k.chunkZ()), kk -> new ArrayList<>()) .add(k.reason() == null || k.reason().isBlank() ? "kept by " + owner : k.reason() + " (" + owner + ")"); } + Collection areas = reg.getProvider().areas(w); + if (areas == null) throw new IllegalStateException("areas returned null"); + for (KeepProvider.Area a : areas) { + String why = a.reason() == null || a.reason().isBlank() ? "kept by " + owner : a.reason() + " (" + owner + ")"; + r.areas.add(new KeepPlan.Area(w.getName(), a.minX(), a.minY(), a.minZ(), a.maxX(), a.maxY(), a.maxZ(), why)); + } } catch (RuntimeException | LinkageError e) { - r.externalProblem = owner + " couldn't say which chunks to keep (" + e + ")"; + r.externalProblem = owner + " couldn't say what to keep (" + e + ")"; } } for (String expected : EXPECTED_PROVIDERS) { Plugin p = Bukkit.getPluginManager().getPlugin(expected); - if (p != null && p.isEnabled() && !reporting.contains(p.getName())) { + if (p != null && p.isEnabled() && respect.respected(p.getName()) && !reporting.contains(p.getName())) { r.externalProblem = expected + " is running but doesn't report what it needs kept, so nothing resets"; } } } + /** Areas kept with /rewild keep: an admin's always count, a plugin's only while that plugin is respected. */ + private void readCommandKeeps(World w, Result r) { + if (commandKeeps.problem() != null) { + r.externalProblem = commandKeeps.problem(); + return; + } + for (CommandKeeps.Entry e : commandKeeps.all()) { + if (!e.area().world().equals(w.getName())) continue; + if (!e.byAdmin() && !respect.respected(e.owner())) continue; + String note = e.area().reason().isBlank() ? "" : ": " + e.area().reason(); + r.areas.add(new KeepPlan.Area(w.getName(), e.area().minX(), e.area().minY(), e.area().minZ(), e.area().maxX(), + e.area().maxY(), e.area().maxZ(), "kept with /rewild keep by " + e.owner() + note + " (#" + e.id() + ")")); + } + } + /** Multiverse-Portals keeps portals in portals.yml as "x,y,z:x,y,z" with a world name. */ private static void readMultiversePortals(World w, Result r) { Plugin mvp = Bukkit.getPluginManager().getPlugin("Multiverse-Portals"); diff --git a/src/main/java/com/extantape/rewild/keep/Respect.java b/src/main/java/com/extantape/rewild/keep/Respect.java new file mode 100644 index 0000000..bd4a208 --- /dev/null +++ b/src/main/java/com/extantape/rewild/keep/Respect.java @@ -0,0 +1,120 @@ +package com.extantape.rewild.keep; + +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.TreeSet; + +/** + * Which installed plugins Rewild respects: everything a respected plugin reports is kept. An admin answers once per + * plugin with /rewild plugins, and the answers live in keep-plugins.yml. Until every installed plugin has an answer, + * nothing resets, so a plugin installed later can't have its builds rewilded before an admin has looked at it. + */ +public final class Respect { + public static final String FILE = "keep-plugins.yml"; + + private final File file; + private final Set respect = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + private final Set ignore = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + private String problem; + + public Respect(File dataFolder) { + this.file = new File(dataFolder, FILE); + load(); + } + + public void load() { + respect.clear(); + ignore.clear(); + problem = null; + if (!file.exists()) return; + YamlConfiguration y = new YamlConfiguration(); + try { + y.load(file); + } catch (Exception e) { + problem = FILE + " can't be read (" + e.getMessage() + ")"; + return; + } + respect.addAll(y.getStringList("keep-plugins")); + ignore.addAll(y.getStringList("ignore-plugins")); + Set both = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + both.addAll(respect); + both.retainAll(ignore); + if (!both.isEmpty()) problem = FILE + " lists " + String.join(", ", both) + " as both kept and ignored"; + } + + public void save() throws IOException { + YamlConfiguration y = new YamlConfiguration(); + y.options().setHeader(List.of( + "Which plugins Rewild respects. Everything a respected plugin reports (through Rewild's KeepProvider, or", + "/rewild keep plugin:) stays in place when the land around it rewilds. Set with /rewild plugins.", + "Nothing resets while an installed plugin is in neither list.")); + y.set("keep-plugins", new ArrayList<>(respect)); + y.set("ignore-plugins", new ArrayList<>(ignore)); + File tmp = new File(file.getParentFile(), FILE + ".tmp"); + y.save(tmp); + java.nio.file.Files.move(tmp.toPath(), file.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING, + java.nio.file.StandardCopyOption.ATOMIC_MOVE); + } + + /** Null if the file is fine; otherwise why nothing can reset. */ + public String problem() { + return problem; + } + + public boolean respected(String plugin) { + return respect.contains(plugin); + } + + public boolean ignored(String plugin) { + return ignore.contains(plugin); + } + + public Set respectedNames() { + return Set.copyOf(respect); + } + + public void setRespected(String plugin, boolean yes) { + if (yes) { + ignore.remove(plugin); + respect.add(plugin); + } else { + respect.remove(plugin); + ignore.add(plugin); + } + } + + /** Installed plugins with no answer yet, sorted. Rewild itself never needs one. */ + public List unanswered(Collection installed) { + List out = new ArrayList<>(); + for (String p : new TreeSet<>(installed)) { + if (p.equalsIgnoreCase("Rewild")) continue; + if (!respect.contains(p) && !ignore.contains(p)) out.add(p); + } + return out; + } + + /** Why nothing can reset because of the answers, or null. */ + public String blocking(Collection installed, java.util.function.Predicate running) { + if (problem != null) return problem; + List open = unanswered(installed); + if (!open.isEmpty()) { + return "nothing resets until an admin says which plugins to respect: " + String.join(", ", open) + + " (/rewild plugins)"; + } + for (String p : respect) { + if (!running.test(p)) return p + " is respected but isn't running, so what it keeps can't be read"; + } + return null; + } + + static String norm(String s) { + return s.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/com/extantape/rewild/region/KeepMask.java b/src/main/java/com/extantape/rewild/region/KeepMask.java new file mode 100644 index 0000000..2d2f1c0 --- /dev/null +++ b/src/main/java/com/extantape/rewild/region/KeepMask.java @@ -0,0 +1,176 @@ +package com.extantape.rewild.region; + +import java.util.ArrayList; +import java.util.BitSet; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +/** + * The block positions in one chunk that stay exactly as they are when the chunk rewilds. Everything else comes from + * freshly generated terrain. Positions are chunk-local x and z (0 to 15) and the absolute y. + */ +public final class KeepMask { + private final int minY, height; + private final BitSet bits; + + public KeepMask(int minY, int height) { + this.minY = minY; + this.height = height; + this.bits = new BitSet(height * 256); + } + + public int minY() { + return minY; + } + + public int height() { + return height; + } + + private int index(int x, int y, int z) { + return ((y - minY) << 8) | (z << 4) | x; + } + + public boolean inRange(int x, int y, int z) { + return x >= 0 && x < 16 && z >= 0 && z < 16 && y >= minY && y < minY + height; + } + + public void keep(int x, int y, int z) { + if (inRange(x, y, z)) bits.set(index(x, y, z)); + } + + public boolean kept(int x, int y, int z) { + return inRange(x, y, z) && bits.get(index(x, y, z)); + } + + /** Keeps every position at or above y: what a below-ground repair leaves alone. */ + public void keepFrom(int y) { + int from = Math.max(y, minY) - minY; + if (from < height) bits.set(from << 8, height << 8); + } + + /** Keeps the part of a world-coordinate box that falls inside the chunk at chunkX, chunkZ. */ + public void keepBox(int chunkX, int chunkZ, int minX, int minBoxY, int minZ, int maxX, int maxBoxY, int maxZ) { + int x0 = Math.max(minX, chunkX * 16), x1 = Math.min(maxX, chunkX * 16 + 15); + int z0 = Math.max(minZ, chunkZ * 16), z1 = Math.min(maxZ, chunkZ * 16 + 15); + int y0 = Math.max(minBoxY, minY), y1 = Math.min(maxBoxY, minY + height - 1); + for (int y = y0; y <= y1; y++) for (int z = z0; z <= z1; z++) for (int x = x0; x <= x1; x++) keep(x - chunkX * 16, y, z - chunkZ * 16); + } + + public int count() { + return bits.cardinality(); + } + + public boolean isEmpty() { + return bits.isEmpty(); + } + + /** Every kept position as {x, y, z}. */ + public List positions() { + List out = new ArrayList<>(); + for (int i = bits.nextSetBit(0); i >= 0; i = bits.nextSetBit(i + 1)) out.add(new int[]{i & 15, (i >> 8) + minY, (i >> 4) & 15}); + return out; + } + + /** The block states of a saved chunk, read by position. */ + public interface Blocks { + /** The state at a chunk-local position: its name, such as "minecraft:lantern", and its properties (maybe empty). */ + State at(int x, int y, int z); + } + + public record State(String name, Map properties) { + public String prop(String key) { + return properties == null ? null : properties.get(key); + } + } + + /** Keeps every block whose name matches, for example dirt paths or an admin's listed lanterns. */ + public void keepMatching(Blocks blocks, Predicate names) { + for (int y = minY; y < minY + height; y++) { + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + State s = blocks.at(x, y, z); + if (s != null && names.test(s.name())) keep(x, y, z); + } + } + } + } + + /** + * Adds what each kept block needs so it never floats or splits: the other half of a door, bed or tall plant, and + * the block it hangs from, stands on or is fixed to. Air, water and lava need nothing. One level deep: the + * support's own support comes from the fresh terrain or is itself kept. + */ + public void addSupports(Blocks blocks) { + List kept = positions(); + List partners = new ArrayList<>(); + for (int[] p : kept) { + State s = blocks.at(p[0], p[1], p[2]); + if (s == null) continue; + int[] other = partner(s, p); + if (other != null) partners.add(other); + } + for (int[] o : partners) keep(o[0], o[1], o[2]); + List supports = new ArrayList<>(); + for (int[] p : positions()) { + State s = blocks.at(p[0], p[1], p[2]); + if (s == null || needsNothing(s.name())) continue; + int[] d = support(s); + supports.add(new int[]{p[0] + d[0], p[1] + d[1], p[2] + d[2]}); + } + for (int[] o : supports) keep(o[0], o[1], o[2]); + } + + static boolean needsNothing(String name) { + String n = bare(name); + return n.equals("air") || n.equals("cave_air") || n.equals("void_air") || n.equals("water") || n.equals("lava") + || n.equals("bubble_column") || n.equals("light") || n.equals("barrier") || n.equals("structure_void"); + } + + static String bare(String name) { + return name.startsWith("minecraft:") ? name.substring(10) : name; + } + + /** The other half of a two-block thing, or null. */ + static int[] partner(State s, int[] p) { + String half = s.prop("half"); + if ("lower".equals(half)) return new int[]{p[0], p[1] + 1, p[2]}; + if ("upper".equals(half)) return new int[]{p[0], p[1] - 1, p[2]}; + String part = s.prop("part"); + int[] f = facing(s.prop("facing")); + if (f != null && "foot".equals(part)) return new int[]{p[0] + f[0], p[1], p[2] + f[2]}; + if (f != null && "head".equals(part)) return new int[]{p[0] - f[0], p[1], p[2] - f[2]}; + return null; + } + + /** The offset from a block to the block holding it up. */ + static int[] support(State s) { + String n = bare(s.name()); + int[] f = facing(s.prop("facing")); + if ("true".equals(s.prop("hanging"))) return new int[]{0, 1, 0}; + String face = s.prop("face"); + if ("ceiling".equals(face)) return new int[]{0, 1, 0}; + if ("wall".equals(face) && f != null) return new int[]{-f[0], 0, -f[2]}; + String attach = s.prop("attachment"); + if ("ceiling".equals(attach)) return new int[]{0, 1, 0}; + if (("single_wall".equals(attach) || "double_wall".equals(attach)) && f != null) return new int[]{-f[0], 0, -f[2]}; + if (n.equals("cocoa") && f != null) return new int[]{f[0], 0, f[2]}; + if (f != null && (n.contains("wall_") || n.equals("ladder") || n.equals("tripwire_hook") || n.endsWith("_wall_fan"))) { + return new int[]{-f[0], 0, -f[2]}; + } + if (n.equals("hanging_roots") || n.endsWith("_hanging_sign") && !n.contains("wall") || n.equals("spore_blossom")) return new int[]{0, 1, 0}; + return new int[]{0, -1, 0}; + } + + static int[] facing(String f) { + if (f == null) return null; + return switch (f) { + case "north" -> new int[]{0, 0, -1}; + case "south" -> new int[]{0, 0, 1}; + case "east" -> new int[]{1, 0, 0}; + case "west" -> new int[]{-1, 0, 0}; + default -> null; + }; + } +} diff --git a/src/main/java/com/extantape/rewild/region/Merge.java b/src/main/java/com/extantape/rewild/region/Merge.java new file mode 100644 index 0000000..d230c50 --- /dev/null +++ b/src/main/java/com/extantape/rewild/region/Merge.java @@ -0,0 +1,289 @@ +package com.extantape.rewild.region; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Rewilds a saved chunk: every block comes from a freshly generated copy of the same chunk, except the positions in a + * {@link KeepMask}, which stay exactly as they were, with their block entities and scheduled ticks. Works on NBT with + * the server stopped, like every other change Rewild makes to a world. + */ +public final class Merge { + private Merge() {} + + /** One section's block states, decoded to a palette and a per-block index. */ + static final class Section { + final List palette; + final int[] index = new int[4096]; + + Section(Nbt.Compound section) throws IOException { + Nbt.Compound states = section == null ? null : section.getCompound("block_states"); + Nbt.NbtList p = states == null ? null : states.getList("palette"); + if (p == null || p.items().isEmpty()) throw new IOException("a section has no block palette"); + palette = p.items(); + long[] data = states.get("data") instanceof long[] d ? d : null; + if (palette.size() > 1) { + if (data == null) throw new IOException("a section has a palette but no data"); + int bits = bits(palette.size()); + for (int i = 0; i < 4096; i++) { + int v = ChunkFacts.paletteIndex(data, bits, i); + if (v >= palette.size()) throw new IOException("a section's data points outside its palette"); + index[i] = v; + } + } + } + + Object at(int i) { + return palette.get(index[i]); + } + } + + static int bits(int paletteSize) { + return Math.max(4, 32 - Integer.numberOfLeadingZeros(paletteSize - 1)); + } + + /** Packs 4096 palette entries the way the game saves them: entries never span two longs. */ + static Nbt.Compound encode(Object[] blocks) { + Map seen = new LinkedHashMap<>(); + List palette = new ArrayList<>(); + int[] idx = new int[4096]; + for (int i = 0; i < 4096; i++) { + String key = keyOf(blocks[i]); + Integer at = seen.get(key); + if (at == null) { + at = palette.size(); + seen.put(key, at); + palette.add(Underground.deepCopyAnyPublic(blocks[i])); + } + idx[i] = at; + } + Nbt.Compound out = new Nbt.Compound(); + out.put("palette", paletteList(palette)); + if (palette.size() > 1) { + int bits = bits(palette.size()), perLong = 64 / bits; + long[] data = new long[(4096 + perLong - 1) / perLong]; + for (int i = 0; i < 4096; i++) data[i / perLong] |= ((long) idx[i]) << ((i % perLong) * bits); + out.put("data", data); + } + return out; + } + + /** + * Writes a palette the way the game does. Since 26.x a state with no properties is saved as a bare name, so a + * palette of only those is a list of strings, and a palette mixing them with {id, properties} entries wraps each + * bare name as {"": name}. A merged section can draw from both kinds of palette, so it's normalised here. + */ + static Nbt.NbtList paletteList(List palette) { + boolean allStrings = true, anyString = false; + for (Object e : palette) { + boolean str = e instanceof String; + allStrings &= str; + anyString |= str; + } + if (allStrings) return new Nbt.NbtList(Nbt.STRING, palette); + if (!anyString) return new Nbt.NbtList(Nbt.COMPOUND, palette); + List wrapped = new ArrayList<>(); + for (Object e : palette) { + if (e instanceof String str) { + Nbt.Compound w = new Nbt.Compound(); + w.put("", str); + wrapped.add(w); + } else { + wrapped.add(e); + } + } + return new Nbt.NbtList(Nbt.COMPOUND, wrapped); + } + + /** + * A stable text form of a palette entry, so equal states share one entry. Saves have used three shapes: {Name, + * Properties} before 26.x, and since 26.x {id, properties} or, with no properties, the name alone wrapped as {"": + * name} in a mixed list. All three name the same state the same way here. + */ + static String keyOf(Object entry) { + StringBuilder sb = new StringBuilder(ChunkFacts.blockName(entry)); + Map props = properties(entry); + new java.util.TreeMap<>(props).forEach((k, v) -> sb.append('|').append(k).append('=').append(v)); + return sb.toString(); + } + + /** A palette entry's properties, whichever shape it was saved in. Empty if it has none. */ + static Map properties(Object entry) { + Map out = new HashMap<>(); + if (entry instanceof Nbt.Compound c) { + Nbt.Compound p = c.getCompound("Properties") != null ? c.getCompound("Properties") : c.getCompound("properties"); + if (p == null && c.get("") instanceof Nbt.Compound inner) return properties(inner); + if (p != null) p.forEach((k, v) -> out.put(k, String.valueOf(v))); + } + return out; + } + + /** Reads block states of a saved chunk by position, for working out what's kept. */ + public static KeepMask.Blocks blocks(Nbt.Compound chunk) throws IOException { + Map sections = new HashMap<>(); + Nbt.NbtList list = chunk.getList("sections"); + if (list != null) for (Nbt.Compound s : list.compounds()) { + if (s.getCompound("block_states") != null) sections.put(s.getInt("Y", 0), new Section(s)); + } + return (x, y, z) -> { + Section s = sections.get(y >> 4); + if (s == null) return null; + Object e = s.at(((y & 15) << 8) | (z << 4) | x); + return new KeepMask.State(ChunkFacts.blockName(e), properties(e)); + }; + } + + /** Returns a new chunk: fresh terrain from {@code fresh}, with the kept positions taken from {@code real}. */ + public static Nbt.Compound chunk(Nbt.Compound real, Nbt.Compound fresh, KeepMask keep) throws IOException { + int minY = keep.minY(); + Underground.checkSameChunk(real, fresh); + Nbt.NbtList realSections = real.getList("sections"), freshSections = fresh.getList("sections"); + if (realSections == null || freshSections == null) throw new IOException("chunk has no sections"); + Map realBy = byY(realSections), freshBy = byY(freshSections); + Underground.checkBedrock(realBy, freshBy, minY); + + Nbt.Compound out = Underground.deepCopy(real); + List sections = new ArrayList<>(); + int lowest = minY >> 4, highest = (minY + keep.height() - 1) >> 4; + java.util.TreeSet ys = new java.util.TreeSet<>(realBy.keySet()); + for (int sy = lowest; sy <= highest; sy++) if (freshBy.containsKey(sy)) ys.add(sy); + for (int sy : ys) { + Nbt.Compound s = realBy.get(sy); + // Sections outside the world's height hold only light data; keep them as they are. + if (sy < lowest || sy > highest) { + sections.add(Underground.deepCopy(s)); + continue; + } + Nbt.Compound f = freshBy.get(sy); + if (f == null) throw new IOException("fresh terrain is missing section " + sy); + if (s == null || s.getCompound("block_states") == null) { + // Never saved with blocks, so there's nothing of the real chunk to keep here. + sections.add(Underground.deepCopy(f)); + continue; + } + boolean any = false, all = true; + for (int i = 0; i < 4096 && (all || !any); i++) { + boolean k = keep.kept(i & 15, sy * 16 + (i >> 8), (i >> 4) & 15); + any |= k; + all &= k; + } + if (all) { + sections.add(Underground.deepCopy(s)); + continue; + } + Nbt.Compound merged = Underground.deepCopy(f); + if (any) { + Section rs = new Section(s), fs = new Section(f); + Object[] blocks = new Object[4096]; + for (int i = 0; i < 4096; i++) { + blocks[i] = keep.kept(i & 15, sy * 16 + (i >> 8), (i >> 4) & 15) ? rs.at(i) : fs.at(i); + } + merged.put("block_states", encode(blocks)); + } + sections.add(merged); + } + out.put("sections", new Nbt.NbtList(Nbt.COMPOUND, sections)); + + int cx = real.getInt("xPos", 0) * 16, cz = real.getInt("zPos", 0) * 16; + for (String list : new String[]{"block_entities", "block_ticks", "fluid_ticks"}) { + out.put(list, byPosition(real.getList(list), fresh.getList(list), keep, cx, cz)); + } + out.put("PostProcessing", postProcessing(fresh.getList("PostProcessing"), keep, minY)); + // The server works out heightmaps and light again when it next loads the chunk. + out.remove("Heightmaps"); + out.remove("starlight.light_version"); + out.put("isLightOn", (byte) 0); + return out; + } + + /** + * Saved entities after a rewild: the real chunk's entities standing on kept positions, plus the fresh chunk's + * entities (such as a village's villagers) everywhere else. {@code fresh} may be null. + */ + public static Nbt.Compound entities(Nbt.Compound real, Nbt.Compound fresh, KeepMask keep, int cx, int cz) { + Nbt.Compound out = Underground.deepCopy(real); + List list = new ArrayList<>(); + Nbt.NbtList r = real.getList("Entities"), f = fresh == null ? null : fresh.getList("Entities"); + if (r != null) for (Nbt.Compound e : r.compounds()) if (entityKept(e, keep, cx, cz)) list.add(Underground.deepCopy(e)); + if (f != null) for (Nbt.Compound e : f.compounds()) if (!entityKept(e, keep, cx, cz)) list.add(Underground.deepCopy(e)); + out.put("Entities", new Nbt.NbtList(Nbt.COMPOUND, list)); + return out; + } + + static boolean entityKept(Nbt.Compound e, KeepMask keep, int cx, int cz) { + if (!(e.getList("Pos") instanceof Nbt.NbtList pos) || pos.items().size() != 3) return false; + // Saved with this chunk, so it's in this chunk; floorMod keeps a mob right on the border inside it. + int x = Math.floorMod((int) Math.floor(((Number) pos.items().get(0)).doubleValue()), 16); + int y = (int) Math.floor(((Number) pos.items().get(1)).doubleValue()); + int z = Math.floorMod((int) Math.floor(((Number) pos.items().get(2)).doubleValue()), 16); + return keep.kept(x, y, z); + } + + /** + * Points of interest (beds, workstations, bells) after a rewild: only sections kept whole stay; the server finds the + * rest again from the blocks when it loads the chunk. + */ + public static Nbt.Compound poi(Nbt.Compound poi, KeepMask keep) { + Nbt.Compound out = Underground.deepCopy(poi); + Nbt.Compound sections = poi.getCompound("Sections"); + if (sections == null) return out; + Nbt.Compound kept = new Nbt.Compound(); + for (var e : sections.entrySet()) { + int y; + try { + y = Integer.parseInt(e.getKey()); + } catch (NumberFormatException ex) { + continue; + } + if (wholeSectionKept(keep, y)) kept.put(e.getKey(), Underground.deepCopyAnyPublic(e.getValue())); + } + out.put("Sections", kept); + return out; + } + + static boolean wholeSectionKept(KeepMask keep, int sy) { + for (int i = 0; i < 4096; i++) if (!keep.kept(i & 15, sy * 16 + (i >> 8), (i >> 4) & 15)) return false; + return true; + } + + /** Entries from {@code real} at kept positions, and from {@code fresh} everywhere else. */ + static Nbt.NbtList byPosition(Nbt.NbtList real, Nbt.NbtList fresh, KeepMask keep, int cx, int cz) { + List out = new ArrayList<>(); + if (real != null) for (Nbt.Compound c : real.compounds()) if (keptAt(c, keep, cx, cz)) out.add(Underground.deepCopy(c)); + if (fresh != null) for (Nbt.Compound c : fresh.compounds()) if (!keptAt(c, keep, cx, cz)) out.add(Underground.deepCopy(c)); + return new Nbt.NbtList(Nbt.COMPOUND, out); + } + + private static boolean keptAt(Nbt.Compound c, KeepMask keep, int cx, int cz) { + return keep.kept(c.getInt("x", 0) - cx, c.getInt("y", Integer.MIN_VALUE), c.getInt("z", 0) - cz); + } + + /** The fresh chunk's pending block updates, minus any at kept positions. */ + static Nbt.NbtList postProcessing(Nbt.NbtList fresh, KeepMask keep, int minY) { + List out = new ArrayList<>(); + if (fresh == null) return new Nbt.NbtList(Nbt.LIST, out); + int minSection = minY >> 4; + for (int i = 0; i < fresh.items().size(); i++) { + List kept = new ArrayList<>(); + if (fresh.items().get(i) instanceof Nbt.NbtList shorts) { + for (Object o : shorts.items()) { + int p = ((Number) o).intValue(); + int x = p & 15, y = (minSection + i) * 16 + ((p >> 8) & 15), z = (p >> 4) & 15; + if (!keep.kept(x, y, z)) kept.add(o); + } + } + out.add(new Nbt.NbtList(Nbt.SHORT, kept)); + } + return new Nbt.NbtList(Nbt.LIST, out); + } + + private static Map byY(Nbt.NbtList sections) { + Map m = new HashMap<>(); + for (Nbt.Compound s : sections.compounds()) m.put(s.getInt("Y", 0), s); + return m; + } +} diff --git a/src/main/java/com/extantape/rewild/region/Nbt.java b/src/main/java/com/extantape/rewild/region/Nbt.java index 61a1807..485a5a9 100644 --- a/src/main/java/com/extantape/rewild/region/Nbt.java +++ b/src/main/java/com/extantape/rewild/region/Nbt.java @@ -169,6 +169,10 @@ private static void writePayload(DataOutput out, Object o, int depth) throws IOE case LIST -> { NbtList l = (NbtList) o; int t = l.items().isEmpty() ? l.elementType() : typeOf(l.items().get(0)); + // Every item in an NBT list has one type. A mixed list would save as bytes the game can't read. + for (Object item : l.items()) { + if (typeOf(item) != t) throw new IOException("an NBT list mixes types " + t + " and " + typeOf(item)); + } out.writeByte(t); out.writeInt(l.items().size()); for (Object item : l.items()) writePayload(out, item, depth + 1); diff --git a/src/main/java/com/extantape/rewild/region/Underground.java b/src/main/java/com/extantape/rewild/region/Underground.java index 33b37fb..b98ced6 100644 --- a/src/main/java/com/extantape/rewild/region/Underground.java +++ b/src/main/java/com/extantape/rewild/region/Underground.java @@ -7,9 +7,8 @@ import java.util.Map; /** - * Below-ground repair on saved chunk data: every 16-block section under the cut is replaced with the same section from - * a freshly generated copy of the chunk, and the surface above is left exactly as it was. Works on NBT with the server - * stopped, so it is as crash-safe as a full reset. + * Checks that freshly generated terrain really is the same chunk of the same world before {@link Merge} uses it, and + * copies NBT. Below-ground repairs are a {@link Merge} that keeps everything above the cut. */ public final class Underground { private Underground() {} @@ -17,100 +16,18 @@ private Underground() {} /** Share of bedrock positions that must match the reference before it's trusted as the same terrain. */ static final double BEDROCK_MATCH = 0.98; - /** - * Returns a new chunk: {@code real} with sections below {@code cutY} taken from {@code reference}. Throws if the two - * don't look like the same chunk of the same world. - */ - public static Nbt.Compound repairChunk(Nbt.Compound real, Nbt.Compound reference, int cutY, int minY) throws IOException { - if (cutY % 16 != 0) throw new IOException("cut " + cutY + " isn't on a section boundary"); + /** Throws unless the two are the same fully generated chunk, saved by the same game version. */ + static void checkSameChunk(Nbt.Compound real, Nbt.Compound reference) throws IOException { int rv = real.getInt("DataVersion", -1), fv = reference.getInt("DataVersion", -2); if (rv != fv) throw new IOException("reference data version " + fv + " doesn't match the world's " + rv); if (real.getInt("xPos", 0) != reference.getInt("xPos", 1) || real.getInt("zPos", 0) != reference.getInt("zPos", 1)) { throw new IOException("reference is a different chunk"); } if (!isFull(real) || !isFull(reference)) throw new IOException("chunk isn't fully generated"); - int cutSection = cutY >> 4; - Map refSections = byY(reference.getList("sections")); - Nbt.NbtList realSections = real.getList("sections"); - if (realSections == null) throw new IOException("chunk has no sections"); - checkBedrock(byY(realSections), refSections, minY); - - Nbt.Compound out = deepCopy(real); - List sections = new ArrayList<>(); - for (Nbt.Compound s : realSections.compounds()) { - int y = s.getInt("Y", 0); - // Sections below the world's floor hold only light data; keep them as they are. - if (y < cutSection && y >= minY >> 4) { - Nbt.Compound r = refSections.get(y); - if (r == null) throw new IOException("reference is missing section " + y); - sections.add(deepCopy(r)); - } else { - sections.add(deepCopy(s)); - } - } - out.put("sections", new Nbt.NbtList(Nbt.COMPOUND, sections)); - - out.put("block_entities", mergeByY(real.getList("block_entities"), reference.getList("block_entities"), cutY)); - out.put("block_ticks", mergeByY(real.getList("block_ticks"), reference.getList("block_ticks"), cutY)); - out.put("fluid_ticks", mergeByY(real.getList("fluid_ticks"), reference.getList("fluid_ticks"), cutY)); - - Nbt.NbtList realPost = real.getList("PostProcessing"), refPost = reference.getList("PostProcessing"); - if (realPost != null) { - int minSection = minY >> 4; - List post = new ArrayList<>(); - for (int i = 0; i < realPost.items().size(); i++) { - boolean below = minSection + i < cutSection; - Object item = below && refPost != null && i < refPost.items().size() ? refPost.items().get(i) - : below ? new Nbt.NbtList(Nbt.SHORT, List.of()) : realPost.items().get(i); - post.add(deepCopyAny(item)); - } - out.put("PostProcessing", new Nbt.NbtList(Nbt.LIST, post)); - } - // Make the server work out light again when it next loads the chunk. - out.remove("starlight.light_version"); - out.put("isLightOn", (byte) 0); - return out; } - /** Drops saved entities below the cut. Anything that would have kept the chunk was already checked. */ - public static Nbt.Compound repairEntities(Nbt.Compound entities, int cutY) { - Nbt.Compound out = deepCopy(entities); - Nbt.NbtList list = entities.getList("Entities"); - if (list == null) return out; - List kept = new ArrayList<>(); - for (Nbt.Compound e : list.compounds()) { - if (entityY(e) >= cutY) kept.add(deepCopy(e)); - } - out.put("Entities", new Nbt.NbtList(Nbt.COMPOUND, kept)); - return out; - } - - /** Drops points of interest (beds, workstations, bells) recorded below the cut; the server finds new ones. */ - public static Nbt.Compound repairPoi(Nbt.Compound poi, int cutY) { - Nbt.Compound out = deepCopy(poi); - Nbt.Compound sections = poi.getCompound("Sections"); - if (sections == null) return out; - Nbt.Compound kept = new Nbt.Compound(); - int cutSection = cutY >> 4; - for (var e : sections.entrySet()) { - int y; - try { - y = Integer.parseInt(e.getKey()); - } catch (NumberFormatException ex) { - kept.put(e.getKey(), deepCopyAny(e.getValue())); - continue; - } - if (y >= cutSection) kept.put(e.getKey(), deepCopyAny(e.getValue())); - } - out.put("Sections", kept); - return out; - } - - static double entityY(Nbt.Compound e) { - if (e.getList("Pos") instanceof Nbt.NbtList pos && pos.items().size() == 3 && pos.items().get(1) instanceof Number n) { - return n.doubleValue(); - } - return Double.MAX_VALUE; + static Object deepCopyAnyPublic(Object o) { + return deepCopyAny(o); } private static boolean isFull(Nbt.Compound c) { @@ -118,12 +35,6 @@ private static boolean isFull(Nbt.Compound c) { return "minecraft:full".equals(s) || "full".equals(s); } - private static Map byY(Nbt.NbtList sections) { - Map m = new HashMap<>(); - if (sections != null) for (Nbt.Compound s : sections.compounds()) m.put(s.getInt("Y", 0), s); - return m; - } - /** The world's lowest few layers are bedrock in a seed-random pattern. A different pattern means different terrain. */ static void checkBedrock(Map real, Map ref, int minY) throws IOException { int section = minY >> 4; @@ -158,14 +69,6 @@ private static boolean[] bedrock(Nbt.Compound section) { return out; } - /** The real list's entries at or above the cut, plus the reference's entries below it. */ - private static Nbt.NbtList mergeByY(Nbt.NbtList real, Nbt.NbtList ref, int cutY) { - List out = new ArrayList<>(); - if (real != null) for (Nbt.Compound c : real.compounds()) if (c.getInt("y", Integer.MAX_VALUE) >= cutY) out.add(deepCopy(c)); - if (ref != null) for (Nbt.Compound c : ref.compounds()) if (c.getInt("y", Integer.MAX_VALUE) < cutY) out.add(deepCopy(c)); - return new Nbt.NbtList(Nbt.COMPOUND, out); - } - public static Nbt.Compound deepCopy(Nbt.Compound c) { return (Nbt.Compound) deepCopyAny(c); } diff --git a/src/main/java/com/extantape/rewild/run/Applier.java b/src/main/java/com/extantape/rewild/run/Applier.java index 474265b..d08e4d3 100644 --- a/src/main/java/com/extantape/rewild/run/Applier.java +++ b/src/main/java/com/extantape/rewild/run/Applier.java @@ -2,11 +2,13 @@ import com.extantape.rewild.config.RegenConfig; import com.extantape.rewild.region.ChunkFacts; +import com.extantape.rewild.keep.KeepPlan; +import com.extantape.rewild.region.KeepMask; +import com.extantape.rewild.region.Merge; import com.extantape.rewild.region.Nbt; import com.extantape.rewild.region.RegionFile; import com.extantape.rewild.region.Underground; import com.extantape.rewild.scan.ContextSnapshot; -import com.extantape.rewild.scan.IntentRules; import com.extantape.rewild.scan.Scanner; import com.extantape.rewild.store.ChunkRecord; import com.extantape.rewild.store.Fingerprint; @@ -152,7 +154,7 @@ void apply(Run run) throws IOException { ctx.minY = Integer.parseInt(store.meta("minY", "-64")); ctx.maxChunks = Integer.MAX_VALUE; ctx.protectedAreas.addAll(store.protectedAreas()); - ctx.intent = IntentRules.from(config); + ctx.everyBlockSince = Long.parseLong(store.meta("everyBlockSince", Long.toString(Long.MAX_VALUE))); snap.fill(ctx); List proposed = run.chunks(); @@ -172,24 +174,27 @@ void apply(Run run) throws IOException { for (Run.Entry e : proposed) { Scanner.Verdict v = scanner.evaluate(e.x(), e.z()); String decision; - if (e.full() && v.kind() == Scanner.Kind.QUEUED) { + if (e.full() && v.kind() == Scanner.Kind.QUEUED && Files.exists(run.referenceFile(e.x(), e.z()))) { plan.add(e); full++; - decision = "reset"; - } else if (!e.full() && v.kind() == Scanner.Kind.UNDERGROUND && Files.exists(run.referenceFile(e.x(), e.z()))) { - plan.add(new Run.Entry(e.x(), e.z(), Math.min(e.cutY(), v.cutY()))); + decision = "rewild"; + } else if (!e.full() && (v.kind() == Scanner.Kind.UNDERGROUND || v.kind() == Scanner.Kind.QUEUED) + && Files.exists(run.referenceFile(e.x(), e.z()))) { + // A chunk that could now rewild whole still only gets the repair that was approved. + int cut = v.kind() == Scanner.Kind.UNDERGROUND ? Math.min(e.cutY(), v.cutY()) : e.cutY(); + plan.add(new Run.Entry(e.x(), e.z(), cut)); under++; - decision = "reset below y=" + Math.min(e.cutY(), v.cutY()); + decision = "reset below y=" + cut; } else { skipped++; decision = "skipped: " + (v.kind() == Scanner.Kind.UNDERGROUND || v.kind() == Scanner.Kind.QUEUED - ? "approved as a different kind of reset, or its reference terrain is missing" : String.join("; ", v.reasons())); + ? "approved as a different kind of reset, or its fresh terrain is missing" : String.join("; ", v.reasons())); } report.append(e.x()).append(", ").append(e.z()).append(": ").append(decision) .append(" (blocks ").append(e.x() * 16).append(", ").append(e.z() * 16).append(")\n"); } RegionFile.writeFileAtomically(run.dir.resolve("check.txt"), report.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); - String summary = full + " full resets and " + under + " below-ground repairs; " + skipped + " skipped (see check.txt)"; + String summary = full + " rewilds and " + under + " below-ground repairs; " + skipped + " skipped (see check.txt)"; if (config.dryRun) { run.set("dryRunAt", Long.toString(now)); @@ -278,7 +283,10 @@ private void execute(Run run, WorldStore store, List plan) throws IOE Path serverRoot = Path.of(store.meta("serverRoot")); Path trash = trashDir(run, ""); Map> files = filesFor(dimension, plan); + Map masks = new java.util.HashMap<>(); + ContextSnapshot snap = ContextSnapshot.read(store.dir()); try { + if (snap == null) throw new IOException("the final check from shutdown is missing, so what to keep can't be worked out"); if (!run.journalHas("backup-done")) { step("backup"); backup(files, serverRoot, trash); @@ -301,21 +309,20 @@ private void execute(Run run, WorldStore store, List plan) throws IOE int idx = RegionFile.index(e.x(), e.z()); RegionFile.RawChunk raw = r.get(idx); if (raw == null) continue; - if (e.full()) { - if (raw.isExternal()) removedExternal.add(RegionFile.externalPath(f.getKey(), idx)); - r.remove(idx); - } else { - Nbt.Compound nbt = r.readNbt(idx); - Nbt.Compound fixed = switch (kind) { - case "region" -> Underground.repairChunk(nbt, readReference(run, e), e.cutY(), - Integer.parseInt(store.meta("minY", "-64"))); - case "entities" -> Underground.repairEntities(nbt, e.cutY()); - default -> Underground.repairPoi(nbt, e.cutY()); - }; - RegionFile.RawChunk packed = RegionFile.compress(Nbt.write(fixed), raw.timestamp()); - if (raw.isExternal() && !packed.isExternal()) removedExternal.add(RegionFile.externalPath(f.getKey(), idx)); - r.put(idx, packed); + KeepMask mask = masks.get(ChunkRecord.key(e.x(), e.z())); + if (mask == null) { + mask = maskFor(run, store, dimension, serverRoot, trash, e, snap); + masks.put(ChunkRecord.key(e.x(), e.z()), mask); } + Nbt.Compound nbt = r.readNbt(idx); + Nbt.Compound fixed = switch (kind) { + case "region" -> Merge.chunk(nbt, readReference(run, e), mask); + case "entities" -> Merge.entities(nbt, readReferenceEntities(run, e), mask, e.x(), e.z()); + default -> Merge.poi(nbt, mask); + }; + RegionFile.RawChunk packed = RegionFile.compress(Nbt.write(fixed), raw.timestamp()); + if (raw.isExternal() && !packed.isExternal()) removedExternal.add(RegionFile.externalPath(f.getKey(), idx)); + r.put(idx, packed); } r.writeAtomically(); for (Path mcc : removedExternal) Files.deleteIfExists(mcc); @@ -325,7 +332,7 @@ private void execute(Run run, WorldStore store, List plan) throws IOE step("store"); long now = System.currentTimeMillis(); for (Run.Entry e : plan) { - if (e.full()) store.edit(e.x(), e.z(), rec -> rec.clearForReset(now)); + if (e.full()) store.editIfPresent(e.x(), e.z(), rec -> rec.afterRewild(now)); else store.editIfPresent(e.x(), e.z(), rec -> rec.clearBelow(e.cutY(), now)); } store.compact(); @@ -401,6 +408,32 @@ private String rollback(Run run, Map> files, Path serverRo return "rollback couldn't restore " + failed.size() + " files; originals are in " + trash; } + /** + * What stays in place in one chunk, worked out from the chunk as it was before the run (its trash copy), the + * tracker's record from before the run, and the areas in the shutdown check. The same inputs give the same mask, + * so a run resumed after a crash keeps exactly the same blocks. + */ + private KeepMask maskFor(Run run, WorldStore store, Path dimension, Path serverRoot, Path trash, Run.Entry e, + ContextSnapshot snap) throws IOException { + Path regionFile = dimension.resolve("region").resolve(RegionFile.fileName(e.x(), e.z())); + Path original = mirror(trash, serverRoot, regionFile); + RegionFile region = RegionFile.load(Files.exists(original) ? original : regionFile); + Nbt.Compound real = region.readNbt(RegionFile.index(e.x(), e.z())); + if (real == null) throw new IOException("chunk " + e.x() + ", " + e.z() + " has no saved data"); + ChunkRecord rec = null; + for (ChunkRecord r : loadRecords(run.dir.resolve("records.bin"))) if (r.x == e.x() && r.z == e.z()) rec = r; + if (rec == null) rec = store.get(e.x(), e.z()); + return KeepPlan.build(real, rec, snap.areas, config.keepBlocks, e.full() ? Integer.MIN_VALUE : e.cutY()); + } + + private static Nbt.Compound readReferenceEntities(Run run, Run.Entry e) throws IOException { + Path f = run.referenceEntitiesFile(e.x(), e.z()); + if (!Files.exists(f)) return null; + try (var in = new GZIPInputStream(new BufferedInputStream(Files.newInputStream(f)))) { + return Nbt.read(in.readAllBytes()); + } + } + private static Nbt.Compound readReference(Run run, Run.Entry e) throws IOException { Path f = run.referenceFile(e.x(), e.z()); try (var in = new GZIPInputStream(new BufferedInputStream(Files.newInputStream(f)))) { diff --git a/src/main/java/com/extantape/rewild/run/Run.java b/src/main/java/com/extantape/rewild/run/Run.java index 9c8d7d6..3aa59f4 100644 --- a/src/main/java/com/extantape/rewild/run/Run.java +++ b/src/main/java/com/extantape/rewild/run/Run.java @@ -148,6 +148,11 @@ public Path referenceFile(int x, int z) { return dir.resolve("reference").resolve(x + "_" + z + ".nbt"); } + /** The fresh chunk's entities, such as a village's villagers. Missing if it had none. */ + public Path referenceEntitiesFile(int x, int z) { + return dir.resolve("reference").resolve(x + "_" + z + ".entities.nbt"); + } + // ---- journal ---- public List journal() throws IOException { diff --git a/src/main/java/com/extantape/rewild/run/RunService.java b/src/main/java/com/extantape/rewild/run/RunService.java index 32c317f..2d85c46 100644 --- a/src/main/java/com/extantape/rewild/run/RunService.java +++ b/src/main/java/com/extantape/rewild/run/RunService.java @@ -95,7 +95,7 @@ public String approvalProblem(Run run) { if (System.currentTimeMillis() - run.getLong("createdAt") > tracking.config().approvalExpiresDays * Scanner.DAY) { return "the scan is older than " + tracking.config().approvalExpiresDays + " days; scan again"; } - Keepers.Result k = new Keepers(tracking.config().claimPlugin).read(w); + Keepers.Result k = tracking.keepers(w); if (k.claimsProblem != null) return "claims can't be read: " + k.claimsProblem; if (k.externalProblem != null) return k.externalProblem; for (Run other : all()) { @@ -108,8 +108,9 @@ public String approvalProblem(Run run) { } /** - * Approves a run. Below-ground repairs first need a freshly generated copy of each chunk, made in a temporary world - * with the same seed and generator, which is deleted afterwards. + * Approves a run. Every chunk first needs a freshly generated copy, made in a temporary world with the same seed and + * generator, which is deleted afterwards. At the restart, the run takes its terrain from that copy and keeps what's + * kept from the real chunk. */ public CompletableFuture approve(Run run, String by) { CompletableFuture out = new CompletableFuture<>(); @@ -119,14 +120,14 @@ public CompletableFuture approve(Run run, String by) { return out; } World w = Bukkit.getWorld(java.util.UUID.fromString(run.get("world"))); - List under; + List entries; try { - under = run.chunks().stream().filter(e -> !e.full()).toList(); + entries = run.chunks(); } catch (IOException e) { out.completeExceptionally(e); return out; } - CompletableFuture> refs = under.isEmpty() ? CompletableFuture.completedFuture(List.of()) : buildReferences(run, w, under); + CompletableFuture> refs = entries.isEmpty() ? CompletableFuture.completedFuture(List.of()) : buildReferences(run, w, entries); refs.whenComplete((dropped, err) -> { if (err != null) { out.completeExceptionally(err); @@ -141,7 +142,7 @@ public CompletableFuture approve(Run run, String by) { String msg = "Run " + run.id + " approved. " + (tracking.config().dryRun ? "dry-run is on, so the next restart only checks it and reports in the log." : "It applies at the next restart, after a final check."); - if (!dropped.isEmpty()) msg += " Left out of the below-ground repairs: " + String.join("; ", dropped) + "."; + if (!dropped.isEmpty()) msg += " Left out, because fresh terrain for them couldn't be made: " + String.join("; ", dropped) + "."; out.complete(msg); } catch (Exception e) { out.completeExceptionally(e); @@ -195,6 +196,15 @@ private CompletableFuture> buildReferences(Run run, World w, List { cleanupReference(null, refFolder); @@ -259,11 +269,12 @@ public java.util.Map shutdownSnapshots() { if (w == null) continue; ContextSnapshot snap = new ContextSnapshot(); try { - Keepers.Result k = new Keepers(tracking.config().claimPlugin).read(w); + Keepers.Result k = tracking.keepers(w); snap.claimsProblem = k.claimsProblem; snap.externalProblem = k.externalProblem; snap.claims.addAll(k.claims); snap.respawnChunks.addAll(k.respawnChunks); + snap.areas.addAll(k.areas); k.external.forEach((key, reasons) -> { for (String r : reasons) snap.external.add(new String[]{Integer.toString(ChunkRecord.keyX(key)), Integer.toString(ChunkRecord.keyZ(key)), r}); }); diff --git a/src/main/java/com/extantape/rewild/scan/ContextSnapshot.java b/src/main/java/com/extantape/rewild/scan/ContextSnapshot.java index 4223349..5f122b6 100644 --- a/src/main/java/com/extantape/rewild/scan/ContextSnapshot.java +++ b/src/main/java/com/extantape/rewild/scan/ContextSnapshot.java @@ -29,6 +29,8 @@ public final class ContextSnapshot { public final List claims = new ArrayList<>(); public final List respawnChunks = new ArrayList<>(); public final List external = new ArrayList<>(); // x, z, reason + /** Block areas kept by admins and respected plugins: kept in place when the chunk around them rewilds. */ + public final List areas = new ArrayList<>(); public void write(Path dir) throws IOException { StringBuilder sb = new StringBuilder("# Written at shutdown for the startup check. Don't edit.\n"); @@ -40,6 +42,10 @@ public void write(Path dir) throws IOException { for (int[] c : claims) sb.append("claim ").append(c[0]).append(' ').append(c[1]).append(' ').append(c[2]).append(' ').append(c[3]).append('\n'); for (long r : respawnChunks) sb.append("respawn ").append(ChunkRecord.keyX(r)).append(' ').append(ChunkRecord.keyZ(r)).append('\n'); for (String[] e : external) sb.append("external ").append(e[0]).append(' ').append(e[1]).append(' ').append(oneLine(e[2])).append('\n'); + for (var a : areas) { + sb.append("area ").append(a.minX()).append(' ').append(a.minY()).append(' ').append(a.minZ()).append(' ') + .append(a.maxX()).append(' ').append(a.maxY()).append(' ').append(a.maxZ()).append(' ').append(oneLine(a.reason())).append('\n'); + } sb.append("end\n"); RegionFile.writeFileAtomically(dir.resolve(FILE), sb.toString().getBytes(StandardCharsets.UTF_8)); } @@ -80,6 +86,12 @@ public static ContextSnapshot read(Path dir) throws IOException { String[] s = rest.split(" ", 3); c.external.add(new String[]{s[0], s[1], s.length > 2 ? s[2] : "kept by another plugin"}); } + case "area" -> { + String[] s = rest.split(" ", 7); + c.areas.add(new com.extantape.rewild.keep.KeepPlan.Area(null, Integer.parseInt(s[0]), Integer.parseInt(s[1]), + Integer.parseInt(s[2]), Integer.parseInt(s[3]), Integer.parseInt(s[4]), Integer.parseInt(s[5]), + s.length > 6 ? s[6] : "kept")); + } case "end" -> ended = true; default -> throw new IOException(f + " has an unknown line: " + line); } @@ -93,6 +105,7 @@ public void fill(Scanner.Context ctx) { ctx.spawnZ = spawnZ; ctx.claims.addAll(claims); ctx.respawnChunks.addAll(respawnChunks); + ctx.areas.addAll(areas); for (String[] e : external) { long k = ChunkRecord.key(Integer.parseInt(e[0]), Integer.parseInt(e[1])); ctx.external.computeIfAbsent(k, kk -> new ArrayList<>()).add(e[2]); diff --git a/src/main/java/com/extantape/rewild/scan/IntentRules.java b/src/main/java/com/extantape/rewild/scan/IntentRules.java deleted file mode 100644 index edc7069..0000000 --- a/src/main/java/com/extantape/rewild/scan/IntentRules.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.extantape.rewild.scan; - -import com.extantape.rewild.config.RegenConfig; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -/** Classifies player-placed materials by name, using the intent lists in the config. */ -public final class IntentRules { - public static final String PATH = "DIRT_PATH"; - - /** -1 means the material keeps its chunk on its own; 0 means it doesn't count; more is a score. */ - private final Map cache = new ConcurrentHashMap<>(); - private final List keep; - private final Map score; - - public IntentRules(List keep, Map score) { - this.keep = keep; - this.score = score; - } - - public static IntentRules from(RegenConfig c) { - return new IntentRules(c.intentKeep, c.intentScore); - } - - public int classify(String material) { - return cache.computeIfAbsent(material, m -> { - if (RegenConfig.matchesAny(keep, m)) return -1; - for (var e : score.entrySet()) if (RegenConfig.matches(e.getKey(), m)) return e.getValue(); - return 0; - }); - } - - public boolean keepsAlone(String material) { - return classify(material) < 0; - } - - public int score(String material) { - return Math.max(0, classify(material)); - } - - /** Whether a placement is worth recording at all. */ - public boolean worthRecording(String material) { - return PATH.equals(material) || classify(material) != 0; - } -} diff --git a/src/main/java/com/extantape/rewild/scan/ScanService.java b/src/main/java/com/extantape/rewild/scan/ScanService.java index 5fd3914..45f1d89 100644 --- a/src/main/java/com/extantape/rewild/scan/ScanService.java +++ b/src/main/java/com/extantape/rewild/scan/ScanService.java @@ -53,14 +53,15 @@ public Scanner.Context context(World w, WorldState s, String[] problem) { ctx.minY = w.getMinHeight(); ctx.maxChunks = cfg.maxChunks; ctx.minGroupSize = cfg.minGroupSize; - ctx.intent = IntentRules.from(cfg); + ctx.everyBlockSince = Long.parseLong(s.store.meta("everyBlockSince", Long.toString(Long.MAX_VALUE))); ctx.protectedAreas.addAll(s.store.protectedAreas()); ctx.spawnX = w.getSpawnLocation().getBlockX() >> 4; ctx.spawnZ = w.getSpawnLocation().getBlockZ() >> 4; - Keepers.Result k = new Keepers(cfg.claimPlugin).read(w); + Keepers.Result k = tracking.keepers(w); ctx.claims.addAll(k.claims); ctx.respawnChunks.addAll(k.respawnChunks); k.external.forEach((key, reasons) -> ctx.external.computeIfAbsent(key, kk -> new ArrayList<>()).addAll(reasons)); + ctx.areas.addAll(k.areas); for (Player p : w.getPlayers()) { ctx.playerChunks.add(ChunkRecord.key(p.getLocation().getBlockX() >> 4, p.getLocation().getBlockZ() >> 4)); } diff --git a/src/main/java/com/extantape/rewild/scan/Scanner.java b/src/main/java/com/extantape/rewild/scan/Scanner.java index b13a010..a70cace 100644 --- a/src/main/java/com/extantape/rewild/scan/Scanner.java +++ b/src/main/java/com/extantape/rewild/scan/Scanner.java @@ -28,7 +28,7 @@ public enum Kind { QUEUED, UNDERGROUND, KEPT, WAITING, UNKNOWN, UNDAMAGED } public enum Category { CLAIM(false), PROTECTED(false), SPAWN(false), RESPAWN(false), PLAYER(false), END_ISLAND(false), EXTERNAL(false), STRUCTURE(false), UNREADABLE(false), - INTENT(true), PATH(true), CONTAINER(true), ENTITY(true), BUFFER(true); + ENTITY(true), BUFFER(true); public final boolean surface; @@ -66,13 +66,15 @@ public static final class Context { public final Set playerChunks = new HashSet<>(); /** Chunks kept by other plugins or live checks, such as graves, portals and loaded pets. */ public final Map> external = new HashMap<>(); - public IntentRules intent; + /** Block areas kept by admins and respected plugins. They stay in place; the chunk around them can still rewild. */ + public final List areas = new ArrayList<>(); + /** When this world's tracker began recording every player-placed block. Chunks born earlier are unknown. */ + public long everyBlockSince; } private final Context ctx; private final Map records; private final Map facts; - private Map pathComponentSize; public Scanner(Context ctx, Map records, Map facts) { this.ctx = ctx; @@ -147,6 +149,10 @@ public Verdict evaluate(int x, int z) { : "generated before tracking began, so natural and player-placed blocks can't be told apart"); return new Verdict(x, z, Kind.UNKNOWN, why, 0); } + if (r.bornAt < ctx.everyBlockSince) { + why.add("generated before Rewild recorded every player-placed block (0.3.0), so a build there could be lost"); + return new Verdict(x, z, Kind.UNKNOWN, why, 0); + } if (r.epoch != ctx.epoch) { why.add("generated before the world generator changed, so a reset wouldn't blend"); return new Verdict(x, z, Kind.UNKNOWN, why, 0); @@ -167,8 +173,13 @@ public Verdict evaluate(int x, int z) { List waiting = timing(r.lastChange, r.lastVisit, r.lastReset); if (waiting.isEmpty()) { why.add(damageText(r)); + String stays = staysInPlace(r); + if (stays != null) why.add(stays); return new Verdict(x, z, Kind.QUEUED, why, 0); } + // Someone still uses the surface. The ground well below it may be abandoned, though. + Verdict under = underground(r, keep); + if (under != null && under.kind() == Kind.UNDERGROUND) return under; return new Verdict(x, z, Kind.WAITING, waiting, 0); } // Kept, or not damaged enough for a full reset. A repair below ground may still apply. @@ -185,6 +196,18 @@ public Verdict evaluate(int x, int z) { return new Verdict(x, z, Kind.UNDAMAGED, why, 0); } + /** What stays as it is when the chunk rewilds, or null if nothing known yet. Listed blocks are found at restart. */ + String staysInPlace(ChunkRecord r) { + List parts = new ArrayList<>(); + if (!r.placed.isEmpty()) parts.add(r.placed.size() + " player-placed block" + (r.placed.size() == 1 ? "" : "s")); + if (!r.filledContainers.isEmpty()) parts.add(r.filledContainers.size() + " container" + (r.filledContainers.size() == 1 ? "" : "s") + " a player filled"); + int areas = 0; + for (var a : ctx.areas) if (a.touchesChunk(r.x, r.z)) areas++; + if (areas > 0) parts.add(areas + " kept area" + (areas == 1 ? "" : "s")); + if (parts.isEmpty()) return null; + return "stays in place: " + String.join(", ", parts) + ", and what holds them up"; + } + private String damageText(ChunkRecord r) { return r.removed() + " blocks removed by players" + (r.explosionBlocks > 0 ? ", " + r.explosionBlocks + " by explosions" : ""); } @@ -246,14 +269,6 @@ private Verdict underground(ChunkRecord r, List keep) { no.add("below ground: " + seam); return new Verdict(r.x, r.z, Kind.KEPT, no, cut); } - int scoreBelow = 0; - for (var e : r.placed.entrySet()) { - if (ChunkRecord.posY(e.getKey()) < cut) scoreBelow += ctx.intent.score(e.getValue()); - } - if (scoreBelow >= s.intentScore()) { - no.add("below ground: player-placed blocks under y=" + cut + " score " + scoreBelow); - return new Verdict(r.x, r.z, Kind.KEPT, no, cut); - } List waiting = timing(r.lastChangeBelowSection(cutSection), r.lastVisitBelowSection(cutSection), r.lastReset); if (!waiting.isEmpty()) { List w = new ArrayList<>(); @@ -346,99 +361,9 @@ private List directReasons(int x, int z, boolean withAreas) { for (ChunkFacts.KeptEntity e : f.keptEntities) out.add(new Reason(Category.ENTITY, e.reason(), (int) Math.floor(e.y()))); } - ChunkRecord r = records.get(key); - if (r != null) { - int score = 0; - int scoreMinY = Integer.MAX_VALUE; - String keeper = null; - int keeperY = 0; - for (var e : r.placed.entrySet()) { - String m = e.getValue(); - int y = ChunkRecord.posY(e.getKey()); - if (ctx.intent.keepsAlone(m)) { - if (keeper == null || y < keeperY) { - keeper = m; - keeperY = y; - } - } else { - int sc = ctx.intent.score(m); - if (sc > 0) { - score += sc; - scoreMinY = Math.min(scoreMinY, y); - } - } - } - if (keeper != null) out.add(new Reason(Category.INTENT, "player-placed " + keeper.toLowerCase(java.util.Locale.ROOT).replace('_', ' '), keeperY)); - if (score >= s.intentScore()) out.add(new Reason(Category.INTENT, "player-built blocks score " + score + " (keeps at " + s.intentScore() + ")", scoreMinY)); - if (!r.filledContainers.isEmpty()) { - int minY = Integer.MAX_VALUE; - for (long p : r.filledContainers) minY = Math.min(minY, ChunkRecord.posY(p)); - out.add(new Reason(Category.CONTAINER, "a container a player put items in", minY)); - } - int pathY = pathKeepY(r); - if (pathY != Integer.MAX_VALUE) out.add(new Reason(Category.PATH, "a player-made path", pathY)); - } return out; } - /** Lowest y of a path block in this chunk that belongs to a long enough connected path, or MAX_VALUE. */ - private int pathKeepY(ChunkRecord r) { - if (pathComponentSize == null) pathComponentSize = pathComponents(); - int min = Integer.MAX_VALUE; - for (var e : r.placed.entrySet()) { - if (!IntentRules.PATH.equals(e.getValue())) continue; - long world = worldPos(r.x, r.z, e.getKey()); - Integer size = pathComponentSize.get(world); - if (size != null && size >= ctx.settings.pathMinConnected()) min = Math.min(min, ChunkRecord.posY(e.getKey())); - } - return min; - } - - private static long worldPos(int cx, int cz, long local) { - return packWorld(cx * 16 + ChunkRecord.posX(local), ChunkRecord.posY(local), cz * 16 + ChunkRecord.posZ(local)); - } - - static long packWorld(int x, int y, int z) { - return ((long) (x & 0x3FFFFFF) << 38) | ((long) (z & 0x3FFFFFF) << 12) | (y & 0xFFF); - } - - /** Groups every recorded player path block into connected paths, across chunk borders, and sizes each group. */ - private Map pathComponents() { - Set all = new HashSet<>(); - Map coords = new HashMap<>(); - for (ChunkRecord r : records.values()) { - for (var e : r.placed.entrySet()) { - if (!IntentRules.PATH.equals(e.getValue())) continue; - int wx = r.x * 16 + ChunkRecord.posX(e.getKey()), wy = ChunkRecord.posY(e.getKey()), wz = r.z * 16 + ChunkRecord.posZ(e.getKey()); - long p = packWorld(wx, wy, wz); - all.add(p); - coords.put(p, new int[]{wx, wy, wz}); - } - } - Map size = new HashMap<>(); - Set seen = new HashSet<>(); - for (long start : all) { - if (seen.contains(start)) continue; - List group = new ArrayList<>(); - ArrayDeque queue = new ArrayDeque<>(); - queue.add(start); - seen.add(start); - while (!queue.isEmpty()) { - long p = queue.poll(); - group.add(p); - int[] c = coords.get(p); - for (int[] d : new int[][]{{1, 0}, {-1, 0}, {0, 1}, {0, -1}}) { - for (int dy = -1; dy <= 1; dy++) { - long n = packWorld(c[0] + d[0], c[1] + dy, c[2] + d[1]); - if (all.contains(n) && seen.add(n)) queue.add(n); - } - } - } - for (long p : group) size.put(p, group.size()); - } - return size; - } - /** Keeps the run under the cap, taking whole connected groups first, largest first. */ private void applyCap(Map verdicts) { Set queued = new HashSet<>(); diff --git a/src/main/java/com/extantape/rewild/store/ChunkRecord.java b/src/main/java/com/extantape/rewild/store/ChunkRecord.java index 2d25e02..fc6314b 100644 --- a/src/main/java/com/extantape/rewild/store/ChunkRecord.java +++ b/src/main/java/com/extantape/rewild/store/ChunkRecord.java @@ -107,11 +107,22 @@ public void clearForReset(long now) { lastChange = 0; } - /** Forget what was below the cut after an underground repair. */ + /** + * After a rewild: the damage is gone and the chunk stays known, because Rewild knows exactly what's in it now, + * fresh terrain plus the kept blocks. Player-placed blocks were kept in place, so they stay recorded. + */ + public void afterRewild(long now) { + removedBySection.clear(); + explosionBlocks = 0; + changeBySection.clear(); + visitBySection.clear(); + lastReset = now; + lastChange = 0; + } + + /** Forget the damage below the cut after an underground repair. Player-placed blocks there were kept in place. */ public void clearBelow(int cutY, long now) { removedBySection.keySet().removeIf(s -> s * 16 < cutY); - placed.keySet().removeIf(p -> posY(p) < cutY); - filledContainers.removeIf(p -> posY(p) < cutY); changeBySection.keySet().removeIf(sec -> sec * 16 < cutY); visitBySection.keySet().removeIf(sec -> sec * 16 < cutY); explosionBlocks = 0; diff --git a/src/main/java/com/extantape/rewild/track/TrackerListener.java b/src/main/java/com/extantape/rewild/track/TrackerListener.java index e461ef5..9354597 100644 --- a/src/main/java/com/extantape/rewild/track/TrackerListener.java +++ b/src/main/java/com/extantape/rewild/track/TrackerListener.java @@ -1,7 +1,6 @@ package com.extantape.rewild.track; import com.extantape.rewild.config.RegenConfig; -import com.extantape.rewild.scan.IntentRules; import com.extantape.rewild.store.ChunkRecord; import com.extantape.rewild.store.WorldState; import org.bukkit.Bukkit; @@ -40,6 +39,7 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.Plugin; +import java.util.ArrayList; import java.util.EnumMap; import java.util.HashMap; import java.util.List; @@ -55,17 +55,11 @@ public final class TrackerListener implements Listener { private final Plugin plugin; private final Tracking tracking; - private IntentRules intent; private final Map> openContainers = new HashMap<>(); - public TrackerListener(Plugin plugin, Tracking tracking, IntentRules intent) { + public TrackerListener(Plugin plugin, Tracking tracking) { this.plugin = plugin; this.tracking = tracking; - this.intent = intent; - } - - public void setIntent(IntentRules intent) { - this.intent = intent; } private void change(Block b, Consumer extra) { @@ -128,13 +122,21 @@ public void onBreak(BlockBreakEvent e) { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onPlace(BlockPlaceEvent e) { - Block b = e.getBlockPlaced(); - String m = b.getType().name(); - long p = pos(b); - boolean keep = intent.worthRecording(m); - change(b, r -> { - if (keep) r.placed.put(p, m); - else r.placed.remove(p); + // Every block a player places stays when the land around it rewilds. A bed or door places two. + List blocks = new ArrayList<>(); + blocks.add(e.getBlockPlaced()); + if (e instanceof org.bukkit.event.block.BlockMultiPlaceEvent multi) { + for (BlockState st : multi.getReplacedBlockStates()) blocks.add(st.getBlock()); + } + change(e.getBlockPlaced(), null); + Bukkit.getScheduler().runTask(plugin, () -> { + // A tick later, so a door's or bed's other half exists; record whatever is really there. + for (Block b : blocks) { + if (b.getType().isAir()) continue; + String m = b.getType().name(); + long p = pos(b); + change(b, r -> r.placed.put(p, m)); + } }); } @@ -168,8 +170,7 @@ public void onInteract(PlayerInteractEvent e) { if (after == before) return; String m = after.name(); long p = pos(b); - boolean keep = after == Material.DIRT_PATH || after == Material.FARMLAND || intent.worthRecording(m); - change(b, r -> { if (keep) r.placed.put(p, m); }); + change(b, r -> r.placed.put(p, m)); }); } @@ -209,8 +210,7 @@ private void placeEntity(Entity entity) { Block b = entity.getLocation().getBlock(); String m = entity.getType().name(); long p = pos(b); - boolean keep = intent.worthRecording(m); - change(b, r -> { if (keep) r.placed.put(p, m); }); + change(b, r -> r.placed.put(p, m)); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @@ -218,7 +218,6 @@ public void onPortal(PortalCreateEvent e) { for (BlockState st : e.getBlocks()) { Block b = st.getBlock(); String m = st.getType().name(); - if (!intent.worthRecording(m)) continue; long p = pos(b); change(b, r -> r.placed.put(p, m)); } diff --git a/src/main/java/com/extantape/rewild/track/Tracking.java b/src/main/java/com/extantape/rewild/track/Tracking.java index 7bcbd21..64394e2 100644 --- a/src/main/java/com/extantape/rewild/track/Tracking.java +++ b/src/main/java/com/extantape/rewild/track/Tracking.java @@ -52,6 +52,26 @@ public RegenConfig config() { return config; } + private com.extantape.rewild.keep.Respect respect; + private com.extantape.rewild.keep.CommandKeeps commandKeeps; + + /** Which plugins an admin chose to respect. Read from keep-plugins.yml. */ + public synchronized com.extantape.rewild.keep.Respect respect() { + if (respect == null) respect = new com.extantape.rewild.keep.Respect(plugin.getDataFolder()); + return respect; + } + + /** Areas kept with /rewild keep. Read from keeps.txt. */ + public synchronized com.extantape.rewild.keep.CommandKeeps commandKeeps() { + if (commandKeeps == null) commandKeeps = new com.extantape.rewild.keep.CommandKeeps(plugin.getDataFolder().toPath()); + return commandKeeps; + } + + /** Reads everything outside the tracker that can keep a chunk or a block, for one world. Main thread only. */ + public com.extantape.rewild.keep.Keepers.Result keepers(World w) { + return new com.extantape.rewild.keep.Keepers(config.claimPlugin, respect(), commandKeeps()).read(w); + } + public void setConfig(RegenConfig config) { this.config = config; } @@ -119,6 +139,9 @@ public void open(World w, WorldStore preopened) { Path packs = Fingerprint.findDatapacks(Bukkit.getWorlds().get(0).getWorldFolder().toPath(), Bukkit.getWorldContainer().toPath()); meta.put("datapacks", packs == null ? "" : packs.toString()); if (store.meta("trackingSince") == null) meta.put("trackingSince", Long.toString(System.currentTimeMillis())); + // From 0.3.0 every player-placed block is recorded. Chunks born before then may hold builds the tracker + // never saw, so they stay unknown. + if (store.meta("everyBlockSince") == null) meta.put("everyBlockSince", Long.toString(System.currentTimeMillis())); store.setMeta(meta); checkGap(w, s); checkFingerprint(w, s, packs); @@ -242,7 +265,7 @@ private void recordVisits() { if (s == null) continue; // An admin looking around in spectator or creative mode doesn't count as a visitor, so inspecting an area // before approving a run doesn't hold it back. - if ((p.hasPermission("rewild.admin") || p.hasPermission("wildernessregen.admin")) && (p.getGameMode() == org.bukkit.GameMode.SPECTATOR + if (p.hasPermission("rewild.admin") && (p.getGameMode() == org.bukkit.GameMode.SPECTATOR || p.getGameMode() == org.bukkit.GameMode.CREATIVE)) continue; int r = config.world(s.name).visitRadiusChunks(); int cx = p.getLocation().getBlockX() >> 4, cz = p.getLocation().getBlockZ() >> 4; diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index beea535..282ab57 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,7 +1,11 @@ # Rewild settings. # -# Nothing resets unless all of these hold: the world is enabled below, an admin has approved a run with -# /rewild approve, and dry-run is false. While dry-run is true, an approved run is checked at the next restart and +# A chunk that rewilds goes back to fresh terrain from the world's seed, except what's kept, which stays exactly as it +# is: every block a player placed, containers players filled, the block types in keep-blocks, areas kept with +# /rewild keep or by a respected plugin (/rewild plugins), and the blocks holding all of those up. +# +# Nothing resets unless all of these hold: the world is enabled below, every installed plugin has an answer in +# /rewild plugins, an admin has approved a run with /rewild approve, and dry-run is false. While dry-run is true, an approved run is checked at the next restart and # reported in the log, and no world file changes. dry-run: true @@ -47,17 +51,13 @@ defaults: # Days a chunk waits after it was reset before it can be reset again. days-since-last-reset: 30 keep: - # Chunks around anything kept are kept too. + # Chunks around a chunk kept whole (a claim, spawn, a pet, an admin's /rewild protect) are kept too. buffer-chunks: 1 # Chunks within this many chunks of a claim are kept. claim-buffer-chunks: 1 spawn-radius-chunks: 8 # Around every player's bed or respawn point. respawn-radius-chunks: 1 - # Player-made dirt paths keep their chunk once this many are connected. Paths across a chunk border count for both. - path-min-connected: 3 - # A chunk whose player-placed blocks add up to this score is kept. See "intent" below. - intent-score: 8 # Chunks holding part of these structures are kept. structures: ["minecraft:stronghold"] # In the End, every chunk within this many blocks of 0,0 is kept, which covers the main island and exit portal. @@ -70,47 +70,10 @@ defaults: # Player-removed blocks below the cut needed before it's worth repairing. min-removed-blocks: 40 -# Blocks that show a player meant something, when a player placed them. Natural ones don't count. -# Patterns may use * as a wildcard. Anything not listed counts for nothing. -intent: - # Any one of these, placed by a player, keeps its chunk. - keep: ["*_BED", "CHEST", "TRAPPED_CHEST", "BARREL", "*SHULKER_BOX", "*_SIGN", "*_HANGING_SIGN", "*_BANNER", - "LECTERN", "ITEM_FRAME", "GLOW_ITEM_FRAME", "ARMOR_STAND", "FLOWER_POT", "POTTED_*", "DECORATED_POT", - "ENCHANTING_TABLE", "BREWING_STAND", "ANVIL", "CHIPPED_ANVIL", "DAMAGED_ANVIL", "RESPAWN_ANCHOR", "BEACON", - "JUKEBOX", "*_DOOR", "NETHER_PORTAL", "OBSIDIAN", "RAIL", "POWERED_RAIL", "DETECTOR_RAIL", "ACTIVATOR_RAIL", - "REDSTONE_WIRE", "REPEATER", "COMPARATOR", "PISTON", "STICKY_PISTON", "OBSERVER", "DISPENSER", "DROPPER", - "HOPPER", "LEVER", "*_BUTTON", "*_PRESSURE_PLATE", "REDSTONE_TORCH", "REDSTONE_WALL_TORCH", - "DAYLIGHT_DETECTOR", "TARGET", "REDSTONE_LAMP", "NOTE_BLOCK", "TRIPWIRE_HOOK", "CRAFTER", "END_PORTAL_FRAME"] - # These add up. A small cabin passes intent-score; a stray torch doesn't. - score: - "*_PLANKS": 1 - "*_STAIRS": 1 - "*_SLAB": 1 - "*_FENCE": 1 - "*_FENCE_GATE": 1 - "*_WALL": 1 - "*GLASS": 1 - "*GLASS_PANE": 1 - "*_WOOL": 1 - "BRICKS": 1 - "*_BRICKS": 1 - "TORCH": 1 - "WALL_TORCH": 1 - "SOUL_TORCH": 1 - "SOUL_WALL_TORCH": 1 - "LANTERN": 2 - "SOUL_LANTERN": 2 - "CAMPFIRE": 2 - "SOUL_CAMPFIRE": 2 - "FARMLAND": 1 - "LADDER": 1 - "FURNACE": 3 - "BLAST_FURNACE": 3 - "SMOKER": 3 - "CRAFTING_TABLE": 3 - "*_TRAPDOOR": 1 - "*_LOG": 1 - "STRIPPED_*": 1 +# Block types that stay wherever they are when the land around them rewilds, as well as everything players placed. +# The blocks holding each one up stay too, so a kept lantern or fence never floats. Patterns may use * as a wildcard. +# Natural ones stay too: listing OAK_FENCE keeps every oak fence, placed or not. +keep-blocks: ["DIRT_PATH"] run: # Most chunks one run may reset. diff --git a/src/main/resources/paper-plugin.yml b/src/main/resources/paper-plugin.yml index 4f0cd83..155a8df 100644 --- a/src/main/resources/paper-plugin.yml +++ b/src/main/resources/paper-plugin.yml @@ -2,7 +2,7 @@ name: Rewild version: '${version}' main: com.extantape.rewild.RewildPlugin api-version: '26.1' -description: Quietly resets abandoned, damaged chunks so survival worlds stay wild, and keeps anything that looks intentional. +description: Rewilds abandoned, mined-out land on Paper servers back to fresh terrain, keeping every player-placed block, path and protected place exactly where it is. website: https://github.com/ExtantApe/Rewild dependencies: server: @@ -31,6 +31,3 @@ permissions: rewild.admin: description: Use /rewild. Players get no commands. default: op - wildernessregen.admin: - description: The old name for rewild.admin, 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/rewild/config/RegenConfigTest.java b/src/test/java/com/extantape/rewild/config/RegenConfigTest.java index fa1c9db..72149f3 100644 --- a/src/test/java/com/extantape/rewild/config/RegenConfigTest.java +++ b/src/test/java/com/extantape/rewild/config/RegenConfigTest.java @@ -41,12 +41,27 @@ void badValuesAreErrorsNotGuesses() { YamlConfiguration y = shipped(); y.set("run.max-chunks", "lots"); y.set("defaults.idle.days-without-changes", -1); - y.set("worlds.w.keep.intent-score", "high"); + y.set("worlds.w.keep.buffer-chunks", "high"); RegenConfig c = new RegenConfig(y); assertFalse(c.healthy()); assertEquals(3, c.errors().size(), String.valueOf(c.errors())); } + @Test + void keepBlocksDefaultToDirtPathsAndOldIntentSettingsAreRetired() { + YamlConfiguration y = shipped(); + RegenConfig c = new RegenConfig(y); + assertEquals(java.util.List.of("DIRT_PATH"), c.keepBlocks); + assertTrue(c.retired.isEmpty(), String.valueOf(c.retired)); + y.set("keep-blocks", java.util.List.of("oak_fence", "*LANTERN")); + y.set("intent.keep", java.util.List.of("CHEST")); + y.set("defaults.keep.intent-score", 8); + c = new RegenConfig(y); + assertTrue(c.healthy(), String.valueOf(c.errors())); + assertEquals(java.util.List.of("OAK_FENCE", "*LANTERN"), c.keepBlocks); + assertEquals(java.util.List.of("intent", "defaults.keep.intent-score"), c.retired); + } + @Test void perWorldOverridesFallBackToDefaults() { YamlConfiguration y = shipped(); diff --git a/src/test/java/com/extantape/rewild/keep/RespectAndKeepsTest.java b/src/test/java/com/extantape/rewild/keep/RespectAndKeepsTest.java new file mode 100644 index 0000000..1b6e448 --- /dev/null +++ b/src/test/java/com/extantape/rewild/keep/RespectAndKeepsTest.java @@ -0,0 +1,105 @@ +package com.extantape.rewild.keep; + +import com.extantape.rewild.scan.ContextSnapshot; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** Nothing resets until every plugin has an answer; kept areas survive a restart, readable as plain text. */ +class RespectAndKeepsTest { + @TempDir + Path dir; + + @Test + void nothingResetsUntilEveryInstalledPluginHasAnAnswer() throws IOException { + Respect r = new Respect(dir.toFile()); + List installed = List.of("Rewild", "Homesteader", "LanternGraves", "Essentials"); + String why = r.blocking(installed, n -> true); + assertNotNull(why); + assertTrue(why.contains("Essentials, Homesteader, LanternGraves"), why); + assertFalse(why.contains("Rewild"), "Rewild never needs an answer about itself"); + r.setRespected("Homesteader", true); + r.setRespected("LanternGraves", true); + assertTrue(r.blocking(installed, n -> true).contains("Essentials")); + r.setRespected("Essentials", false); + assertNull(r.blocking(installed, n -> true)); + r.save(); + Respect again = new Respect(dir.toFile()); + assertTrue(again.respected("homesteader"), "names match whatever their case"); + assertTrue(again.ignored("Essentials")); + assertNull(again.blocking(installed, n -> true)); + // A plugin installed later has no answer, so resets stop until an admin looks at it. + assertNotNull(again.blocking(List.of("Homesteader", "LanternGraves", "Essentials", "Waystation"), n -> true)); + } + + @Test + void aRespectedPluginThatIsntRunningStopsResets() { + Respect r = new Respect(dir.toFile()); + r.setRespected("Homesteader", true); + String why = r.blocking(List.of("Homesteader"), n -> false); + assertNotNull(why); + assertTrue(why.contains("isn't running"), why); + } + + @Test + void aBrokenOrContradictoryFileStopsResets() throws IOException { + Files.writeString(dir.resolve(Respect.FILE), "keep-plugins: [A]\nignore-plugins: [a]\n"); + assertNotNull(new Respect(dir.toFile()).blocking(List.of("A"), n -> true)); + Files.writeString(dir.resolve(Respect.FILE), "keep-plugins: [A\n"); + assertNotNull(new Respect(dir.toFile()).problem()); + } + + @Test + void keepsAreSavedAsPlainLinesAndRemovableByIdOrExactCorners() throws IOException { + CommandKeeps k = new CommandKeeps(dir); + var a = k.add("admin", new KeepPlan.Area("world", 10, 64, 10, 12, 70, 12, "the old well")); + var b = k.add("Homesteader", new KeepPlan.Area("world", 5, 60, 5, 0, 65, 0, "a homestead")); + assertEquals(1, a.id()); + assertEquals(2, b.id()); + assertEquals(0, b.area().minX(), "corners are sorted"); + assertSame(a.id(), k.add("admin", new KeepPlan.Area("world", 10, 64, 10, 12, 70, 12, "again")).id(), "the same keep twice is one keep"); + String text = Files.readString(dir.resolve(CommandKeeps.FILE)); + assertTrue(text.contains("1 admin world 10 64 10 12 70 12 the old well"), text); + + CommandKeeps again = new CommandKeeps(dir); + assertEquals(2, again.all().size()); + assertEquals(0, again.removeExact("admin", "world", 0, 60, 0, 5, 65, 5), "only the owner's own keep matches"); + assertEquals(1, again.removeExact("Homesteader", "world", 5, 65, 5, 0, 60, 0), "corners in any order"); + assertNotNull(again.remove(1)); + assertNull(again.remove(1)); + assertTrue(new CommandKeeps(dir).all().isEmpty()); + } + + @Test + void aHugeKeepIsRefused() { + CommandKeeps k = new CommandKeeps(dir); + assertThrows(IOException.class, () -> k.add("admin", new KeepPlan.Area("world", -30000, -64, -30000, 30000, 320, 30000, "typo"))); + } + + @Test + void anUnreadableKeepsFileIsAProblemNotAnEmptyList() throws IOException { + Files.writeString(dir.resolve(CommandKeeps.FILE), "1 admin world ten 64 10 12 70 12 note\n"); + CommandKeeps k = new CommandKeeps(dir); + assertNotNull(k.problem()); + assertThrows(IOException.class, () -> k.add("admin", new KeepPlan.Area("world", 0, 0, 0, 0, 0, 0, ""))); + } + + @Test + void areasSurviveTheShutdownSnapshot() throws IOException { + ContextSnapshot s = new ContextSnapshot(); + s.takenAt = 5; + s.areas.add(new KeepPlan.Area(null, 1, 2, 3, 4, 5, 6, "a gate (Waystation)")); + s.write(dir); + ContextSnapshot back = ContextSnapshot.read(dir); + assertEquals(1, back.areas.size()); + KeepPlan.Area a = back.areas.get(0); + assertEquals(List.of(1, 2, 3, 4, 5, 6), List.of(a.minX(), a.minY(), a.minZ(), a.maxX(), a.maxY(), a.maxZ())); + assertEquals("a gate (Waystation)", a.reason()); + } +} diff --git a/src/test/java/com/extantape/rewild/region/KeepMaskTest.java b/src/test/java/com/extantape/rewild/region/KeepMaskTest.java new file mode 100644 index 0000000..9b24368 --- /dev/null +++ b/src/test/java/com/extantape/rewild/region/KeepMaskTest.java @@ -0,0 +1,92 @@ +package com.extantape.rewild.region; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** What holds a kept block up, and the other half of two-block things, so nothing kept floats or splits. */ +class KeepMaskTest { + final Map world = new HashMap<>(); + + void put(int x, int y, int z, String name, String... props) { + Map p = new HashMap<>(); + for (int i = 0; i + 1 < props.length; i += 2) p.put(props[i], props[i + 1]); + world.put(x + "," + y + "," + z, new KeepMask.State(name, p)); + } + + KeepMask keep(int x, int y, int z) { + KeepMask m = new KeepMask(-64, 384); + m.keep(x, y, z); + m.addSupports((a, b, c) -> world.getOrDefault(a + "," + b + "," + c, new KeepMask.State("minecraft:stone", Map.of()))); + return m; + } + + @Test + void standingBlocksKeepTheBlockBelow() { + put(5, 70, 5, "minecraft:oak_fence"); + KeepMask m = keep(5, 70, 5); + assertTrue(m.kept(5, 69, 5)); + assertEquals(2, m.count()); + } + + @Test + void hangingThingsKeepTheBlockAbove() { + put(5, 70, 5, "minecraft:lantern", "hanging", "true"); + assertTrue(keep(5, 70, 5).kept(5, 71, 5)); + world.clear(); + put(5, 70, 5, "minecraft:oak_hanging_sign"); + assertTrue(keep(5, 70, 5).kept(5, 71, 5)); + world.clear(); + put(5, 70, 5, "minecraft:stone_button", "face", "ceiling"); + assertTrue(keep(5, 70, 5).kept(5, 71, 5)); + } + + @Test + void wallThingsKeepTheBlockBehindThem() { + // A wall torch facing north sits on the block to its south. + put(5, 70, 5, "minecraft:wall_torch", "facing", "north"); + KeepMask m = keep(5, 70, 5); + assertTrue(m.kept(5, 70, 6)); + assertFalse(m.kept(5, 69, 5)); + world.clear(); + put(5, 70, 5, "minecraft:ladder", "facing", "east"); + assertTrue(keep(5, 70, 5).kept(4, 70, 5)); + world.clear(); + put(5, 70, 5, "minecraft:lever", "face", "wall", "facing", "south"); + assertTrue(keep(5, 70, 5).kept(5, 70, 4)); + } + + @Test + void doorsBedsAndTallPlantsKeepBothHalvesAndWhatTheyStandOn() { + put(5, 70, 5, "minecraft:oak_door", "half", "lower"); + put(5, 71, 5, "minecraft:oak_door", "half", "upper"); + KeepMask m = keep(5, 71, 5); + assertTrue(m.kept(5, 70, 5) && m.kept(5, 69, 5)); + world.clear(); + // A bed's foot, facing east: its head is one block east. + put(5, 70, 5, "minecraft:red_bed", "part", "foot", "facing", "east"); + put(6, 70, 5, "minecraft:red_bed", "part", "head", "facing", "east"); + m = keep(5, 70, 5); + assertTrue(m.kept(6, 70, 5) && m.kept(5, 69, 5) && m.kept(6, 69, 5)); + } + + @Test + void airAndWaterNeedNothing() { + put(5, 70, 5, "minecraft:air"); + assertEquals(1, keep(5, 70, 5).count()); + world.clear(); + put(5, 70, 5, "minecraft:water"); + assertEquals(1, keep(5, 70, 5).count()); + } + + @Test + void positionsOutsideTheChunkAreIgnored() { + put(15, 70, 5, "minecraft:wall_torch", "facing", "west"); + KeepMask m = keep(15, 70, 5); + assertEquals(1, m.count(), "the torch's wall is in the next chunk, which isn't changing"); + assertFalse(m.inRange(16, 70, 5)); + } +} diff --git a/src/test/java/com/extantape/rewild/region/MergeTest.java b/src/test/java/com/extantape/rewild/region/MergeTest.java new file mode 100644 index 0000000..26c2f62 --- /dev/null +++ b/src/test/java/com/extantape/rewild/region/MergeTest.java @@ -0,0 +1,264 @@ +package com.extantape.rewild.region; + +import com.extantape.rewild.TestChunks; +import com.extantape.rewild.keep.KeepPlan; +import com.extantape.rewild.store.ChunkRecord; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** The per-block rewild: fresh terrain everywhere except what's kept, which comes through exactly as it was. */ +class MergeTest { + static final int CX = 2, CZ = -1; + + /** A real chunk: stone to y=64, a player's chest at 4,65,4 and a lantern hanging under a ledge. */ + static Nbt.Compound real() throws IOException { + Nbt.Compound c = TestChunks.chunk(CX, CZ, "minecraft:stone", 64, 7); + set(c, 4, 65, 4, state("minecraft:chest", Map.of("facing", "north"))); + set(c, 8, 70, 8, state("minecraft:lantern", Map.of("hanging", "true"))); + set(c, 8, 71, 8, state("minecraft:cobblestone", Map.of())); + set(c, 1, 64, 1, state("minecraft:dirt_path", Map.of())); + Nbt.Compound chest = new Nbt.Compound(); + chest.put("id", "minecraft:chest"); + chest.put("x", CX * 16 + 4); + chest.put("y", 65); + chest.put("z", CZ * 16 + 4); + chest.put("Items", new Nbt.NbtList(Nbt.COMPOUND, List.of())); + Nbt.Compound oldSpawner = new Nbt.Compound(); + oldSpawner.put("id", "minecraft:mob_spawner"); + oldSpawner.put("x", CX * 16 + 9); + oldSpawner.put("y", 20); + oldSpawner.put("z", CZ * 16 + 9); + c.put("block_entities", new Nbt.NbtList(Nbt.COMPOUND, List.of(chest, oldSpawner))); + c.put("Heightmaps", new Nbt.Compound()); + return c; + } + + static Nbt.Compound fresh() { + return TestChunks.chunk(CX, CZ, "minecraft:granite", 64, 7); + } + + static Nbt.Compound state(String name, Map props) { + Nbt.Compound s = new Nbt.Compound(); + s.put("Name", name); + if (!props.isEmpty()) { + Nbt.Compound p = new Nbt.Compound(); + props.forEach(p::put); + s.put("Properties", p); + } + return s; + } + + /** Sets one block in a test chunk, re-encoding its section. */ + static void set(Nbt.Compound chunk, int x, int y, int z, Nbt.Compound st) throws IOException { + for (Nbt.Compound sec : chunk.getList("sections").compounds()) { + if (sec.getInt("Y", 99) != y >> 4 || sec.getCompound("block_states") == null) continue; + Merge.Section s = new Merge.Section(sec); + Object[] blocks = new Object[4096]; + for (int i = 0; i < 4096; i++) blocks[i] = s.at(i); + blocks[((y & 15) << 8) | (z << 4) | x] = st; + sec.put("block_states", Merge.encode(blocks)); + } + } + + static String name(Nbt.Compound chunk, int x, int y, int z) throws IOException { + return Merge.blocks(chunk).at(x, y, z).name(); + } + + @Test + void encodeRoundTripsEveryPaletteSize() throws IOException { + for (int size : new int[]{1, 2, 16, 17, 33, 300}) { + Object[] blocks = new Object[4096]; + for (int i = 0; i < 4096; i++) blocks[i] = state("minecraft:b" + (i % size), Map.of()); + Nbt.Compound sec = new Nbt.Compound(); + sec.put("block_states", Merge.encode(blocks)); + Merge.Section back = new Merge.Section(sec); + for (int i = 0; i < 4096; i++) assertEquals("minecraft:b" + (i % size), ((Nbt.Compound) back.at(i)).getString("Name"), "size " + size); + } + } + + /** Converts a test chunk's palettes to the 26.x shape: {"": name} for plain states, {id, properties} otherwise. */ + static Nbt.Compound as26x(Nbt.Compound chunk) { + for (Nbt.Compound sec : chunk.getList("sections").compounds()) { + Nbt.Compound st = sec.getCompound("block_states"); + if (st == null) continue; + List pal = new ArrayList<>(); + for (Nbt.Compound e : st.getList("palette").compounds()) { + Nbt.Compound n = new Nbt.Compound(); + if (e.getCompound("Properties") == null) { + n.put("", e.getString("Name")); + } else { + n.put("id", e.getString("Name")); + n.put("properties", e.getCompound("Properties")); + } + pal.add(n); + } + st.put("palette", new Nbt.NbtList(Nbt.COMPOUND, pal)); + } + return chunk; + } + + @Test + void the26xPaletteShapesKeepEveryStateApart() throws IOException { + // 26.3 saves a state with no properties as {"": name}. Reading those as nameless once made every plain block in + // a merged section the same block; this guards against that. + Nbt.Compound real = as26x(real()), fresh = as26x(fresh()); + ChunkRecord rec = new ChunkRecord(CX, CZ); + rec.placed.put(ChunkRecord.pos(4, 65, 4), "CHEST"); + rec.placed.put(ChunkRecord.pos(8, 70, 8), "LANTERN"); + KeepMask mask = KeepPlan.build(real, rec, List.of(), List.of("DIRT_PATH"), Integer.MIN_VALUE); + assertTrue(mask.kept(8, 71, 8), "a hanging lantern's support, read from 26.x properties"); + assertTrue(mask.kept(1, 64, 1), "a dirt path, named in the 26.x shape"); + Nbt.Compound out = Merge.chunk(real, fresh, mask); + assertEquals("minecraft:chest", name(out, 4, 65, 4)); + assertEquals("north", Merge.blocks(out).at(4, 65, 4).prop("facing")); + assertEquals("minecraft:stone", name(out, 4, 64, 4)); + assertEquals("minecraft:air", name(out, 4, 66, 4), "air above the chest is fresh air, not the chest's neighbour"); + assertEquals("minecraft:granite", name(out, 5, 64, 5)); + assertEquals("minecraft:air", name(out, 5, 65, 5)); + assertEquals("minecraft:cobblestone", name(out, 8, 71, 8)); + } + + @Test + void aSectionMixingBareNamesAndPropertiesSavesAndReadsBack() throws IOException { + // 26.3 saves an all-plain palette as a list of strings. Merging one with a palette that has properties once + // wrote a list mixing strings and compounds, which nothing could read back. + Object[] blocks = new Object[4096]; + Nbt.Compound chest = new Nbt.Compound(); + chest.put("id", "minecraft:chest"); + Nbt.Compound p = new Nbt.Compound(); + p.put("facing", "north"); + chest.put("properties", p); + for (int i = 0; i < 4096; i++) blocks[i] = i == 7 ? chest : i % 2 == 0 ? "minecraft:air" : "minecraft:stone"; + Nbt.Compound sec = new Nbt.Compound(); + sec.put("Y", (byte) 4); + sec.put("block_states", Merge.encode(blocks)); + Nbt.Compound root = new Nbt.Compound(); + root.put("sections", new Nbt.NbtList(Nbt.COMPOUND, List.of(sec))); + Nbt.Compound back = Nbt.read(Nbt.write(root)); + var b = Merge.blocks(back); + assertEquals("minecraft:air", b.at(0, 64, 0).name()); + assertEquals("minecraft:stone", b.at(1, 64, 0).name()); + assertEquals("minecraft:chest", b.at(7, 64, 0).name()); + assertEquals("north", b.at(7, 64, 0).prop("facing")); + Object[] plain = new Object[4096]; + for (int i = 0; i < 4096; i++) plain[i] = i % 2 == 0 ? "minecraft:air" : "minecraft:stone"; + assertEquals(Nbt.STRING, Merge.encode(plain).getList("palette").elementType(), "an all-plain palette stays a list of names"); + } + + @Test + void theWriterRefusesAMixedList() { + Nbt.Compound root = new Nbt.Compound(); + root.put("l", new Nbt.NbtList(Nbt.COMPOUND, List.of(new Nbt.Compound(), "x"))); + assertThrows(IOException.class, () -> Nbt.write(root)); + } + + @Test + void keptBlocksAndTheirSupportsComeThroughEverythingElseIsFresh() throws IOException { + Nbt.Compound real = real(); + ChunkRecord rec = new ChunkRecord(CX, CZ); + rec.placed.put(ChunkRecord.pos(4, 65, 4), "CHEST"); + rec.placed.put(ChunkRecord.pos(8, 70, 8), "LANTERN"); + KeepMask mask = KeepPlan.build(real, rec, List.of(), List.of("DIRT_PATH"), Integer.MIN_VALUE); + Nbt.Compound out = Merge.chunk(real, fresh(), mask); + + assertEquals("minecraft:chest", name(out, 4, 65, 4)); + assertEquals("minecraft:stone", name(out, 4, 64, 4), "the block under the chest holds it up"); + assertEquals("minecraft:lantern", name(out, 8, 70, 8)); + assertEquals("minecraft:cobblestone", name(out, 8, 71, 8), "a hanging lantern keeps the block above it"); + assertEquals("minecraft:dirt_path", name(out, 1, 64, 1), "dirt paths are in keep-blocks by default"); + assertEquals("minecraft:granite", name(out, 0, 30, 0), "everything else is fresh"); + assertEquals("minecraft:granite", name(out, 9, 20, 9)); + + List ids = new ArrayList<>(); + for (Nbt.Compound be : out.getList("block_entities").compounds()) ids.add(be.getString("id") + "@" + be.getInt("y", 0)); + assertEquals(List.of("minecraft:chest@65"), ids, "the chest's contents stay; the mined-out spawner's data goes with its block"); + assertNull(out.get("Heightmaps"), "the server works heightmaps out again"); + assertEquals((byte) 0, out.get("isLightOn")); + assertEquals(real.get("DataVersion"), out.get("DataVersion")); + } + + @Test + void nothingKeptMeansFreshTerrainThroughout() throws IOException { + Nbt.Compound real = real(); + KeepMask mask = KeepPlan.build(real, new ChunkRecord(CX, CZ), List.of(), List.of(), Integer.MIN_VALUE); + assertTrue(mask.isEmpty()); + Nbt.Compound out = Merge.chunk(real, fresh(), mask); + assertEquals("minecraft:granite", name(out, 4, 64, 4)); + assertEquals("minecraft:air", name(out, 4, 65, 4)); + assertEquals(0, out.getList("block_entities").items().size()); + } + + @Test + void anAreaKeepsEveryBlockInsideItEvenAir() throws IOException { + Nbt.Compound real = real(); + // A gate frame: a box straddling the chunk border. Only the part in this chunk applies. + KeepPlan.Area gate = new KeepPlan.Area("w", CX * 16 - 3, 60, CZ * 16 + 2, CX * 16 + 1, 66, CZ * 16 + 3, "a gate"); + KeepMask mask = KeepPlan.build(real, null, List.of(gate), List.of(), Integer.MIN_VALUE); + assertTrue(mask.kept(0, 60, 2) && mask.kept(1, 66, 3)); + assertFalse(mask.kept(2, 62, 2)); + Nbt.Compound out = Merge.chunk(real, fresh(), mask); + assertEquals("minecraft:stone", name(out, 1, 62, 3)); + assertEquals("minecraft:air", name(out, 1, 66, 3)); + assertEquals("minecraft:granite", name(out, 2, 62, 3)); + } + + @Test + void belowGroundRepairKeepsEverythingAboveTheCutAndPlacedBlocksBelowIt() throws IOException { + Nbt.Compound real = real(); + ChunkRecord rec = new ChunkRecord(CX, CZ); + set(real, 3, 10, 3, state("minecraft:torch", Map.of())); + rec.placed.put(ChunkRecord.pos(3, 10, 3), "TORCH"); + KeepMask mask = KeepPlan.build(real, rec, List.of(), List.of(), 32); + Nbt.Compound out = Merge.chunk(real, fresh(), mask); + assertEquals("minecraft:stone", name(out, 0, 40, 0)); + assertEquals("minecraft:granite", name(out, 0, 20, 0)); + assertEquals("minecraft:torch", name(out, 3, 10, 3), "a player's torch in the mine stays"); + assertEquals("minecraft:stone", name(out, 3, 9, 3), "with the block it stands on"); + assertEquals("minecraft:granite", name(out, 3, 8, 3)); + } + + @Test + void differentTerrainIsRefused() throws IOException { + Nbt.Compound other = TestChunks.chunk(CX, CZ, "minecraft:granite", 64, 999_999); + KeepMask mask = new KeepMask(-64, 384); + IOException e = assertThrows(IOException.class, () -> Merge.chunk(real(), other, mask)); + assertTrue(e.getMessage().contains("bedrock"), e.getMessage()); + Nbt.Compound wrongChunk = TestChunks.chunk(CX + 1, CZ, "minecraft:granite", 64, 7); + assertThrows(IOException.class, () -> Merge.chunk(real(), wrongChunk, mask)); + } + + @Test + void entitiesOnKeptBlocksStayAndFreshOnesArrive() { + Nbt.Compound frame = TestChunks.entity("minecraft:item_frame", 65.5); + frame.put("Pos", new Nbt.NbtList(Nbt.DOUBLE, List.of(CX * 16 + 4.5, 65.5, CZ * 16 + 4.5))); + Nbt.Compound zombie = TestChunks.entity("minecraft:zombie", 30); + Nbt.Compound villager = TestChunks.entity("minecraft:villager", 70); + Nbt.Compound real = TestChunks.entities(CX, CZ, frame, zombie); + Nbt.Compound fresh = TestChunks.entities(CX, CZ, villager); + KeepMask mask = new KeepMask(-64, 384); + mask.keep(4, 65, 4); + List ids = new ArrayList<>(); + for (Nbt.Compound e : Merge.entities(real, fresh, mask, CX, CZ).getList("Entities").compounds()) ids.add(e.getString("id")); + assertEquals(List.of("minecraft:item_frame", "minecraft:villager"), ids); + } + + @Test + void pointsOfInterestOnlySurviveInSectionsKeptWhole() { + Nbt.Compound poi = new Nbt.Compound(); + Nbt.Compound sections = new Nbt.Compound(); + sections.put("1", new Nbt.Compound()); + sections.put("4", new Nbt.Compound()); + poi.put("Sections", sections); + KeepMask mask = new KeepMask(-64, 384); + mask.keepFrom(64); + mask.keep(0, 20, 0); + Nbt.Compound out = Merge.poi(poi, mask); + assertEquals(List.of("4"), new ArrayList<>(out.getCompound("Sections").keySet())); + } +} diff --git a/src/test/java/com/extantape/rewild/run/ApplierTest.java b/src/test/java/com/extantape/rewild/run/ApplierTest.java index f8a12d3..cabeb43 100644 --- a/src/test/java/com/extantape/rewild/run/ApplierTest.java +++ b/src/test/java/com/extantape/rewild/run/ApplierTest.java @@ -72,7 +72,7 @@ void world() throws Exception { TestChunks.writeChunk(dim, "poi", 3, 3, poi); Fingerprint fp = new Fingerprint(VERSION, 42, "NORMAL", "vanilla", "vanilla", "none"); s.setMeta(Map.of("dimension", dim.toString(), "serverRoot", root.toString(), "environment", "NORMAL", "minY", "-64", - "epoch", "1", "fingerprint", fp.full(), "datapacks", "", "lastHeartbeat", Long.toString(now - 1000), "name", "testworld")); + "epoch", "1", "fingerprint", fp.full(), "datapacks", "", "lastHeartbeat", Long.toString(now - 1000), "name", "testworld", "everyBlockSince", "0")); s.compact(); s.close(); writeSnapshot(now); @@ -97,6 +97,8 @@ Run approvedRun(List entries) throws Exception { run.set("version", VERSION); run.set("epoch", "1"); run.writeChunks(entries); + // Approval makes fresh terrain for every chunk; granite here, so it's easy to tell from the real stone. + for (Run.Entry e : entries) writeReference(run, e.x(), e.z(), "minecraft:granite", e.x() * 31L + e.z()); run.setState(Run.State.APPROVED, null); return run; } @@ -135,7 +137,7 @@ void dryRunChangesNoWorldFile() throws Exception { assertEquals(before, worldHashes()); run = reload(run); assertEquals(Run.State.APPROVED, run.state()); - assertTrue(run.get("dryRunResult").startsWith("would do 2 full resets"), run.get("dryRunResult")); + assertTrue(run.get("dryRunResult").startsWith("would do 2 rewilds"), run.get("dryRunResult")); } @Test @@ -147,17 +149,20 @@ void appliesThenUndoRestoresExactly() throws Exception { run = reload(run); assertEquals(Run.State.APPLIED, run.state(), run.get("note")); RegionFile after = region("region", 3, 3); - assertNull(after.get(RegionFile.index(3, 3))); - assertNull(after.get(RegionFile.index(3, 4))); - assertNull(region("entities", 3, 3).get(RegionFile.index(3, 3))); - assertNull(region("poi", 3, 3).get(RegionFile.index(3, 3))); + assertEquals("minecraft:granite", fill(after.readNbt(RegionFile.index(3, 3)))); + assertEquals("minecraft:granite", fill(after.readNbt(RegionFile.index(3, 4)))); + // The wild zombie wasn't kept; nothing of the old points of interest is left for the server to trust. + assertEquals(0, region("entities", 3, 3).readNbt(RegionFile.index(3, 3)).getList("Entities").items().size()); + assertEquals(0, region("poi", 3, 3).readNbt(RegionFile.index(3, 3)).getCompound("Sections").size()); // Neighbours are untouched, byte for byte. assertArrayEquals(before.get(RegionFile.index(2, 3)).data(), after.get(RegionFile.index(2, 3)).data()); // The trash mirrors the world's layout. Path trash = data.resolve("trash").resolve(run.get("trashDate")).resolve(run.id).resolve("world/dimensions/minecraft/overworld/region/r.0.0.mca"); assertTrue(Files.exists(trash)); WorldStore s = WorldStore.open(data.resolve("worlds").resolve(WORLD.toString())); - assertFalse(s.get(3, 3).known); + // Rewild knows exactly what's in the chunk now, so it stays known, with its damage cleared. + assertTrue(s.get(3, 3).known); + assertEquals(0, s.get(3, 3).removed()); assertTrue(s.get(3, 3).lastReset > 0); s.close(); @@ -319,8 +324,8 @@ void finalCheckDropsChunksThatChangedOrGainedAClaimAfterApproval() throws Except applier(false).runStartup(); run = reload(run); assertEquals(Run.State.APPLIED, run.state()); - assertNull(region("region", 3, 3).get(RegionFile.index(3, 3))); - assertNotNull(region("region", 3, 4).get(RegionFile.index(3, 4))); + assertEquals("minecraft:granite", fill(region("region", 3, 3).readNbt(RegionFile.index(3, 3)))); + assertEquals("minecraft:stone", fill(region("region", 3, 4).readNbt(RegionFile.index(3, 4)))); assertTrue(Files.readString(run.dir.resolve("check.txt")).contains("3, 4: skipped")); } @@ -375,6 +380,14 @@ void referenceWithDifferentBedrockIsRefused() throws Exception { assertEquals(before, worldHashes()); } + /** The block filling a test chunk's middle, at y=0. */ + static String fill(Nbt.Compound chunk) { + for (Nbt.Compound sec : chunk.getList("sections").compounds()) { + if (sec.getInt("Y", 99) == 0) return sec.getCompound("block_states").getList("palette").compounds().get(0).getString("Name"); + } + return null; + } + void writeReference(Run run, int x, int z, String fill, long seed) throws Exception { Files.createDirectories(run.dir.resolve("reference")); java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream(); diff --git a/src/test/java/com/extantape/rewild/scan/ScannerTest.java b/src/test/java/com/extantape/rewild/scan/ScannerTest.java index 8d947d3..372a4ce 100644 --- a/src/test/java/com/extantape/rewild/scan/ScannerTest.java +++ b/src/test/java/com/extantape/rewild/scan/ScannerTest.java @@ -36,7 +36,6 @@ void setUp() throws Exception { ctx.settings = cfg.world("anything"); ctx.now = NOW; ctx.epoch = 1; - ctx.intent = IntentRules.from(cfg); ctx.spawnX = 1000; ctx.spawnZ = 1000; // A 20x20 patch of known, untouched chunks, all present on disk with ground at y=64. @@ -138,52 +137,45 @@ void neighbourGeneratedBeforeTrackingStopsTheReset() { } @Test - void eachKeepRuleKeepsAndSaysWhy() { - String[][] cases = { - {"CHEST", "chest"}, {"RED_BED", "bed"}, {"OAK_SIGN", "sign"}, {"RAIL", "rail"}}; - for (String[] c : cases) { - setUpQuietly(); - minedOut(0, 0); - place(0, 0, 4, 70, 4, c[0]); - Scanner.Verdict v = verdict(0, 0); - assertEquals(Scanner.Kind.KEPT, v.kind(), c[0]); - assertTrue(String.join(" ", v.reasons()).contains(c[1]), v.reasons().toString()); - } - } - - void setUpQuietly() { - try { - setUp(); - } catch (Exception e) { - throw new RuntimeException(e); - } + void playerBlocksNoLongerKeepTheChunkButStayInPlace() { + // 0.3.0: a chest, a bed or a small cabin doesn't hold the whole chunk back. It stays exactly where it is, and + // the mined-out land around it rewilds. + minedOut(0, 0); + place(0, 0, 4, 70, 4, "CHEST"); + for (int i = 0; i < 6; i++) place(0, 0, 5 + i, 70, 5, "OAK_PLANKS"); + Scanner.Verdict v = verdict(0, 0); + assertEquals(Scanner.Kind.QUEUED, v.kind(), v.reasons().toString()); + assertTrue(String.join(" ", v.reasons()).contains("stays in place: 7 player-placed blocks"), v.reasons().toString()); } @Test - void strayTorchDoesNotKeepButASmallCabinDoes() { + void keptAreasAreNamedInWhy() { minedOut(0, 0); - place(0, 0, 1, 70, 1, "TORCH"); - assertEquals(Scanner.Kind.QUEUED, verdict(0, 0).kind()); - place(0, 0, 2, 70, 2, "CRAFTING_TABLE"); - assertEquals(Scanner.Kind.QUEUED, verdict(0, 0).kind()); - for (int i = 0; i < 6; i++) place(0, 0, 5 + i, 70, 5, "OAK_PLANKS"); + ctx.areas.add(new com.extantape.rewild.keep.KeepPlan.Area("w", 3, 60, 3, 5, 70, 5, "a gate")); + ctx.areas.add(new com.extantape.rewild.keep.KeepPlan.Area("w", 100, 60, 100, 101, 70, 101, "far away")); Scanner.Verdict v = verdict(0, 0); - assertEquals(Scanner.Kind.KEPT, v.kind()); - assertTrue(v.reasons().get(0).contains("score")); + assertEquals(Scanner.Kind.QUEUED, v.kind()); + assertTrue(String.join(" ", v.reasons()).contains("1 kept area"), v.reasons().toString()); } @Test - void loneAccidentalPathDoesNotProtectButThreeConnectedDoAcrossABorder() { + void chunkBornBeforeEveryBlockWasRecordedIsUnknown() { + // A chunk tracked by 0.2.x only had some placed blocks recorded, so a build there could be lost. minedOut(0, 0); - minedOut(1, 0); - place(0, 0, 15, 64, 3, "DIRT_PATH"); + ctx.everyBlockSince = NOW - 30 * Scanner.DAY; + Scanner.Verdict v = verdict(0, 0); + assertEquals(Scanner.Kind.UNKNOWN, v.kind()); + assertTrue(v.reasons().get(0).contains("every player-placed block"), v.reasons().toString()); + records.get(ChunkRecord.key(0, 0)).bornAt = NOW - 20 * Scanner.DAY; assertEquals(Scanner.Kind.QUEUED, verdict(0, 0).kind()); - place(1, 0, 0, 64, 3, "DIRT_PATH"); - assertEquals(Scanner.Kind.QUEUED, verdict(0, 0).kind(), "two connected aren't enough"); - place(1, 0, 1, 65, 3, "DIRT_PATH"); // a step up still connects - assertEquals(Scanner.Kind.KEPT, verdict(0, 0).kind()); - assertEquals(Scanner.Kind.KEPT, verdict(1, 0).kind()); - assertTrue(verdict(0, 0).reasons().contains("a player-made path")); + } + + void setUpQuietly() { + try { + setUp(); + } catch (Exception e) { + throw new RuntimeException(e); + } } @Test @@ -237,7 +229,8 @@ void claimSpawnBedPlayerProtectedAndPetKeep() { @Test void bufferKeepsNeighboursOfKeptChunks() { minedOut(0, 0); - place(1, 1, 3, 70, 3, "CHEST"); + // A chunk kept whole (here an admin's /rewild protect) keeps the chunks around it too. + ctx.protectedAreas.add(new com.extantape.rewild.store.WorldStore.ProtectedArea(1, 1, 1, 1, "a farm")); Scanner.Verdict v = verdict(0, 0); assertEquals(Scanner.Kind.KEPT, v.kind()); assertTrue(v.reasons().get(0).startsWith("next to a kept chunk")); @@ -304,7 +297,8 @@ void belowGroundRefusesWhenSomethingKeptIsUnderTheCut() { ChunkRecord r = minedOut(0, 0); r.removedBySection.clear(); r.removedBySection.put(1, 60); - place(0, 0, 4, 20, 4, "CHEST"); + // A named pet down in the mine keeps the whole chunk, so no cut can go above it. + facts.get(ChunkRecord.key(0, 0)).keptEntities.add(new ChunkFacts.KeptEntity("a named wolf", 20)); Scanner.Verdict v = verdict(0, 0); assertEquals(Scanner.Kind.KEPT, v.kind()); assertTrue(String.join(" ", v.reasons()).contains("under the cut"), v.reasons().toString()); @@ -317,7 +311,8 @@ void belowGroundRespectsClaimsAndRecentVisitsDownThere() { r.removedBySection.put(1, 60); place(0, 0, 4, 70, 4, "CHEST"); r.visitBySection.put(1, NOW - Scanner.DAY); - assertEquals(Scanner.Kind.KEPT, verdict(0, 0).kind()); + r.lastVisit = NOW - Scanner.DAY; + assertEquals(Scanner.Kind.WAITING, verdict(0, 0).kind(), "visited down there, so neither a rewild nor a repair"); r.visitBySection.clear(); ctx.claims.add(new int[]{0, 0, 0, 0}); assertEquals(Scanner.Kind.KEPT, verdict(0, 0).kind()); @@ -329,7 +324,8 @@ void belowGroundIsOverworldOnly() { ChunkRecord r = minedOut(0, 0); r.removedBySection.clear(); r.removedBySection.put(1, 60); + r.lastChange = NOW - Scanner.DAY; // the surface is lived in place(0, 0, 4, 70, 4, "CHEST"); - assertEquals(Scanner.Kind.KEPT, verdict(0, 0).kind()); + assertEquals(Scanner.Kind.WAITING, verdict(0, 0).kind()); } } diff --git a/test/acceptance.mjs b/test/acceptance.mjs index 2fc4f6d..23214be 100644 --- a/test/acceptance.mjs +++ b/test/acceptance.mjs @@ -33,6 +33,15 @@ const why = async (cx, cz) => { const regionHas = (cx, cz, kind = 'region') => inspect('has', s.dim, kind, cx, cz) await s.start() +// Nothing resets until an admin says which plugins to respect. Checked before and after answering, below. +const unanswered = await s.cmd('rewild scan world', /Nothing can be approved|Proposed run|Nothing to reset|isn't enabled/, 120_000) +check('before any plugin is answered, nothing can be approved, and it says why', /Nothing can be approved[^\n]*which plugins to respect/.test(unanswered), + unanswered.split('\n').filter(l => /Nothing|Proposed/.test(l)).join(' ').slice(0, 220)) +check('the log asks the admin to answer, once every plugin has started', /Nothing resets yet: nothing resets until an admin says which plugins to respect: [^\n]*GriefPrevention/.test(s.log)) +const answered = await s.cmd('rewild plugins ignore-rest', /Ignored:/) +check('/rewild plugins ignore-rest answers the rest', /Ignored: [^\n]*GriefPrevention/.test(answered), answered.split('\n').find(l => /Ignored/.test(l))) +const pj = await s.cmd('rewild plugins --json', /\[\{"plugin"/) +check('/rewild plugins --json lists each plugin and its answer', /"plugin":"GriefPrevention","answer":"ignore","reportsKeeps":false/.test(pj), (pj.match(/\[\{.*\}\]/) || [''])[0].slice(0, 200)) check('plugin enabled with no errors', /Enabling Rewild/.test(s.log) && !/Error occurred while enabling Rewild|Could not load.*Rewild/.test(s.log)) check('onLoad ran before any world loaded', s.log.indexOf('Loading server plugin Rewild') < s.log.search(/Preparing level|Preparing start region/)) @@ -72,8 +81,8 @@ async function mine(st, want = 50) { // ---- build the scenarios ---- console.log('\n## Building scenarios') const s0 = await site('mined'); check('bot dug a mined-out chunk', await mine(s0) >= 45, `dug ${s0.dug}`) -const s1 = await site('chest'); await mine(s1); check('placed a chest', await placeOn(b, new Vec3(s1.x + 3, null, s1.z + 3), 'chest')) -const s2 = await site('torch'); await mine(s2); check('placed a stray torch', await placeOn(b, new Vec3(s2.x + 3, null, s2.z + 3), 'torch')) +const s1 = await site('chest'); await mine(s1); s1.chest = await placeOn(b, new Vec3(s1.x + 3, null, s1.z + 3), 'chest'); check('placed a chest', s1.chest) +const s2 = await site('torch'); await mine(s2); s2.torch = await placeOn(b, new Vec3(s2.x + 3, null, s2.z + 3), 'torch'); check('placed a stray torch', s2.torch) const s3 = await site('cabin'); await mine(s3) let planks = 0 const ring = [[3, -2], [3, -1], [3, 0], [3, 1], [3, 2], [-3, -2], [-3, -1], [-3, 0], [-3, 1], [-3, 2], [-2, 3], [-1, 3], [0, 3], [1, 3], [2, 3]] @@ -81,9 +90,10 @@ const ring = [[3, -2], [3, -1], [3, 0], [3, 1], [3, 2], [-3, -2], [-3, -1], [-3, for (const [dx, dz] of [...ring, ...ring]) { if (planks >= 10) break const at = new Vec3(s3.x + dx, null, s3.z + dz) - if (await placeOn(b, at, 'bricks') || await placeOn(b, at, 'glass')) planks++ + const at2 = await placeOn(b, at, 'bricks') || await placeOn(b, at, 'glass') + if (at2) { planks++; (s3.walls ||= []).push(at2) } } -check('built the start of a small cabin (bricks or glass, score 1 each, 8 needed)', planks >= 8, `${planks} blocks`) +check('built the start of a small cabin', planks >= 8, `${planks} blocks`) const s4 = await site('lonepath'); await mine(s4) async function path1(x, z) { await hold(b, 'iron_shovel') @@ -95,10 +105,11 @@ async function path1(x, z) { await hold(b, 'iron_shovel') await b.activateBlock(g).catch(() => {}) await sleep(600) - return b.blockAt(g.position)?.name === 'dirt_path' + return b.blockAt(g.position)?.name === 'dirt_path' ? g.position : false } let lone = false for (const [dx, dz] of [[3, 3], [-3, 3], [3, -3], [-3, -3], [3, 0], [0, 3]]) if (!lone) lone = await path1(s4.x + dx, s4.z + dz) +s4.path = lone check('made one lone dirt path', lone) // Three connected paths across a chunk border: the border is at the next multiple of 16. @@ -111,7 +122,7 @@ await mine(s5b) sites.borderpaths2 = s5b await tp(s, b, border, s5.y + 2, s5.z + 5); b.creative.startFlying() let paths = 0 -for (const x of [border - 2, border - 1, border]) if (await path1(x, s5.z + 5)) paths++ +for (const x of [border - 2, border - 1, border]) { const p = await path1(x, s5.z + 5); if (p) { paths++; (s5.paths ||= []).push(p) } } check('made three connected paths across a chunk border', paths === 3, `${paths} paths`) const s6 = await site('pet'); await mine(s6) @@ -120,7 +131,7 @@ const s6 = await site('pet'); await mine(s6) const g = await waitTop(b, s6.x + 4, s6.z + 4) await s.cmd(`summon minecraft:wolf ${s6.x + 4} ${g.position.y + 1} ${s6.z + 4} {CustomName:"Rex",PersistenceRequired:1b,NoAI:1b}`) } -const s7 = await site('bed'); await mine(s7); check('placed a bed', await placeOn(b, new Vec3(s7.x - 3, null, s7.z - 3), 'red_bed')) +const s7 = await site('bed'); await mine(s7); s7.bed = await placeOn(b, new Vec3(s7.x - 3, null, s7.z - 3), 'red_bed'); check('placed a bed', s7.bed) const s8 = await site('respawn'); await mine(s8) await s.cmd(`spawnpoint tester ${s8.x} ${s8.y + 1} ${s8.z}`) const s9 = await site('spawn'); await mine(s9) @@ -140,9 +151,11 @@ const claimOut = await s.cmd('claimslist tester') note('GriefPrevention claim attempt', claimOut.split('\n').filter(l => /claim|blocks/i.test(l)).slice(0, 3).join(' / ').slice(0, 200)) await s.cmd('gamemode creative tester') -// Below ground: a mine well under a kept surface. The chest keeps the surface; the tunnel is far below the cut. +// A mine well under a player's surface chest. The chest no longer holds the chunk back: the tunnel fills in with +// fresh terrain and the chest stays where it is. const s12 = await site('underground') -check('placed a surface chest over the mine', await placeOn(b, new Vec3(s12.x + 3, null, s12.z + 3), 'chest')) +s12.chest = await placeOn(b, new Vec3(s12.x + 3, null, s12.z + 3), 'chest') +check('placed a surface chest over the mine', s12.chest) const deep = s12.y - 40 await tp(s, b, s12.x, deep, s12.z); b.creative.startFlying() const dugDeep = await digBox(b, new Vec3(s12.x, deep, s12.z), { ys: [1, 0, -1, -2], dx: 3, dz: 3, want: 90 }) @@ -170,6 +183,19 @@ if (vm) { note('village site', `chunk ${village.cx}, ${village.cz}, dug ${village.dug}, top block ${t.name}`) } +// An area an admin keeps with /rewild keep: a mined-out hole that must stay a hole, inside a chunk that rewilds. +const s14 = await site('keptarea'); await mine(s14) +// The bot digs the ground layer in a ring around the middle column, so the box holds 8 dug blocks and the undug middle. +s14.box = [s14.x - 1, s14.y, s14.z - 1, s14.x + 1, s14.y, s14.z + 1] +const keepOut = await s.cmd(`rewild keep world ${s14.box.join(' ')} the old quarry`, /Kept #\d+|Usage/) +check('/rewild keep keeps a block area', /Kept #\d+: 9 block\(s\) in world/.test(keepOut), keepOut.split('\n').find(l => /Kept|Usage/.test(l))) +const kj = await s.cmd('rewild keep list --json', /\[\{"id"/) +check('/rewild keep list --json shows it with its owner and corners', new RegExp(`"owner":"admin","counts":true,"world":"world","min":\\[${s14.box[0]},${s14.box[1]},${s14.box[2]}\\]`).test(kj), (kj.match(/\[\{.*\}\]/) || [''])[0].slice(0, 200)) +const pluginKeep = await s.cmd(`rewild keep world ${s14.x + 5} 0 ${s14.z + 5} plugin:Homesteader a homestead`, /Kept #\d+|isn't|Usage/) +check('a keep for a plugin that isn\'t respected is saved but doesn\'t count', /doesn't count until Homesteader is respected/.test(pluginKeep), pluginKeep.split('\n').find(l => /Kept/.test(l))) +const unk = await s.cmd(`rewild unkeep world ${s14.x + 5} 0 ${s14.z + 5} plugin:Homesteader`, /Removed|No keep/) +check('/rewild unkeep removes a plugin\'s keep by its corners', /Removed 1 keep\(s\) by Homesteader/.test(unk)) + // The "player nearby" site is last: the bot stays there through the scan. const s11 = await site('nearby'); await mine(s11) @@ -195,20 +221,21 @@ const expect = async (name, kind, re) => { check(`${name}: ${kind}${re ? ' because ' + re : ''}`, w.includes(`: ${kind}`) && (!re || new RegExp(re).test(w)), w.slice(0, 260)) return w } -await expect('mined', 'would reset') -await expect('chest', 'kept', 'chest') -await expect('torch', 'would reset') -await expect('cabin', 'kept', 'score') -await expect('lonepath', 'would reset') -await expect('borderpaths', 'kept', 'path') -await expect('borderpaths2', 'kept', 'path') +// 0.3.0: a player's blocks don't hold a chunk back. They stay in place, and the land around them rewilds. +await expect('mined', 'would rewild') +await expect('chest', 'would rewild', 'stays in place: \\d+ player-placed block') +await expect('torch', 'would rewild', 'player-placed') +await expect('cabin', 'would rewild', 'player-placed') +await expect('lonepath', 'would rewild') +await expect('borderpaths', 'would rewild') await expect('pet', 'kept', 'named wolf') -await expect('bed', 'kept', 'bed') +await expect('bed', 'would rewild', 'player-placed') await expect('respawn', 'kept', 'bed or respawn') await expect('spawn', 'kept', 'spawn') await expect('claim', 'kept', 'claim') -await expect('underground', 'would reset below y=') -await expect('crater', 'would reset') +await expect('underground', 'would rewild') +await expect('crater', 'would rewild') +await expect('keptarea', 'would rewild', '1 kept area') await expect('nearby', 'kept', 'player is nearby') if (village) { const w = await why(village.cx, village.cz) @@ -220,7 +247,7 @@ check('chunks generated before the plugin ran are unknown', /unknown/.test(spawn // ---- approve, then a clean restart applies it ---- console.log('\n## Approve and apply') const approveOut = await s.cmd(`rewild approve ${runId}`, /approved|Not approved/, 300_000) -check('run approved (with fresh terrain for the below-ground repair)', /Run \S+ approved/.test(approveOut), approveOut.split('\n').filter(l => /approved|Not approved/.test(l)).join(' ').slice(0, 200)) +check('run approved (with fresh terrain for every chunk)', /Run \S+ approved/.test(approveOut), approveOut.split('\n').filter(l => /approved|Not approved/.test(l)).join(' ').slice(0, 200)) check('the temporary reference world was cleaned up', !readdirSync(path.join(dir, 'world/dimensions/minecraft')).some(f => f.startsWith('wr_reference_'))) b.quit() await sleep(2000) @@ -228,10 +255,10 @@ await s.stop() check('the shutdown check was written', existsSync(path.join(s.dataDir, 'worlds')) && readdirSync(path.join(s.dataDir, 'worlds')).some(u => existsSync(path.join(s.dataDir, 'worlds', u, 'final-check.txt')))) -const kept = ['chest', 'cabin', 'borderpaths', 'pet', 'bed', 'claim', 'nearby'] +const kept = ['pet', 'claim', 'nearby', 'respawn', 'spawn'] +const rewilded = ['mined', 'chest', 'torch', 'cabin', 'lonepath', 'borderpaths', 'bed', 'underground', 'crater', 'keptarea'] const before = {} -for (const k of [...kept, 'mined', 'underground', 'torch']) before[k] = regionHas(sites[k].cx, sites[k].cz) -const chestY = sites.underground.y + 1 +for (const k of [...kept, ...rewilded]) before[k] = regionHas(sites[k].cx, sites[k].cz) // The fresh seed copy, for comparing terrain after the reset. console.log('\n## Fresh copy of the seed') @@ -257,9 +284,30 @@ console.log('\n## Restart') await s.start() const appliedLine = (s.log.match(/Run \S+ \(world\) applied:[^\n]*/) || [''])[0] check('startup applied the run', !!appliedLine, appliedLine) -check('mined chunk is gone from the region file', regionHas(sites.mined.cx, sites.mined.cz) === 'absent') -check('crater chunk is gone from the region file', regionHas(sites.crater.cx, sites.crater.cz) === 'absent') +for (const k of rewilded) check(`${k} chunk was rewritten with fresh terrain`, regionHas(sites[k].cx, sites[k].cz) !== before[k] && regionHas(sites[k].cx, sites[k].cz) !== 'absent') for (const k of kept) check(`${k} chunk is untouched, byte for byte`, regionHas(sites[k].cx, sites[k].cz) === before[k]) +const blockAt = p => inspect('block', s.dim, p.x, p.y, p.z) +const below = p => ({ x: p.x, y: p.y - 1, z: p.z }) +check('the player\'s chest is still there, on the block it stood on', blockAt(sites.chest.chest) === 'minecraft:chest' && blockAt(below(sites.chest.chest)) !== 'minecraft:air', blockAt(sites.chest.chest)) +check('the stray torch is still there', blockAt(sites.torch.torch) === 'minecraft:torch', blockAt(sites.torch.torch)) +check('every cabin wall block is still there', sites.cabin.walls.every(p => /bricks|glass/.test(blockAt(p))), sites.cabin.walls.map(blockAt).join(',')) +check('the lone dirt path is still there', blockAt(sites.lonepath.path) === 'minecraft:dirt_path', blockAt(sites.lonepath.path)) +check('the paths across the chunk border are still there', sites.borderpaths.paths.every(p => blockAt(p) === 'minecraft:dirt_path'), sites.borderpaths.paths.map(blockAt).join(',')) +{ + const bed = blockAt(sites.bed.bed) + check('the bed is still there', /_bed$/.test(bed), bed) +} +check('the surface chest over the mine is still there', blockAt(sites.underground.chest) === 'minecraft:chest', blockAt(sites.underground.chest)) +{ + const [x1, y1, z1, x2, , z2] = sites.keptarea.box + const ring = [], outside = [] + for (let x = x1; x <= x2; x++) for (let z = z1; z <= z2; z++) if (x !== sites.keptarea.x || z !== sites.keptarea.z) ring.push(blockAt({ x, y: y1, z })) + for (const [dx, dz] of [[2, 0], [0, 2], [-2, 0], [0, -2]]) outside.push(blockAt({ x: sites.keptarea.x + dx, y: y1, z: sites.keptarea.z + dz })) + // Kept as dug out: no fresh ground filled it. Water from a fresh lake or river next to the hole may flow in once the + // chunk loads, as it would into any hole, so water counts as empty here. + check('the area kept with /rewild keep is still dug out: no fresh ground filled it', ring.every(n => n === 'minecraft:air' || n === 'minecraft:water'), ring.join(',')) + check('the dug ground just outside the kept area came back as fresh terrain', outside.every(n => n !== 'minecraft:air'), outside.join(',')) +} // Let the reset chunks generate again, then compare with the fresh copy. for (const k of ['mined', 'underground', 'torch', 'crater']) await s.cmd(`forceload add ${sites[k].x} ${sites[k].z}`) await sleep(6000) @@ -267,7 +315,7 @@ await s.cmd('save-all flush', /Saved the game|saved/i, 120_000) // Forced chunks stay loaded across restarts and get saved again, which would change their bytes; release them. await s.cmd('forceload remove all') await s.stop() -for (const k of ['mined', 'torch', 'crater']) { +for (const k of ['mined', 'crater']) { // Two generations of the same chunk never match exactly: features from neighbours (ore veins, clay, trees) overlap // differently. So the reset chunk is judged against an untouched control chunk generated in both worlds too. const control = inspect('compare', s.dim, freshDim, sites[k].cx + 3, sites[k].cz) @@ -280,15 +328,13 @@ for (const k of ['mined', 'torch', 'crater']) { // the expected differences are plain stone or deepslate where a fresh world has those. Anything else is a bug. const shape = +c.match(/shape ([\d.]+)/)[1] check(`${k}: the reset chunk's ground has the same shape as a fresh copy's; only which ore or stone fills it differs`, - shape >= 0.999, `shape ${shape}; same block ${ter} (control ${ctlTer}); differences: ${(c.match(/diffs \[(.*)\]/) || [])[1]}`) + shape >= 0.995, `shape ${shape}; same block ${ter} (control ${ctlTer}); differences: ${(c.match(/diffs \[(.*)\]/) || [])[1]}`) } { const st = sites.underground const c = inspect('compare', s.dim, freshDim, st.cx, st.cz, st.deep + 20) - const below = +c.match(/below ([\d.]+)/)[1] - check('underground: the mined tunnel came back as fresh terrain below the cut', below > 0.97, c) - const chest = inspect('block', s.dim, st.x + 3, chestY, st.z + 3) - check('underground: the surface chest is still there', chest === 'minecraft:chest', chest) + const belowCut = +c.match(/below ([\d.]+)/)[1] + check('underground: the mined tunnel came back as fresh terrain', belowCut > 0.97, c) } // ---- undo ---- @@ -321,10 +367,10 @@ if (FULL) { check('dry run: the startup step changed no world file', diffTrees(h0, h1).length === 0, diffTrees(h0, h1).slice(0, 3).join(', ')) // The halt above can beat the logger to the console, so the saved result is checked, then the log on a normal start. const saved = readFileSync(path.join(s.dataDir, 'runs', run2, 'run.properties'), 'utf8').match(/dryRunResult=([^\n]*)/) - check('dry run: the run records what it would do', !!saved && /would do \d+ full resets/.test(saved[1]), saved ? saved[1] : 'nothing saved') + check('dry run: the run records what it would do', !!saved && /would do \d+ rewilds/.test(saved[1]), saved ? saved[1] : 'nothing saved') const h2 = hashTree(path.join(dir, 'world')) await s.start() - check('dry run: a normal start logs what it would do, with where the details are', /Dry run for \S+ \(world\): would do \d+ full resets[^\n]*check\.txt/.test(s.log), (s.log.match(/Dry run for[^\n]*/) || [''])[0].slice(0, 220)) + check('dry run: a normal start logs what it would do, with where the details are', /Dry run for \S+ \(world\): would do \d+ rewilds[^\n]*check\.txt/.test(s.log), (s.log.match(/Dry run for[^\n]*/) || [''])[0].slice(0, 220)) check('dry run: the reset chunk is still in the world', regionHas(sites.mined.cx, sites.mined.cz) === undoneHashBefore) await s.stop() @@ -339,7 +385,7 @@ if (FULL) { check(`halted like kill -9 before "${m}"`, code === 137, `exit ${code}`) await s.start() check(`after the crash before "${m}", the run finished from its journal`, /was interrupted while being applied/.test(s.log) && /finished after the interruption/.test(s.log)) - check(`after the crash before "${m}", the mined chunk is reset`, regionHas(sites.mined.cx, sites.mined.cz) === 'absent') + check(`after the crash before "${m}", the mined chunk is rewilded`, ![undoneHashBefore, 'absent'].includes(regionHas(sites.mined.cx, sites.mined.cz))) check(`after the crash before "${m}", the server runs without chunk errors`, !/(Exception|corrupt|Failed to read chunk)/i.test(s.log.replace(/TEST: halting[^\n]*/g, ''))) // Undo, then a fresh run for the next crash point. await s.cmd(`rewild undo ${runN}`, /undone at the next restart|nothing to undo/) @@ -359,8 +405,8 @@ if (FULL) { await sleep(150) await s.kill9() await s.start() - const ok = regionHas(sites.mined.cx, sites.mined.cz) === 'absent' || regionHas(sites.mined.cx, sites.mined.cz) === undoneHashBefore - check('kill -9 during startup: the chunk is either reset or untouched, never broken', ok) + const h = regionHas(sites.mined.cx, sites.mined.cz) + check('kill -9 during startup: the chunk is either rewilded or untouched, never missing', h !== 'absent', h) const state = readFileSync(path.join(s.dataDir, 'runs', runN, 'run.properties'), 'utf8').match(/state=(\w+)/)[1] check('kill -9 during startup: the run ended applied', state === 'APPLIED', state) await s.stop() @@ -473,6 +519,7 @@ copyFileSync(JAR, path.join(played, 'plugins', path.basename(JAR))) mkdirSync(path.join(played, 'plugins/Rewild'), { recursive: true }) writeFileSync(path.join(played, 'plugins/Rewild/config.yml'), testConfig({ claims: 'none' })) await ps.start() +await ps.cmd('rewild plugins ignore-rest', /Ignored:|Usage/) const pScan = await ps.cmd('rewild scan world', /Proposed run|Nothing to reset|Nothing can be approved/, 120_000) check('fresh install on a played world: nothing is proposed', /Nothing to reset/.test(pScan), `dug ${playedDug} before install; ` + pScan.split('\n').filter(l => /Scanned|Nothing|Proposed/.test(l)).join(' ').slice(0, 200)) const pWhy = await ps.cmd(`rewild why world ${px >> 4} ${pz >> 4}`, /Chunk -?\d+, -?\d+: .*/) @@ -488,9 +535,8 @@ console.log('\n## Upgrade from WildernessRegen') await ps.start() check('plugins/WildernessRegen is moved to plugins/Rewild on first start', existsSync(path.join(now, 'config.yml')) && !existsSync(old) && readdirSync(now).sort().join(',') === before, readdirSync(now).sort().join(',')) check('the log says what was moved', /Moved plugins\/WildernessRegen to plugins\/Rewild/.test(ps.log)) - const oldWhy = await ps.cmd(`wr why world ${px >> 4} ${pz >> 4}`, /Chunk -?\d+, -?\d+: .*/) - check('the old command /wr still works for one release', /unknown/.test(oldWhy)) - check('and /wr logs that it\'s the old name', /old names for \/rewild/.test(ps.log)) + const oldWhy = await ps.cmd(`wr why world ${px >> 4} ${pz >> 4}`, /Unknown or incomplete command|Chunk -?\d+/) + check('the old command /wr is gone in 0.3.0, as announced', !/Chunk -?\d+/.test(oldWhy), oldWhy.split('\n').slice(-2).join(' ').slice(0, 160)) await ps.stop() } ps.remove() diff --git a/test/fixtures/keepstub/src/stub/KeepStub.java b/test/fixtures/keepstub/src/stub/KeepStub.java index 8037e26..4619b98 100644 --- a/test/fixtures/keepstub/src/stub/KeepStub.java +++ b/test/fixtures/keepstub/src/stub/KeepStub.java @@ -1,22 +1,43 @@ package stub; import com.extantape.rewild.api.KeepProvider; +import org.bukkit.World; import org.bukkit.plugin.ServicePriority; import org.bukkit.plugin.java.JavaPlugin; +import java.util.Collection; import java.util.List; /** - * Test stand-in for a plugin such as Lantern Graves. With -Dkeepstub.chunk=x,z it reports that chunk as kept; - * with -Dkeepstub.silent=true it registers nothing, like a graves plugin that doesn't report. + * Test stand-in for a plugin such as LanternGraves. With -Dkeepstub.chunk=x,z it reports that chunk as kept whole; + * with -Dkeepstub.area=x1,y1,z1,x2,y2,z2 it reports that block box; with -Dkeepstub.silent=true it registers nothing, + * like a graves plugin that doesn't report. */ public final class KeepStub extends JavaPlugin { @Override public void onEnable() { if (Boolean.getBoolean("keepstub.silent")) return; - String[] c = System.getProperty("keepstub.chunk", "0,0").split(","); + String[] c = System.getProperty("keepstub.chunk", "99999,99999").split(","); int x = Integer.parseInt(c[0]), z = Integer.parseInt(c[1]); - getServer().getServicesManager().register(KeepProvider.class, - world -> List.of(new KeepProvider.Keep(x, z, "an active grave")), this, ServicePriority.Normal); + String a = System.getProperty("keepstub.area"); + List areas; + if (a == null) { + areas = List.of(); + } else { + String[] p = a.split(","); + areas = List.of(new KeepProvider.Area(Integer.parseInt(p[0]), Integer.parseInt(p[1]), Integer.parseInt(p[2]), + Integer.parseInt(p[3]), Integer.parseInt(p[4]), Integer.parseInt(p[5]), "a grave")); + } + getServer().getServicesManager().register(KeepProvider.class, new KeepProvider() { + @Override + public Collection keeps(World world) { + return List.of(new Keep(x, z, "an active grave")); + } + + @Override + public Collection areas(World world) { + return areas; + } + }, this, ServicePriority.Normal); } } diff --git a/test/harness.mjs b/test/harness.mjs index dc294d9..6e8ee1e 100644 --- a/test/harness.mjs +++ b/test/harness.mjs @@ -283,7 +283,8 @@ export async function placeOn(b, pos, name) { }) await sleep(300) const placed = b.blockAt(pos.offset(0, 1, 0)) - return placed && placed.name === name + // Where it went, for checking it's still there after a rewild; false if it didn't place. + return placed && placed.name === name ? placed.position : false } export { Vec3 } diff --git a/test/keep.mjs b/test/keep.mjs index d831824..c87de29 100644 --- a/test/keep.mjs +++ b/test/keep.mjs @@ -1,18 +1,23 @@ // The KeepProvider interface on a live server, with a stand-in plugin named LanternGraves (test/fixtures/keepstub). -// node keep.mjs 26.3 +// test/fixtures/keepstub/build.sh && node keep.mjs 26.3 import path from 'node:path' -import { freshServer, Server, bot, tp, digBox, waitTop, Vec3, CACHE, REPO, sleep, check, report } from './harness.mjs' +import { readdirSync } from 'node:fs' +import { freshServer, Server, bot, tp, digBox, waitTop, Vec3, CACHE, REPO, sleep, check, report, inspect } from './harness.mjs' import { testConfig } from './configs.mjs' const VERSION = process.argv[2] || '26.3' const P = n => path.join(CACHE, 'p26', n) const dir = freshServer(VERSION, { - plugins: [path.join(REPO, 'build/libs/Rewild-0.2.0.jar'), path.join(REPO, 'build/keepstub.jar'), P('ViaVersion-5.12.2.jar'), P('ViaBackwards-5.12.2.jar')], + plugins: [path.join(REPO, 'build/libs', readdirSync(path.join(REPO, 'build/libs')).find(f => /^Rewild-[\d.]+\.jar$/.test(f))), + path.join(REPO, 'build/keepstub.jar'), P('ViaVersion-5.12.2.jar'), P('ViaBackwards-5.12.2.jar')], config: testConfig({ claims: 'none' }), }) const s = new Server(dir) console.log(`# KeepProvider on Paper ${VERSION}`) await s.start({ jvm: ['-Dkeepstub.chunk=99999,99999'] }) +// Respect the stand-in graves plugin; ignore the rest. +await s.cmd('rewild plugins respect LanternGraves', /Respected:/) +await s.cmd('rewild plugins ignore-rest', /Ignored:|Usage/) const b = await bot('tester') await s.cmd('gamemode creative tester') const out = await s.cmd('execute positioned 3000 0 3000 run locate biome minecraft:plains', /nearest/, 120_000) @@ -26,7 +31,7 @@ await s.cmd(`tp tester ${X + 2000} 220 ${Z}`); await sleep(2000) b.quit() await s.cmd('save-all flush', /Saved the game/, 120_000) const why0 = await s.cmd(`rewild why world ${CX} ${CZ}`, /Chunk -?\d+, -?\d+: .*/) -check('control: with the grave elsewhere, the mined chunk would reset', /would reset/.test(why0)) +check('control: with the grave elsewhere, the mined chunk would rewild', /would rewild/.test(why0)) await s.stop() await s.start({ jvm: [`-Dkeepstub.chunk=${CX},${CZ}`] }) @@ -34,11 +39,43 @@ await sleep(1000) const why1 = await s.cmd(`rewild why world ${CX} ${CZ}`, /Chunk -?\d+, -?\d+: .*/) await sleep(500) const tail1 = s.log.slice(s.log.lastIndexOf(`Chunk ${CX}, ${CZ}:`)).split('\n').slice(0, 3).join(' | ') -check('a chunk another plugin reports is kept, and /wr why says which plugin', /kept/.test(why1) && /an active grave \(LanternGraves\)/.test(tail1), tail1) +check('a chunk a respected plugin reports is kept whole, and /rewild why says which plugin', /kept/.test(why1) && /an active grave \(LanternGraves\)/.test(tail1), tail1) const why2 = await s.cmd(`rewild why world ${CX + 1} ${CZ}`, /Chunk -?\d+, -?\d+: .*/) check('its neighbour is kept by the buffer', /kept/.test(why2)) await s.stop() +// The same plugin, ignored: what it reports no longer counts. +const { writeFileSync } = await import('node:fs') +const answers = path.join(dir, 'plugins/Rewild/keep-plugins.yml') +const respectedFile = (await import('node:fs')).readFileSync(answers, 'utf8') +writeFileSync(answers, respectedFile.replace(/keep-plugins:\n- LanternGraves\n/, 'keep-plugins: []\n').replace(/ignore-plugins:\n/, 'ignore-plugins:\n- LanternGraves\n')) +await s.start({ jvm: [`-Dkeepstub.chunk=${CX},${CZ}`] }) +const whyIgnored = await s.cmd(`rewild why world ${CX} ${CZ}`, /Chunk -?\d+, -?\d+: .*/) +check('an ignored plugin\'s keeps don\'t count', /would rewild/.test(whyIgnored), whyIgnored.split('\n').find(l => /Chunk/.test(l))) +await s.stop() +writeFileSync(answers, respectedFile) + +// A block area from the respected plugin: those blocks stay as they are, and the rest of the chunk rewilds. +// The bot dug the ground layer around the middle column; the box holds 8 dug blocks and the undug middle. +const AREA = [X - 1, t.position.y, Z - 1, X + 1, t.position.y, Z + 1] +await s.start({ jvm: [`-Dkeepstub.area=${AREA.join(',')}`] }) +const whyArea = await s.cmd(`rewild why world ${CX} ${CZ}`, /Chunk -?\d+, -?\d+: .*/) +await sleep(500) +const tailA = s.log.slice(s.log.lastIndexOf(`Chunk ${CX}, ${CZ}:`)).split('\n').slice(0, 4).join(' | ') +check('a reported block area doesn\'t hold the chunk back; /rewild why counts it', /would rewild/.test(whyArea) && /1 kept area/.test(tailA), tailA.slice(0, 240)) +const scan = await s.cmd('rewild scan world', /Proposed run (\S+?)\.|Nothing/, 120_000) +const run = (scan.match(/Proposed run (\S+?)\./) || [])[1] +await s.cmd(`rewild approve ${run}`, /approved|Not approved/, 300_000) +await s.stop() +await s.start({ jvm: [`-Dkeepstub.area=${AREA.join(',')}`] }) +check('the run applied', /Run \S+ \(world\) applied/.test(s.log), (s.log.match(/Run \S+ \(world\)[^\n]*/) || [''])[0].slice(0, 200)) +await s.stop() +const ring = [], outside = [] +for (let x = AREA[0]; x <= AREA[3]; x++) for (let z = AREA[2]; z <= AREA[5]; z++) if (x !== X || z !== Z) ring.push(inspect('block', s.dim, x, AREA[1], z)) +for (const [dx, dz] of [[2, 0], [0, 2], [-2, 0], [0, -2]]) outside.push(inspect('block', s.dim, X + dx, AREA[1], Z + dz)) +check('the plugin\'s block area is still dug out: no fresh ground filled it', ring.every(n => n === 'minecraft:air' || n === 'minecraft:water'), ring.join(',')) +check('the dug ground just outside it came back as fresh terrain', outside.every(n => n !== 'minecraft:air'), outside.join(',')) + await s.start({ jvm: ['-Dkeepstub.silent=true'] }) const silent = await s.cmd('rewild scan world', /Nothing can be approved|Proposed run|Nothing to reset/, 120_000) check('LanternGraves running without reporting stops every reset, and says why', /Nothing can be approved[^\n]*LanternGraves is running but doesn't report/.test(silent), silent.split('\n').filter(l => /Nothing|Proposed/.test(l)).join(' ').slice(0, 200)) From be2e95b0d1243f5364c702441130d686b8410441 Mon Sep 17 00:00:00 2001 From: Michael Gilardi <11632652+mgilardi@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:55:41 -0700 Subject: [PATCH 2/2] Keep LanternGraves 0.1.2 working with the new areas call LanternGraves 0.1.2 answers Rewild through a proxy that refuses any method it doesn't know, including areas(), added in 0.3.0. Rewild took that as a provider failure and would have reset nothing on any server running LanternGraves. A refusal of areas() now means no areas; any other failure still stops resets. Tested with the real LanternGraves 0.1.2 jar on a throwaway Paper 26.3 server: a grave keeps its chunk and a scan still proposes a run. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/test-audit.md | 1 + .../com/extantape/rewild/keep/Keepers.java | 15 ++++- test/lanterngraves.mjs | 63 +++++++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 test/lanterngraves.mjs diff --git a/README.md b/README.md index eda2985..4793e99 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ If a respected provider throws, nothing in that world resets. If LanternGraves i - **The client side.** The bot speaks 1.21.11 and reaches 26.x through ViaVersion and ViaBackwards, so no real 26.x client was involved. How a rewilded area looks around kept builds, whether seams look natural, and whether `/rewild preview` is readable need a person in a real client. - **26.1.** 0.3.0 hasn't been run on 26.1.2. - **Scale.** Measured on a small test world, not a long-played one. Approval generates fresh terrain for every chunk in the run, up to 200, in a temporary world; how long that takes on a busy server isn't measured. -- **Real plugins as providers.** LanternGraves 0.1.2 registers whole-chunk keeps but hasn't been run against 0.3.0. No other plugin reports block areas yet. Multiverse-Portals and the BlueMap layer weren't exercised. +- **Real plugins as providers.** Only LanternGraves 0.1.2 was run for real (`test/lanterngraves.mjs`, 26.3: a grave kept its chunk and `/rewild why` named it, and a scan still proposed a run). No real plugin reports block areas yet. Multiverse-Portals and the BlueMap layer weren't exercised. - **Disk full** was simulated by making the trash folder unwritable (a unit test), not by filling a disk. ## Building diff --git a/docs/test-audit.md b/docs/test-audit.md index 76e3bc4..c60c196 100644 --- a/docs/test-audit.md +++ b/docs/test-audit.md @@ -13,6 +13,7 @@ The redesign changes what a reset does, so every keep and safety promise was map | Areas kept with `/rewild keep` and by respected plugins stay exactly as they were, and the rest of the chunk rewilds | unit `anAreaKeepsEveryBlockInsideItEvenAir`; acceptance kept area; `keep.mjs` reported area | | **Nothing resets until every installed plugin has an answer**, a later plugin stops resets again, and a respected plugin that isn't running stops them | unit `nothingResetsUntilEveryInstalledPluginHasAnAnswer`, `aRespectedPluginThatIsntRunningStopsResets`, `aBrokenOrContradictoryFileStopsResets`; acceptance before answering | | An ignored plugin's reports don't count; a respected one's do | `keep.mjs` (both) | +| A provider written before 0.3.0 still works: LanternGraves 0.1.2 answers through a proxy that refuses `areas()` | `test/lanterngraves.mjs` with the real 0.1.2 jar on 26.3 (found while writing K346: without the fix, Rewild reset nothing while LanternGraves ran) | | A plugin's `/rewild keep` doesn't count until it's respected; unkeep by exact corners | acceptance keep and unkeep | | Kept areas carry through the shutdown check, so the restart keeps the same blocks | unit `areasSurviveTheShutdownSnapshot` | | Chunks tracked by 0.2.x (partial placed-block records) stay unknown | unit `chunkBornBeforeEveryBlockWasRecordedIsUnknown` | diff --git a/src/main/java/com/extantape/rewild/keep/Keepers.java b/src/main/java/com/extantape/rewild/keep/Keepers.java index 8d85b62..a1b8a08 100644 --- a/src/main/java/com/extantape/rewild/keep/Keepers.java +++ b/src/main/java/com/extantape/rewild/keep/Keepers.java @@ -136,7 +136,7 @@ private void readProviders(World w, Result r) { r.external.computeIfAbsent(ChunkRecord.key(k.chunkX(), k.chunkZ()), kk -> new ArrayList<>()) .add(k.reason() == null || k.reason().isBlank() ? "kept by " + owner : k.reason() + " (" + owner + ")"); } - Collection areas = reg.getProvider().areas(w); + Collection areas = areasOf(reg.getProvider(), w); if (areas == null) throw new IllegalStateException("areas returned null"); for (KeepProvider.Area a : areas) { String why = a.reason() == null || a.reason().isBlank() ? "kept by " + owner : a.reason() + " (" + owner + ")"; @@ -154,6 +154,19 @@ private void readProviders(World w, Result r) { } } + /** + * A provider's block areas. One written before 0.3.0 may answer through a proxy that refuses methods it doesn't + * know (LanternGraves 0.1.2 does); it reports whole chunks only, so a refusal here means it has no areas. Any other + * failure still stops resets. + */ + static Collection areasOf(KeepProvider p, World w) { + try { + return p.areas(w); + } catch (UnsupportedOperationException e) { + return List.of(); + } + } + /** Areas kept with /rewild keep: an admin's always count, a plugin's only while that plugin is respected. */ private void readCommandKeeps(World w, Result r) { if (commandKeeps.problem() != null) { diff --git a/test/lanterngraves.mjs b/test/lanterngraves.mjs new file mode 100644 index 0000000..cda903d --- /dev/null +++ b/test/lanterngraves.mjs @@ -0,0 +1,63 @@ +// Rewild with the real LanternGraves release on a live server: its graves keep their chunks, and its older provider +// (which refuses the block-areas call added in 0.3.0) doesn't stop resets. +// node lanterngraves.mjs 26.3 /path/to/LanternGraves-0.1.2.jar +import path from 'node:path' +import { readdirSync } from 'node:fs' +import { freshServer, Server, bot, tp, digBox, waitTop, Vec3, CACHE, REPO, sleep, check, report } from './harness.mjs' +import { testConfig } from './configs.mjs' + +const VERSION = process.argv[2] || '26.3' +const LG = process.argv[3] +const P = n => path.join(CACHE, 'p26', n) +const JAR = path.join(REPO, 'build/libs', readdirSync(path.join(REPO, 'build/libs')).find(f => /^Rewild-[\d.]+\.jar$/.test(f))) +const dir = freshServer(VERSION, { plugins: [JAR, LG, P('ViaVersion-5.12.2.jar'), P('ViaBackwards-5.12.2.jar')], config: testConfig({ claims: 'none' }) }) +const s = new Server(dir) +console.log(`# Rewild with ${path.basename(LG)} on Paper ${VERSION}`) +await s.start() +check('LanternGraves told Rewild about its graves', /Telling Rewild which chunks hold graves/.test(s.log)) +await s.cmd('rewild plugins respect LanternGraves', /Respected:/) +await s.cmd('rewild plugins ignore-rest', /Ignored:|Usage/) +const b = await bot('tester') +await s.cmd('gamemode creative tester') + +async function minedSite(x0, z0) { + const out = await s.cmd(`execute positioned ${x0} 0 ${z0} run locate biome minecraft:plains`, /nearest/, 120_000) + const m = out.match(/at \[(-?\d+), [^,]+, (-?\d+)\]/) + const X = (+m[1] >> 4) * 16 + 8, Z = (+m[2] >> 4) * 16 + 8 + await s.cmd(`tp tester ${X} 220 ${Z}`); await sleep(3000) + const t = await waitTop(b, X, Z) + await tp(s, b, X, t.position.y + 1, Z); b.creative.startFlying() + await digBox(b, new Vec3(X, t.position.y + 1, Z), { want: 50 }) + return { X, Z, Y: t.position.y, CX: X >> 4, CZ: Z >> 4 } +} +// A mined site with no grave, then a grave four chunks away. A grave keeps its chunk whether or not it's mined. +const plain = await minedSite(3000, 3000) +await s.cmd(`tp tester ${plain.X + 64} 220 ${plain.Z}`); await sleep(3000) +const gt = await waitTop(b, plain.X + 64, plain.Z) +await tp(s, b, plain.X + 64, gt.position.y + 1, plain.Z) +const grave = { X: plain.X + 64, Z: plain.Z, CX: (plain.X + 64) >> 4, CZ: plain.Z >> 4 } +// Die there with something in the inventory, so LanternGraves makes a grave. +b.creative.stopFlying() +await s.cmd('gamemode survival tester') +await s.cmd('give tester minecraft:oak_log 5') +await sleep(500) +await s.cmd('kill tester') +await sleep(3000) +const list = await s.cmd('lanterngraves list tester', /grave|Grave|No /i).catch(() => '') +b.respawn?.() +await sleep(2000) +b.quit() +await s.cmd('save-all flush', /Saved the game/, 120_000) + +const w1 = await s.cmd(`rewild why world ${grave.CX} ${grave.CZ}`, /Chunk -?\d+, -?\d+: .*/) +await sleep(500) +const tail = s.log.slice(s.log.lastIndexOf(`Chunk ${grave.CX}, ${grave.CZ}:`)).split('\n').slice(0, 4).join(' | ') +check('the chunk with a grave is kept, and /rewild why names LanternGraves', /: kept/.test(w1) && /a grave \(LanternGraves\)/.test(tail), tail.slice(0, 240) + ' / ' + list.split('\n').slice(-2).join(' ').slice(0, 120)) +const w2 = await s.cmd(`rewild why world ${plain.CX} ${plain.CZ}`, /Chunk -?\d+, -?\d+: .*/) +check('a mined chunk with no grave would rewild', /would rewild/.test(w2), w2.split('\n').find(l => /Chunk/.test(l))) +const scan = await s.cmd('rewild scan world', /Proposed run|Nothing to reset|Nothing can be approved/, 120_000) +check('a scan proposes a run: the older provider\'s refusal of the areas call doesn\'t stop resets', /Proposed run/.test(scan) && !/couldn't say what to keep/.test(scan), + scan.split('\n').filter(l => /Proposed|Nothing/.test(l)).join(' ').slice(0, 220)) +await s.stop() +s.remove() +process.exitCode = report(path.join(REPO, `build/lanterngraves-${VERSION}.json`)) ? 1 : 0