Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 87 additions & 56 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ plugins {
}

group = "com.extantape"
version = "0.2.1"
version = "0.3.0"

java {
toolchain.languageVersion.set(JavaLanguageVersion.of(25))
Expand Down
7 changes: 7 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Name>` (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 <world> <x y z> [<x y z>] plugin:<Name> [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.
17 changes: 17 additions & 0 deletions docs/releases/v0.3.0.md
Original file line number Diff line number Diff line change
@@ -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:<Name>`). `--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.
27 changes: 27 additions & 0 deletions docs/test-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@

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 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` |
| **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`.
Expand Down
38 changes: 26 additions & 12 deletions src/main/java/com/extantape/rewild/RewildPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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 <Name>,"
+ " /rewild plugins ignore <Name>, or /rewild plugins ignore-rest.");
});
}

/** Why the respected-plugin answers stop resets, or null. */
public String pluginsProblem() {
java.util.List<String> 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
Expand All @@ -129,21 +142,22 @@ public List<String> 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<String> 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.");
}
}

Expand Down
41 changes: 34 additions & 7 deletions src/main/java/com/extantape/rewild/api/KeepProvider.java
Original file line number Diff line number Diff line change
@@ -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:
*
* <pre>{@code
* getServer().getServicesManager().register(KeepProvider.class, provider, this, ServicePriority.Normal);
* }</pre>
*
* 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.
*
* <p>Two ways to report, and a provider may use both:
* <ul>
* <li>{@link #keeps(World)}: whole chunks, left exactly as they are. For anything that needs its surroundings too.</li>
* <li>{@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.</li>
* </ul>
*/
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<Keep> keeps(World world);

/** Every block area in this world to keep in place right now. None by default. */
default Collection<Area> areas(World world) {
return List.of();
}
}
25 changes: 25 additions & 0 deletions src/main/java/com/extantape/rewild/cmd/Json.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading
Loading