diff --git a/README.md b/README.md
index 54fe93d..b39b5b2 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,8 @@ project produces its result item at the station.
other plugins can react to discoveries.
- **Persistent stations** — ownership, aspect progress, tested items, and the rolled
result survive server restarts.
+- **Stations pass on at death** — a dead character's stations go with the estate to
+ the will heir or the guild leader.
- **Ordinary lectern use** — lecterns without an active project still accept and
open written books and books and quills.
@@ -39,7 +41,7 @@ Technical documentation is maintained in [TF-Minecraft/Docs](https://github.com/
Run `mvn clean verify` with Java 21 after preparing the pinned private and shared
dependencies using the [build dependency guide](https://github.com/TF-Minecraft/Docs/blob/main/PIPELINES.md#build-dependencies).
JUnit 5, Mockito, and MockBukkit cover project progress, menus, item handling,
-persistence, configuration, and lifecycle behaviour. Surefire test results are
+persistence, configuration, estates and lifecycle behaviour. Surefire test results are
in `target/surefire-reports/`. The build enforces **100% executable-line coverage** across
all production Java classes, with no coverage exclusions. JaCoCo's HTML and XML
reports are written to `target/site/jacoco/` and uploaded by the Build workflow.
diff --git a/pom.xml b/pom.xml
index 06f1956..c19e602 100644
--- a/pom.xml
+++ b/pom.xml
@@ -17,7 +17,7 @@
full
2.16.0
- 2.3.0
+ 2.6.0
diff --git a/src/main/java/net/tfminecraft/research/Research.java b/src/main/java/net/tfminecraft/research/Research.java
index 1d69ddd..aec7899 100644
--- a/src/main/java/net/tfminecraft/research/Research.java
+++ b/src/main/java/net/tfminecraft/research/Research.java
@@ -5,6 +5,9 @@
import org.bukkit.plugin.java.JavaPlugin;
import net.tfminecraft.research.command.CommandManager;
+import net.tfminecraft.research.database.StationBackfill;
+import net.tfminecraft.research.estate.EstateProbe;
+import net.tfminecraft.research.estate.StationEstate;
import net.tfminecraft.research.loader.AspectLoader;
import net.tfminecraft.research.loader.ConfigLoader;
import net.tfminecraft.research.loader.GuiLoader;
@@ -51,6 +54,12 @@ public void onEnable() {
getCommand("research").setTabCompleter(commandManager);
getServer().getPluginManager().registerEvents(researchManager, this);
researchManager.start();
+ StationBackfill.forPlugin(getDataFolder(), getLogger()).run(researchManager.stations());
+ if (EstateProbe.available(EstateProbe.CLASSES)) {
+ StationEstate.start(this, researchManager);
+ } else {
+ getLogger().warning("TLibs has no estate API (2.6.0 or later); stations of dead characters stay with them.");
+ }
started = true;
getLogger().info("Research enabled.");
}
diff --git a/src/main/java/net/tfminecraft/research/database/DurableFiles.java b/src/main/java/net/tfminecraft/research/database/DurableFiles.java
new file mode 100644
index 0000000..ecc9335
--- /dev/null
+++ b/src/main/java/net/tfminecraft/research/database/DurableFiles.java
@@ -0,0 +1,37 @@
+package net.tfminecraft.research.database;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+
+/** Whole files only: a temporary file is flushed, then moved over the target, then the folder is flushed. */
+public final class DurableFiles {
+
+ private DurableFiles() {}
+
+ public static void write(Path target, String text) throws IOException {
+ Path folder = target.toAbsolutePath().getParent();
+ Files.createDirectories(folder);
+ Path temp = Files.createTempFile(folder, ".pending-", ".tmp");
+ try {
+ try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) {
+ ByteBuffer buffer = ByteBuffer.wrap(text.getBytes(StandardCharsets.UTF_8));
+ while (buffer.hasRemaining()) {
+ channel.write(buffer);
+ }
+ channel.force(true);
+ }
+ Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ try (FileChannel channel = FileChannel.open(folder, StandardOpenOption.READ)) {
+ channel.force(true);
+ }
+ } finally {
+ Files.deleteIfExists(temp);
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/research/database/StationBackfill.java b/src/main/java/net/tfminecraft/research/database/StationBackfill.java
new file mode 100644
index 0000000..f5d0516
--- /dev/null
+++ b/src/main/java/net/tfminecraft/research/database/StationBackfill.java
@@ -0,0 +1,279 @@
+package net.tfminecraft.research.database;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.function.BooleanSupplier;
+import java.util.function.Function;
+import java.util.logging.Logger;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+
+import net.tfminecraft.research.model.ResearchStation;
+import net.tfminecraft.tlibs.character.CharacterRef;
+import net.tfminecraft.tlibs.character.Characters;
+
+/**
+ * Once per server: stations saved before characters were recorded go to their account's reviewed heir,
+ * as 6a did for locks and vehicles. The plan ({@code plan.json}) is written before any station changes and
+ * reused after a crash; each changed station's original file is kept in {@code backup/}; {@code done.json}
+ * records the result, after which later boots do nothing. Accounts without a heir keep their stations
+ * unassigned (the next character on the account claims them) and are listed for staff.
+ */
+public final class StationBackfill {
+ static final String FOLDER = "data/backfill-character-v1";
+
+ record Destination(UUID ownerUuid, String characterId) {
+ }
+
+ record Plan(Map stations, List skipped) {
+ }
+
+ record Result(int filled, List unresolved, List skipped, String completedAt) {
+ }
+
+ private record Metadata(String original, JsonObject data, UUID ownerUuid, String characterId) {
+ }
+
+ private static final Gson JSON = new GsonBuilder().setPrettyPrinting().serializeNulls().create();
+ private final Path folder;
+ private final Logger logger;
+ private final BooleanSupplier ready;
+ private final Function> heirs;
+
+ StationBackfill(Path folder, Logger logger, BooleanSupplier ready, Function> heirs) {
+ this.folder = folder;
+ this.logger = logger;
+ this.ready = ready;
+ this.heirs = heirs;
+ }
+
+ /** The backfill for this data folder; it waits forever on a TLibs without reviewed heirs. */
+ public static StationBackfill forPlugin(File dataFolder, Logger logger) {
+ return forPlugin(dataFolder, logger, heirsAvailable(Characters.class));
+ }
+
+ // The heir methods are only linked when the probe found them (TLibs 2.4.0 or later).
+ static StationBackfill forPlugin(File dataFolder, Logger logger, boolean heirApi) {
+ Path folder = dataFolder.toPath().resolve(FOLDER);
+ if (!heirApi) {
+ return new StationBackfill(folder, logger, () -> false, owner -> Optional.empty());
+ }
+ return new StationBackfill(folder, logger, Characters::legacyHeirsReady, Characters::legacyHeir);
+ }
+
+ static boolean heirsAvailable(Class> characters) {
+ try {
+ characters.getMethod("legacyHeirsReady");
+ characters.getMethod("legacyHeir", UUID.class);
+ return true;
+ } catch (ReflectiveOperationException | LinkageError ex) {
+ return false;
+ }
+ }
+
+ /** Runs the backfill unless it already ran; false when it must try again at the next boot. */
+ public boolean run(List stations) {
+ Path marker = folder.resolve("done.json");
+ if (Files.exists(marker)) {
+ return true;
+ }
+ if (!ready.getAsBoolean()) {
+ logger.warning("[Research] Reviewed heirs are not ready; stations without a character wait for the next boot.");
+ return false;
+ }
+ try {
+ Plan plan = plan();
+ List unresolved = new ArrayList<>();
+ List skipped = new ArrayList<>(plan.skipped());
+ int filled = 0;
+ for (Map.Entry entry : plan.stations().entrySet()) {
+ String key = entry.getKey();
+ Destination destination = entry.getValue();
+ Path file = stationFile(key);
+ if (!Files.exists(file)) {
+ skipped.add(key);
+ continue;
+ }
+ Metadata metadata;
+ try {
+ metadata = read(file, key);
+ } catch (IOException | RuntimeException ex) {
+ throw new IOException("Could not read planned station " + key, ex);
+ }
+ ResearchStation station = find(stations, key);
+ // A replacement or a claim since the plan was written must never be overwritten.
+ if (!metadata.ownerUuid().equals(destination.ownerUuid())
+ || (station != null && (!station.getOwnerUuid().equals(metadata.ownerUuid())
+ || (station.getOwnerCharacterId() != null
+ && !station.getOwnerCharacterId().equalsIgnoreCase(metadata.characterId()))))) {
+ skipped.add(key);
+ continue;
+ }
+ if (metadata.characterId() != null) {
+ if (!metadata.characterId().equalsIgnoreCase(destination.characterId())) {
+ skipped.add(key);
+ continue;
+ }
+ } else if (destination.characterId() == null) {
+ unresolved.add(key);
+ continue;
+ } else {
+ fill(file, metadata, key, destination.characterId());
+ }
+ if (station != null) {
+ station.setOwnerCharacterId(destination.characterId());
+ }
+ filled++;
+ }
+ if (!unresolved.isEmpty()) {
+ logger.warning("[Research] Stations whose account has no reviewed heir stay unassigned: "
+ + String.join(", ", unresolved));
+ }
+ if (!skipped.isEmpty()) {
+ logger.warning("[Research] Station heir backfill skipped invalid, missing or changed stations; staff review: "
+ + String.join(", ", skipped));
+ }
+ DurableFiles.write(marker, JSON.toJson(new Result(filled, unresolved, skipped, Instant.now().toString())));
+ logger.info("[Research] Gave " + filled + " station(s) to their account's heir; " + unresolved.size()
+ + " without a heir; " + skipped.size() + " skipped for staff review.");
+ return true;
+ } catch (IOException | RuntimeException ex) {
+ logger.severe("[Research] Station heir backfill failed; retrying next boot: " + ex);
+ return false;
+ }
+ }
+
+ // The plan from an interrupted run is reused, so a crash never changes who a station goes to.
+ private Plan plan() throws IOException {
+ Path file = folder.resolve("plan.json");
+ if (Files.exists(file)) {
+ try {
+ Plan plan = JSON.fromJson(Files.readString(file), Plan.class);
+ if (plan == null || plan.stations() == null || plan.skipped() == null) {
+ throw new IOException("Incomplete backfill plan " + file);
+ }
+ for (Map.Entry entry : plan.stations().entrySet()) {
+ stationFile(entry.getKey());
+ if (entry.getValue() == null || entry.getValue().ownerUuid() == null) {
+ throw new IOException("Missing planned station account " + entry.getKey());
+ }
+ if (entry.getValue().characterId() != null) {
+ uuid(entry.getValue().characterId());
+ }
+ }
+ return plan;
+ } catch (JsonParseException ex) {
+ throw new IOException("Unreadable backfill plan " + file, ex);
+ }
+ }
+ Map stations = new LinkedHashMap<>();
+ List skipped = new ArrayList<>();
+ Path stationFolder = folder.getParent().resolve("stations");
+ Files.createDirectories(stationFolder);
+ try (var files = Files.list(stationFolder)) {
+ for (Path stationFile : files.filter(path -> path.getFileName().toString().endsWith(".json")).sorted().toList()) {
+ String name = stationFile.getFileName().toString();
+ String key = name.substring(0, name.length() - 5);
+ Metadata metadata;
+ try {
+ metadata = read(stationFile, key);
+ } catch (IOException | RuntimeException ex) {
+ skipped.add(key);
+ logger.warning("[Research] Invalid ownership metadata in station " + name + ": " + ex.getMessage());
+ continue;
+ }
+ if (metadata.characterId() == null) {
+ UUID account = metadata.ownerUuid();
+ // An heir on another account would hand the station to someone else's character.
+ stations.put(key, new Destination(account, heirs.apply(account)
+ .filter(heir -> heir.ownerId().equals(account))
+ .map(heir -> heir.characterId().toString()).orElse(null)));
+ }
+ }
+ }
+ Plan plan = new Plan(stations, skipped);
+ DurableFiles.write(file, JSON.toJson(plan));
+ return plan;
+ }
+
+ private void fill(Path file, Metadata metadata, String key, String character) throws IOException {
+ Path backup = folder.resolve("backup").resolve(key + ".json");
+ if (!Files.exists(backup)) {
+ DurableFiles.write(backup, metadata.original());
+ }
+ metadata.data().addProperty("owner_character_id", character);
+ DurableFiles.write(file, JSON.toJson(metadata.data()));
+ }
+
+ private Path stationFile(String key) throws IOException {
+ if (key.isBlank() || !Path.of(key).getFileName().toString().equals(key)) {
+ throw new IOException("Invalid planned station filename");
+ }
+ return folder.getParent().resolve("stations").resolve(key + ".json");
+ }
+
+ // Ownership does not depend on a recipe, an output, or a world being loadable today.
+ private static Metadata read(Path file, String key) throws IOException {
+ String original = Files.readString(file);
+ JsonObject data = JSON.fromJson(original, JsonObject.class);
+ String world = string(data, "world");
+ String savedKey = world + "_" + coordinate(data, "x") + "_" + coordinate(data, "y") + "_" + coordinate(data, "z");
+ if (!savedKey.equals(key)) {
+ throw new IOException("Station location does not match its filename");
+ }
+ UUID owner = uuid(string(data, "owner_uuid"));
+ JsonElement character = data.get("owner_character_id");
+ String characterId = character == null || character.isJsonNull() ? null : string(data, "owner_character_id");
+ if (characterId != null) {
+ uuid(characterId);
+ }
+ return new Metadata(original, data, owner, characterId);
+ }
+
+ private static String string(JsonObject data, String field) throws IOException {
+ JsonElement value = data == null ? null : data.get(field);
+ if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()
+ || value.getAsString().isBlank()) {
+ throw new IOException("Missing or invalid " + field);
+ }
+ return value.getAsString();
+ }
+
+ private static int coordinate(JsonObject data, String field) throws IOException {
+ JsonElement value = data.get(field);
+ if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) {
+ throw new IOException("Missing or invalid " + field);
+ }
+ return value.getAsBigDecimal().intValueExact();
+ }
+
+ private static UUID uuid(String value) throws IOException {
+ UUID uuid = UUID.fromString(value);
+ if (!uuid.toString().equalsIgnoreCase(value)) {
+ throw new IOException("Invalid ownership UUID");
+ }
+ return uuid;
+ }
+
+ private static ResearchStation find(List stations, String key) {
+ for (ResearchStation station : stations) {
+ if (ResearchStation.locationKey(station.getLocation()).equals(key)) {
+ return station;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/research/database/StationStore.java b/src/main/java/net/tfminecraft/research/database/StationStore.java
index f493569..f88f1bb 100644
--- a/src/main/java/net/tfminecraft/research/database/StationStore.java
+++ b/src/main/java/net/tfminecraft/research/database/StationStore.java
@@ -2,10 +2,8 @@
import java.io.File;
import java.io.FileReader;
-import java.io.FileWriter;
import java.io.IOException;
import java.io.Reader;
-import java.io.Writer;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@@ -60,12 +58,12 @@ public static void saveAll(List stations) {
}
}
- public static void saveStation(ResearchStation station) {
+ /** Writes the station atomically; false when it could not be saved (logged) or is incomplete. */
+ public static boolean saveStation(ResearchStation station) {
if (station == null || station.getLocation() == null || station.getProject() == null) {
- return;
+ return false;
}
File file = fileFor(station.getLocation());
- file.getParentFile().mkdirs();
ActiveProject project = station.getProject();
StationSaveData data = new StationSaveData();
@@ -87,11 +85,13 @@ public static void saveStation(ResearchStation station) {
data.setTestedItems(new java.util.ArrayList<>(project.getTestedExperimentItems()));
data.setAspectSlotOrder(new java.util.ArrayList<>(project.getAspectSlotOrder()));
- try (Writer writer = new FileWriter(file)) {
- GSON.toJson(data, writer);
+ try {
+ DurableFiles.write(file.toPath(), GSON.toJson(data));
+ return true;
} catch (IOException ex) {
Research.plugin.getLogger().severe("[Research] Failed to save station " + file.getName() + ": "
+ ex.getMessage());
+ return false;
}
}
@@ -183,7 +183,8 @@ private static File folder() {
return new File(Research.plugin.getDataFolder(), "data/stations");
}
- private static File fileFor(Location location) {
+ /** The file a station at this location is saved to. */
+ public static File fileFor(Location location) {
return new File(folder(), ResearchStation.locationKey(location) + ".json");
}
}
diff --git a/src/main/java/net/tfminecraft/research/estate/EstateProbe.java b/src/main/java/net/tfminecraft/research/estate/EstateProbe.java
new file mode 100644
index 0000000..27962c3
--- /dev/null
+++ b/src/main/java/net/tfminecraft/research/estate/EstateProbe.java
@@ -0,0 +1,26 @@
+package net.tfminecraft.research.estate;
+
+/** Whether TLibs has the estate API (2.6.0). On older TLibs stations stay with dead owners. */
+public final class EstateProbe {
+
+ public static final String[] CLASSES = {
+ "net.tfminecraft.tlibs.character.estate.Estates",
+ "net.tfminecraft.tlibs.character.estate.EstateParticipant",
+ "net.tfminecraft.tlibs.character.estate.EstateDecision",
+ "net.tfminecraft.tlibs.character.estate.Outcome",
+ "net.tfminecraft.tlibs.character.estate.Holdings",
+ };
+
+ private EstateProbe() {}
+
+ public static boolean available(String... classes) {
+ try {
+ for (String name : classes) {
+ Class.forName(name);
+ }
+ return true;
+ } catch (ReflectiveOperationException | LinkageError ex) {
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/research/estate/StationEstate.java b/src/main/java/net/tfminecraft/research/estate/StationEstate.java
new file mode 100644
index 0000000..610edd9
--- /dev/null
+++ b/src/main/java/net/tfminecraft/research/estate/StationEstate.java
@@ -0,0 +1,123 @@
+package net.tfminecraft.research.estate;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import java.util.logging.Logger;
+
+import org.bukkit.Location;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.plugin.ServicePriority;
+
+import net.tfminecraft.research.manager.ResearchManager;
+import net.tfminecraft.research.model.ResearchStation;
+import net.tfminecraft.tlibs.character.CharacterRef;
+import net.tfminecraft.tlibs.character.estate.EstateDecision;
+import net.tfminecraft.tlibs.character.estate.EstateParticipant;
+import net.tfminecraft.tlibs.character.estate.Estates;
+import net.tfminecraft.tlibs.character.estate.Holdings;
+import net.tfminecraft.tlibs.character.estate.Outcome;
+
+/**
+ * The {@code research} estate participant: a dead character's research stations go to the estate's
+ * property recipient. Loaded only after {@link EstateProbe} finds the TLibs 2.6.0 estate API.
+ */
+public final class StationEstate implements EstateParticipant {
+ static final String ID = "research";
+ static final long HOUR_TICKS = 20L * 60 * 60;
+
+ interface Reporter {
+ boolean report(UUID deathId, String participantId, long revision, Outcome outcome);
+ }
+
+ private final ResearchManager manager;
+ private final StationTransfers transfers;
+ private final Logger logger;
+ Predicate guard = Estates::guardHolds;
+ Supplier> decided = Estates::decided;
+ Reporter reporter = Estates::report;
+ // The last outcome reported per death, so the hourly pass reports only changes.
+ private final Map> reported = new HashMap<>();
+
+ StationEstate(ResearchManager manager, StationTransfers transfers, Logger logger) {
+ this.manager = manager;
+ this.transfers = transfers;
+ this.logger = logger;
+ }
+
+ /** Registers the participant once stations are loaded; reconciles on the first tick, then hourly. */
+ public static void start(Plugin plugin, ResearchManager manager) {
+ StationEstate estate = new StationEstate(manager,
+ new StationTransfers(manager, plugin.getDataFolder().toPath().resolve("data").resolve("estates")),
+ plugin.getLogger());
+ plugin.getServer().getServicesManager().register(EstateParticipant.class, estate, plugin, ServicePriority.Normal);
+ plugin.getServer().getScheduler().runTaskTimer(plugin, estate::reconcile, 1L, HOUR_TICKS);
+ }
+
+ @Override
+ public String id() {
+ return ID;
+ }
+
+ @Override
+ public Outcome settle(EstateDecision decision) {
+ if (!guard.test(decision)) {
+ return Outcome.retry("The estate guard does not hold");
+ }
+ Optional recipient = decision.property().character();
+ // Waiting and frozen property stays with the testator; there is nothing to move at this revision.
+ if (recipient.isEmpty()) {
+ return Outcome.done();
+ }
+ try {
+ List moved = transfers.move(decision.deathId(), decision.testator().characterId(), recipient.get());
+ return Outcome.done(Map.of("stations", moved.size()), moved);
+ } catch (IOException | RuntimeException ex) {
+ logger.warning("Could not move the research stations of estate " + decision.deathId() + ": " + ex);
+ return Outcome.retry("Could not save a research station: " + ex.getMessage());
+ }
+ }
+
+ @Override
+ public Holdings describe(UUID characterId) {
+ List labels = manager.stationsOwnedBy(characterId.toString()).stream().map(StationEstate::label)
+ .sorted().toList();
+ return labels.isEmpty() ? Holdings.NONE : new Holdings(Map.of("stations", labels.size()), labels);
+ }
+
+ /** Settles every decided estate whose guard holds and reports outcomes that changed. */
+ void reconcile() {
+ for (EstateDecision decision : decided.get()) {
+ // One estate failing must not stop the others until the next hour.
+ try {
+ if (!guard.test(decision)) {
+ continue;
+ }
+ Outcome outcome = settle(decision);
+ Map.Entry last = Map.entry(decision.revision(), outcome);
+ if (!last.equals(reported.get(decision.deathId()))
+ && reporter.report(decision.deathId(), ID, decision.revision(), outcome)) {
+ reported.put(decision.deathId(), last);
+ }
+ } catch (RuntimeException ex) {
+ logger.warning("Could not reconcile the research stations of estate " + decision.deathId() + ": " + ex);
+ }
+ }
+ }
+
+ private static String label(ResearchStation station) {
+ // Describe feeds the will screen, so a station without its world or project must not throw.
+ Location location = station.getLocation();
+ String place = location != null && location.isWorldLoaded()
+ ? location.getWorld().getName() + " " + location.getBlockX() + ", " + location.getBlockY() + ", "
+ + location.getBlockZ()
+ : "an unloaded world";
+ return "Research station at " + place
+ + (station.getProject() == null ? "" : " (" + station.getProject().getInputId() + ")");
+ }
+}
diff --git a/src/main/java/net/tfminecraft/research/estate/StationTransfers.java b/src/main/java/net/tfminecraft/research/estate/StationTransfers.java
new file mode 100644
index 0000000..605c257
--- /dev/null
+++ b/src/main/java/net/tfminecraft/research/estate/StationTransfers.java
@@ -0,0 +1,117 @@
+package net.tfminecraft.research.estate;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonParseException;
+
+import net.tfminecraft.research.database.DurableFiles;
+import net.tfminecraft.research.manager.ResearchManager;
+import net.tfminecraft.research.model.ResearchStation;
+import net.tfminecraft.tlibs.character.CharacterRef;
+
+/**
+ * Gives a dead character's research stations to the estate's recipient. Each death keeps a journal in
+ * {@code data/estates/.json}, written before the first station changes, so a crash between
+ * stations is finished at the next settle and every moved station is still reported.
+ */
+final class StationTransfers {
+ static final String PLANNED = "planned";
+ static final String MOVED = "moved";
+ // The station was scrapped, finished or given to someone else before this move reached it.
+ static final String SUPERSEDED = "superseded";
+
+ record Entry(String account, String character, String state) {
+ CharacterRef recipient() {
+ return new CharacterRef(UUID.fromString(account), UUID.fromString(character));
+ }
+ }
+
+ record Journal(String deathId, String testator, Map stations) {}
+
+ private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create();
+ private final ResearchManager manager;
+ private final Path directory;
+
+ StationTransfers(ResearchManager manager, Path directory) {
+ this.manager = manager;
+ this.directory = directory;
+ }
+
+ /** Moves the testator's stations and any planned before a crash; returns every station key moved. */
+ List move(UUID deathId, UUID testator, CharacterRef to) throws IOException {
+ Journal journal = read(deathId, testator);
+ Map stations = journal.stations();
+ List owned = manager.stationsOwnedBy(testator.toString());
+ Entry planned = new Entry(to.ownerId().toString(), to.characterId().toString(), PLANNED);
+ for (ResearchStation station : owned) {
+ stations.put(ResearchStation.locationKey(station.getLocation()), planned);
+ }
+ if (!owned.isEmpty()) {
+ write(deathId, journal);
+ }
+ boolean changed = false;
+ try {
+ for (Map.Entry entry : stations.entrySet()) {
+ Entry plan = entry.getValue();
+ if (!PLANNED.equals(plan.state())) {
+ continue;
+ }
+ ResearchStation station = manager.getStationByKey(entry.getKey());
+ String owner = station == null ? null : station.getOwnerCharacterId();
+ String state = SUPERSEDED;
+ if (testator.toString().equalsIgnoreCase(owner)) {
+ if (!manager.transferStation(station, plan.recipient())) {
+ throw new IOException("Could not save research station " + entry.getKey());
+ }
+ state = MOVED;
+ } else if (plan.character().equalsIgnoreCase(owner)) {
+ state = MOVED; // saved before a crash cut the journal update short
+ }
+ entry.setValue(new Entry(plan.account(), plan.character(), state));
+ changed = true;
+ }
+ } finally {
+ if (changed) {
+ write(deathId, journal);
+ }
+ }
+ return stations.entrySet().stream().filter(entry -> MOVED.equals(entry.getValue().state()))
+ .map(Map.Entry::getKey).toList();
+ }
+
+ private Journal read(UUID deathId, UUID testator) throws IOException {
+ Path file = file(deathId);
+ if (!Files.exists(file)) {
+ return new Journal(deathId.toString(), testator.toString(), new LinkedHashMap<>());
+ }
+ try {
+ Journal journal = JSON.fromJson(Files.readString(file), Journal.class);
+ if (journal == null || journal.stations() == null) {
+ throw new IOException("Incomplete estate journal " + file);
+ }
+ // A journal copied from another death must not add its stations to this estate.
+ if (!deathId.toString().equals(journal.deathId()) || !testator.toString().equals(journal.testator())) {
+ throw new IOException("Estate journal " + file + " belongs to another death");
+ }
+ return new Journal(journal.deathId(), journal.testator(), new LinkedHashMap<>(journal.stations()));
+ } catch (JsonParseException ex) {
+ throw new IOException("Unreadable estate journal " + file, ex);
+ }
+ }
+
+ private void write(UUID deathId, Journal journal) throws IOException {
+ DurableFiles.write(file(deathId), JSON.toJson(journal));
+ }
+
+ private Path file(UUID deathId) {
+ return directory.resolve(deathId + ".json");
+ }
+}
diff --git a/src/main/java/net/tfminecraft/research/manager/ResearchManager.java b/src/main/java/net/tfminecraft/research/manager/ResearchManager.java
index f0e3bf3..c0eaa46 100644
--- a/src/main/java/net/tfminecraft/research/manager/ResearchManager.java
+++ b/src/main/java/net/tfminecraft/research/manager/ResearchManager.java
@@ -54,6 +54,7 @@
import net.tfminecraft.research.util.SoundKeys;
import net.tfminecraft.research.util.StationCompleteEffects;
import net.tfminecraft.research.util.StationStartEffects;
+import net.tfminecraft.tlibs.character.CharacterRef;
import net.tfminecraft.tlibs.character.CharacterSessionEndEvent;
public final class ResearchManager implements Listener {
@@ -92,6 +93,40 @@ public void unloadAll() {
stations.clear();
}
+ /** Every loaded station, read-only. */
+ public List stations() {
+ return java.util.Collections.unmodifiableList(stations);
+ }
+
+ /** Stations whose project this character started. */
+ public List stationsOwnedBy(String characterId) {
+ return stations.stream().filter(station -> characterId.equalsIgnoreCase(station.getOwnerCharacterId())).toList();
+ }
+
+ /** The station with this {@link ResearchStation#locationKey} key, or null. */
+ public ResearchStation getStationByKey(String key) {
+ for (ResearchStation station : stations) {
+ if (ResearchStation.locationKey(station.getLocation()).equals(key)) {
+ return station;
+ }
+ }
+ return null;
+ }
+
+ /** Gives a station to another character and saves it. A failed save puts the old owner back. */
+ public boolean transferStation(ResearchStation station, CharacterRef to) {
+ UUID previousAccount = station.getOwnerUuid();
+ String previousCharacter = station.getOwnerCharacterId();
+ station.setOwnerUuid(to.ownerId());
+ station.setOwnerCharacterId(to.characterId().toString());
+ if (StationStore.saveStation(station)) {
+ return true;
+ }
+ station.setOwnerUuid(previousAccount);
+ station.setOwnerCharacterId(previousCharacter);
+ return false;
+ }
+
public ResearchStation getStationAt(Location location) {
Location blockLoc = blockLocation(location);
for (ResearchStation station : stations) {
diff --git a/src/test/java/net/tfminecraft/research/ResearchLifecycleTest.java b/src/test/java/net/tfminecraft/research/ResearchLifecycleTest.java
index 49ab57e..5d7d9ae 100644
--- a/src/test/java/net/tfminecraft/research/ResearchLifecycleTest.java
+++ b/src/test/java/net/tfminecraft/research/ResearchLifecycleTest.java
@@ -1,10 +1,12 @@
package net.tfminecraft.research;
import net.tfminecraft.research.command.CommandManager;
+import net.tfminecraft.research.estate.EstateProbe;
import net.tfminecraft.research.loader.ConfigLoader;
import net.tfminecraft.research.manager.*;
import net.tfminecraft.research.util.ItemRef;
import net.tfminecraft.tlibs.character.Characters;
+import net.tfminecraft.tlibs.character.estate.EstateParticipant;
import org.bukkit.command.*;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.*;
@@ -48,6 +50,26 @@ class ResearchLifecycleTest {
assertTrue(Files.readString(plugin.getDataFolder().toPath().resolve("config.yml")).contains("external_modifiers"));
plugin.onDisable(); verify(plugin.getResearchManager()).unloadAll();
}
+ @Test void enableRegistersTheStationEstateParticipantAfterLoadingStations() {
+ Research plugin = MockBukkit.load(Research.class);
+ var registrations = plugin.getServer().getServicesManager().getRegistrations(EstateParticipant.class);
+ assertEquals(1, registrations.size());
+ assertEquals("research", registrations.iterator().next().getProvider().id());
+ assertSame(plugin, registrations.iterator().next().getPlugin());
+ // Stations are loaded, then the one-time heir backfill sees them.
+ var order = inOrder(plugin.getResearchManager());
+ order.verify(plugin.getResearchManager()).start();
+ order.verify(plugin.getResearchManager()).stations();
+ }
+ @Test void tlibsWithoutEstatesStillEnablesResearchWithoutTheParticipant() {
+ try (MockedStatic probe = mockStatic(EstateProbe.class)) {
+ probe.when(() -> EstateProbe.available(any(String[].class))).thenReturn(false);
+ Research plugin = MockBukkit.load(Research.class);
+ assertTrue(plugin.isEnabled());
+ verify(plugin.getResearchManager()).start();
+ assertTrue(plugin.getServer().getServicesManager().getRegistrations(EstateParticipant.class).isEmpty());
+ }
+ }
@Test void olderTLibsDisablesResearchBeforeItRegistersAnything() {
try (MockedStatic characters = mockStatic(Characters.class)) {
characters.when(Characters::available).thenThrow(new NoClassDefFoundError("CharacterSession"));
diff --git a/src/test/java/net/tfminecraft/research/database/ResearchPersistenceTest.java b/src/test/java/net/tfminecraft/research/database/ResearchPersistenceTest.java
index 6a2f6ae..e331769 100644
--- a/src/test/java/net/tfminecraft/research/database/ResearchPersistenceTest.java
+++ b/src/test/java/net/tfminecraft/research/database/ResearchPersistenceTest.java
@@ -207,16 +207,32 @@ void missingOrBlockedStationFolderDoesNotThrow() throws Exception {
assertTrue(StationStore.loadAll().isEmpty());
}
+ @Test
+ void savingReplacesTheWholeFileAndLeavesNoTemporaryFiles() throws Exception {
+ Path file = stationsFolder().resolve("world_1_64_2.json");
+ Files.createDirectories(stationsFolder());
+ Files.writeString(file, "x".repeat(10_000));
+ ResearchStation station = station();
+ station.setOwnerCharacterId("alice");
+
+ assertTrue(StationStore.saveStation(station));
+
+ assertEquals("alice", new Gson().fromJson(Files.readString(file), StationSaveData.class).getOwnerCharacterId());
+ try (var files = Files.list(stationsFolder())) {
+ assertEquals(List.of(file), files.toList());
+ }
+ }
+
@Test
void invalidSaveInputsAreIgnoredAndFilesystemFailuresAreLogged() throws Exception {
- StationStore.saveStation(null);
- StationStore.saveStation(new ResearchStation(null, OWNER, station().getProject()));
- StationStore.saveStation(new ResearchStation(new Location(world, 1, 64, 2), OWNER, null));
+ assertFalse(StationStore.saveStation(null));
+ assertFalse(StationStore.saveStation(new ResearchStation(null, OWNER, station().getProject())));
+ assertFalse(StationStore.saveStation(new ResearchStation(new Location(world, 1, 64, 2), OWNER, null)));
assertFalse(Files.exists(stationsFolder()));
Path file = stationsFolder().resolve("world_1_64_2.json");
Files.createDirectories(file);
Files.writeString(file.resolve("child"), "prevents deletion");
- assertDoesNotThrow(() -> StationStore.saveStation(station()));
+ assertFalse(StationStore.saveStation(station()));
verify(logger).severe(contains("Failed to save station"));
StationStore.deleteStation(station().getLocation());
verify(logger).warning(contains("Failed to delete station file"));
diff --git a/src/test/java/net/tfminecraft/research/database/StationBackfillTest.java b/src/test/java/net/tfminecraft/research/database/StationBackfillTest.java
new file mode 100644
index 0000000..aad91c6
--- /dev/null
+++ b/src/test/java/net/tfminecraft/research/database/StationBackfillTest.java
@@ -0,0 +1,456 @@
+package net.tfminecraft.research.database;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.function.Function;
+import java.util.logging.Logger;
+
+import org.bukkit.Location;
+import org.bukkit.World;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockbukkit.mockbukkit.MockBukkit;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+import net.tfminecraft.research.Research;
+import net.tfminecraft.research.ResearchTestState;
+import net.tfminecraft.research.loader.InputLoader;
+import net.tfminecraft.research.loader.OutputLoader;
+import net.tfminecraft.research.model.ActiveProject;
+import net.tfminecraft.research.model.InputDef;
+import net.tfminecraft.research.model.OutputDef;
+import net.tfminecraft.research.model.ResearchStation;
+import net.tfminecraft.tlibs.character.CharacterRef;
+import net.tfminecraft.tlibs.character.Characters;
+
+class StationBackfillTest {
+ private static final UUID ACCOUNT = UUID.randomUUID();
+ private static final CharacterRef HEIR = new CharacterRef(ACCOUNT, UUID.randomUUID());
+ private static final UUID NO_HEIR = UUID.randomUUID();
+ private static final UUID STRANGER = UUID.randomUUID();
+ private static final String CLAIMED = UUID.randomUUID().toString();
+
+ @TempDir Path folder;
+ private ResearchTestState globals;
+ private World world;
+ private Logger logger;
+ private boolean ready = true;
+ private final Map heirs = new HashMap<>();
+ private int heirLookups;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ world = MockBukkit.mock().addSimpleWorld("world");
+ globals = new ResearchTestState();
+ Research.plugin = mock(Research.class);
+ logger = mock(Logger.class);
+ when(Research.plugin.getDataFolder()).thenReturn(folder.toFile());
+ when(Research.plugin.getLogger()).thenReturn(logger);
+ InputLoader.get().clear();
+ OutputLoader.get().clear();
+ InputLoader.get().put("input", new InputDef("input", new YamlConfiguration()));
+ YamlConfiguration output = new YamlConfiguration();
+ output.set("aspects.fire.required_points", 10);
+ OutputLoader.get().put("output", new OutputDef("output", output));
+ heirs.put(ACCOUNT, HEIR);
+ // An heir on another account must never be used.
+ heirs.put(STRANGER, new CharacterRef(UUID.randomUUID(), UUID.randomUUID()));
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ globals.close();
+ MockBukkit.unmock();
+ }
+
+ @Test
+ void unloadedStationsGetTheirHeirWithoutChangingRecipeOrExtensionFields() throws Exception {
+ save(ACCOUNT, null, 1);
+ save(ACCOUNT, null, 2);
+ JsonObject unknownRecipe = JsonParser.parseString(Files.readString(stationFile(1))).getAsJsonObject();
+ unknownRecipe.addProperty("input_id", "retired-input");
+ unknownRecipe.add("extension", JsonParser.parseString("{\"nested\":[1,null,\"kept\"],\"flag\":true}"));
+ Files.writeString(stationFile(1), unknownRecipe.toString());
+ JsonObject missingWorld = JsonParser.parseString(Files.readString(stationFile(2))).getAsJsonObject();
+ missingWorld.addProperty("world", "unloaded_world");
+ Path missingWorldFile = folder.resolve("data/stations/unloaded_world_2_64_0.json");
+ Files.delete(stationFile(2));
+ Files.writeString(missingWorldFile, missingWorld.toString());
+ String original = Files.readString(stationFile(1));
+ assertTrue(StationStore.loadAll().isEmpty());
+
+ assertTrue(backfill().run(StationStore.loadAll()));
+
+ JsonObject updated = JsonParser.parseString(Files.readString(stationFile(1))).getAsJsonObject();
+ assertEquals(HEIR.characterId().toString(), updated.remove("owner_character_id").getAsString());
+ assertEquals(unknownRecipe, updated);
+ JsonObject updatedWorld = JsonParser.parseString(Files.readString(missingWorldFile)).getAsJsonObject();
+ assertEquals(HEIR.characterId().toString(), updatedWorld.remove("owner_character_id").getAsString());
+ assertEquals(missingWorld, updatedWorld);
+ assertEquals(original, Files.readString(backfillFolder().resolve("backup/world_1_64_0.json")));
+ assertEquals(2, result().filled());
+ assertTrue(StationStore.loadAll().isEmpty());
+ }
+
+ @Test
+ void stationsWithoutACharacterGoToTheAccountsHeirWithABackup() throws Exception {
+ save(ACCOUNT, null, 1);
+ save(ACCOUNT, CLAIMED, 2);
+ String original = Files.readString(stationFile(1));
+
+ List stations = StationStore.loadAll();
+ assertTrue(backfill().run(stations));
+
+ assertEquals(HEIR.characterId().toString(), stations.stream()
+ .filter(station -> station.getLocation().getBlockX() == 1).findFirst().orElseThrow().getOwnerCharacterId());
+ assertEquals(HEIR.characterId().toString(), loaded(1).getOwnerCharacterId());
+ assertEquals(CLAIMED, loaded(2).getOwnerCharacterId());
+ assertEquals(original, Files.readString(backfillFolder().resolve("backup/world_1_64_0.json")));
+ assertFalse(Files.exists(backfillFolder().resolve("backup/world_2_64_0.json")));
+ StationBackfill.Result result = result();
+ assertEquals(1, result.filled());
+ assertEquals(List.of(), result.unresolved());
+ }
+
+ @Test
+ void stationsWhoseAccountHasNoHeirStayUnassignedAndAreListed() throws Exception {
+ save(NO_HEIR, null, 1);
+ save(STRANGER, null, 2);
+
+ List stations = StationStore.loadAll();
+ assertTrue(backfill().run(stations));
+
+ assertNull(loaded(1).getOwnerCharacterId());
+ assertNull(loaded(2).getOwnerCharacterId());
+ assertEquals(List.of("world_1_64_0", "world_2_64_0"), result().unresolved().stream().sorted().toList());
+ assertEquals(0, result().filled());
+ verify(logger).warning(contains("world_1_64_0"));
+ }
+
+ @Test
+ void notReadyChangesNothingAndTriesAgainNextBoot() throws Exception {
+ save(ACCOUNT, null, 1);
+ ready = false;
+
+ assertFalse(backfill().run(StationStore.loadAll()));
+
+ assertNull(loaded(1).getOwnerCharacterId());
+ assertFalse(Files.exists(backfillFolder()));
+ assertEquals(0, heirLookups);
+
+ ready = true;
+ assertTrue(backfill().run(StationStore.loadAll()));
+ assertEquals(HEIR.characterId().toString(), loaded(1).getOwnerCharacterId());
+ }
+
+ @Test
+ void aSecondBootDoesNothing() throws Exception {
+ save(ACCOUNT, null, 1);
+ assertTrue(backfill().run(StationStore.loadAll()));
+ String marker = Files.readString(backfillFolder().resolve("done.json"));
+ save(ACCOUNT, null, 2);
+ heirLookups = 0;
+
+ assertTrue(backfill().run(StationStore.loadAll()));
+
+ assertEquals(0, heirLookups);
+ assertNull(loaded(2).getOwnerCharacterId());
+ assertEquals(marker, Files.readString(backfillFolder().resolve("done.json")));
+ }
+
+ @Test
+ void aCrashBeforeTheMarkerFinishesFromTheSamePlan() throws Exception {
+ save(ACCOUNT, null, 1);
+ save(ACCOUNT, null, 2);
+ save(ACCOUNT, null, 3);
+ // The first run wrote its plan, backed up and filled station 1, then the server died.
+ Map planned = new java.util.LinkedHashMap<>();
+ planned.put("world_1_64_0", destination(HEIR.characterId().toString()));
+ planned.put("world_2_64_0", destination(HEIR.characterId().toString()));
+ planned.put("world_3_64_0", destination(null));
+ Files.createDirectories(backfillFolder().resolve("backup"));
+ writePlan(planned);
+ String original = Files.readString(stationFile(1));
+ Files.writeString(backfillFolder().resolve("backup/world_1_64_0.json"), original);
+ List stations = StationStore.loadAll();
+ save(ACCOUNT, HEIR.characterId().toString(), 1);
+ heirs.put(ACCOUNT, new CharacterRef(ACCOUNT, UUID.randomUUID()));
+
+ assertTrue(backfill().run(stations));
+
+ assertEquals(0, heirLookups);
+ assertEquals(HEIR.characterId().toString(), stations.stream()
+ .filter(station -> station.getLocation().getBlockX() == 1).findFirst().orElseThrow().getOwnerCharacterId());
+ assertEquals(HEIR.characterId().toString(), loaded(2).getOwnerCharacterId());
+ assertNull(loaded(3).getOwnerCharacterId());
+ assertEquals(original, Files.readString(backfillFolder().resolve("backup/world_1_64_0.json")));
+ assertEquals(2, result().filled());
+ assertEquals(List.of("world_3_64_0"), result().unresolved());
+ }
+
+ @Test
+ void aFailedSaveLeavesTheStationUnassignedAndWritesNoMarker() throws Exception {
+ save(ACCOUNT, null, 1);
+ List stations = StationStore.loadAll();
+ Path file = stationFile(1);
+ String original = Files.readString(file);
+ try (var writes = mockStatic(DurableFiles.class, CALLS_REAL_METHODS)) {
+ writes.when(() -> DurableFiles.write(eq(file), anyString())).thenThrow(new java.io.IOException("write failed"));
+ assertFalse(backfill().run(stations));
+ }
+
+ assertNull(stations.getFirst().getOwnerCharacterId());
+ assertEquals(original, Files.readString(file));
+ assertEquals(original, Files.readString(backfillFolder().resolve("backup/world_1_64_0.json")));
+ assertFalse(Files.exists(backfillFolder().resolve("done.json")));
+ assertTrue(backfill().run(stations));
+ assertEquals(HEIR.characterId().toString(), stations.getFirst().getOwnerCharacterId());
+ }
+
+ @Test
+ void aStationThatCannotBeBackedUpIsLeftAlone() throws Exception {
+ save(ACCOUNT, null, 1);
+ Files.createDirectories(backfillFolder());
+ Files.writeString(backfillFolder().resolve("backup"), "not a folder");
+
+ assertFalse(backfill().run(StationStore.loadAll()));
+
+ assertNull(loaded(1).getOwnerCharacterId());
+ verify(logger).severe(startsWith("[Research] Station heir backfill failed"));
+ }
+
+ @Test
+ void anUnreadablePlanStopsTheBackfill() throws Exception {
+ save(ACCOUNT, null, 1);
+ Files.createDirectories(backfillFolder());
+ Files.writeString(backfillFolder().resolve("plan.json"), "{ broken");
+ assertFalse(backfill().run(StationStore.loadAll()));
+ Files.writeString(backfillFolder().resolve("plan.json"), "{}");
+ assertFalse(backfill().run(StationStore.loadAll()));
+ assertNull(loaded(1).getOwnerCharacterId());
+ verify(logger, times(2)).severe(startsWith("[Research] Station heir backfill failed"));
+ }
+
+ @Test
+ void plannedStationsThatAreGoneOrClaimedAreNotCounted() throws Exception {
+ save(ACCOUNT, CLAIMED, 1);
+ writePlan(Map.of("world_1_64_0", destination(HEIR.characterId().toString()),
+ "world_9_64_0", destination(HEIR.characterId().toString())));
+
+ assertTrue(backfill().run(StationStore.loadAll()));
+
+ assertEquals(CLAIMED, loaded(1).getOwnerCharacterId());
+ assertEquals(0, result().filled());
+ assertEquals(List.of("world_1_64_0", "world_9_64_0"), result().skipped().stream().sorted().toList());
+ }
+
+ @Test
+ void corruptOwnershipMetadataIsPreservedAndListedForStaff() throws Exception {
+ save(ACCOUNT, null, 1);
+ String valid = Files.readString(stationFile(1));
+ Map corrupt = new java.util.LinkedHashMap<>();
+ corrupt.put("broken", "{ broken");
+ corrupt.put("null", "null");
+ corrupt.put("array", "[]");
+ corrupt.put("no-owner", valid.replace("owner_uuid", "former_owner_uuid"));
+ corrupt.put("invalid-owner", valid.replace(ACCOUNT.toString(), "not-a-uuid"));
+ corrupt.put("short-owner", valid.replace(ACCOUNT.toString(), "1-1-1-1-1"));
+ corrupt.put("wrong-location", valid.replace("\"x\": 1", "\"x\": 2"));
+ corrupt.put("missing-coordinate", valid.replace("\"x\": 1,", ""));
+ corrupt.put("invalid-coordinate", valid.replace("\"x\": 1", "\"x\": \"1\""));
+ corrupt.put("fractional-coordinate", valid.replace("\"x\": 1", "\"x\": 1.5"));
+ corrupt.put("invalid-world", valid.replace("\"world\": \"world\"", "\"world\": {}"));
+ corrupt.put("blank-world", valid.replace("\"world\": \"world\"", "\"world\": \"\""));
+ // These match their filename so the corrupt character field is the reason they are skipped.
+ JsonObject badCharacter = JsonParser.parseString(valid).getAsJsonObject();
+ badCharacter.addProperty("x", 2);
+ badCharacter.addProperty("owner_character_id", "invalid-character");
+ corrupt.put("world_2_64_0", badCharacter.toString());
+ JsonObject wrongOwnerType = JsonParser.parseString(valid).getAsJsonObject();
+ wrongOwnerType.addProperty("x", 3);
+ wrongOwnerType.addProperty("owner_uuid", 123);
+ corrupt.put("world_3_64_0", wrongOwnerType.toString());
+ Map matchingFiles = new java.util.LinkedHashMap<>();
+ for (var entry : corrupt.entrySet()) {
+ String key = entry.getKey().startsWith("world_") ? entry.getKey() : entry.getKey() + "_1_64_0";
+ String contents = entry.getKey().startsWith("world_") ? entry.getValue() : entry.getValue()
+ .replace("\"world\": \"world\"", "\"world\": \"" + entry.getKey() + "\"");
+ matchingFiles.put(key, contents);
+ }
+ corrupt = matchingFiles;
+ for (var entry : corrupt.entrySet()) {
+ Files.writeString(folder.resolve("data/stations/" + entry.getKey() + ".json"), entry.getValue());
+ }
+ Files.createDirectories(folder.resolve("data/stations/unreadable.json"));
+ Files.writeString(folder.resolve("data/stations/ignored.txt"), "ignored");
+
+ assertTrue(backfill().run(StationStore.loadAll()));
+
+ assertEquals(HEIR.characterId().toString(), loaded(1).getOwnerCharacterId());
+ assertEquals(1, result().filled());
+ List skipped = new java.util.ArrayList<>(corrupt.keySet());
+ skipped.add("unreadable");
+ assertEquals(skipped.stream().sorted().toList(), result().skipped().stream().sorted().toList());
+ for (var entry : corrupt.entrySet()) {
+ assertEquals(entry.getValue(), Files.readString(folder.resolve("data/stations/" + entry.getKey() + ".json")));
+ }
+ verify(logger).warning(contains("staff review:"));
+ assertEquals(List.of(), result().unresolved());
+ }
+
+ @Test
+ void changedAccountsAndInMemoryClaimsAreNotOverwrittenOnReplay() throws Exception {
+ save(NO_HEIR, null, 1);
+ save(ACCOUNT, null, 2);
+ save(ACCOUNT, null, 3);
+ List stations = StationStore.loadAll();
+ stations.stream().filter(station -> station.getLocation().getBlockX() == 2)
+ .findFirst().orElseThrow().setOwnerCharacterId(CLAIMED);
+ stations.stream().filter(station -> station.getLocation().getBlockX() == 3)
+ .findFirst().orElseThrow().setOwnerUuid(NO_HEIR);
+ writePlan(Map.of("world_1_64_0", destination(HEIR.characterId().toString()),
+ "world_2_64_0", destination(HEIR.characterId().toString()),
+ "world_3_64_0", destination(HEIR.characterId().toString())));
+
+ assertTrue(backfill().run(stations));
+
+ assertEquals(0, result().filled());
+ assertEquals(List.of("world_1_64_0", "world_2_64_0", "world_3_64_0"), result().skipped().stream().sorted().toList());
+ assertNull(loaded(1).getOwnerCharacterId());
+ assertNull(loaded(2).getOwnerCharacterId());
+ assertNull(loaded(3).getOwnerCharacterId());
+ assertFalse(Files.exists(backfillFolder().resolve("backup")));
+ }
+
+ @Test
+ void aMarkerFailureReplaysTheSameHeirAndKeepsTheOriginalBackup() throws Exception {
+ save(ACCOUNT, null, 1);
+ JsonObject data = JsonParser.parseString(Files.readString(stationFile(1))).getAsJsonObject();
+ data.addProperty("resolved_output_id", "unknown-output");
+ data.add("extension", JsonParser.parseString("[1,2,3]"));
+ data.add("owner_character_id", com.google.gson.JsonNull.INSTANCE);
+ Files.writeString(stationFile(1), data.toString());
+ String original = Files.readString(stationFile(1));
+ Path marker = backfillFolder().resolve("done.json");
+ try (var writes = mockStatic(DurableFiles.class, CALLS_REAL_METHODS)) {
+ writes.when(() -> DurableFiles.write(eq(marker), anyString())).thenThrow(new java.io.IOException("marker failed"));
+ assertFalse(backfill().run(StationStore.loadAll()));
+ }
+ assertFalse(Files.exists(marker));
+ String assigned = Files.readString(stationFile(1));
+ assertEquals(HEIR.characterId().toString(), JsonParser.parseString(assigned).getAsJsonObject()
+ .get("owner_character_id").getAsString());
+ heirs.put(ACCOUNT, new CharacterRef(ACCOUNT, UUID.randomUUID()));
+ heirLookups = 0;
+
+ assertTrue(backfill().run(StationStore.loadAll()));
+
+ assertEquals(0, heirLookups);
+ assertEquals(assigned, Files.readString(stationFile(1)));
+ assertEquals(original, Files.readString(backfillFolder().resolve("backup/world_1_64_0.json")));
+ assertEquals(1, result().filled());
+ }
+
+ @Test
+ void anInterruptedPlanWhoseStationBecomesUnreadableWaitsForRepair() throws Exception {
+ save(ACCOUNT, null, 1);
+ String original = Files.readString(stationFile(1));
+ writePlan(Map.of("world_1_64_0", destination(HEIR.characterId().toString())));
+ Files.writeString(stationFile(1), "{ broken");
+
+ assertFalse(backfill().run(List.of()));
+
+ assertFalse(Files.exists(backfillFolder().resolve("done.json")));
+ verify(logger).severe(contains("Could not read planned station world_1_64_0"));
+ Files.writeString(stationFile(1), original);
+ assertTrue(backfill().run(StationStore.loadAll()));
+ assertEquals(0, heirLookups);
+ assertEquals(1, result().filled());
+ }
+
+ @Test
+ void incompleteUnsafeOrLegacyPlansAreRefusedWithoutReplanning() throws Exception {
+ save(ACCOUNT, null, 1);
+ Files.createDirectories(backfillFolder());
+ List invalidPlans = List.of("null", "{\"stations\":{}}", "{\"stations\":{\"world_1_64_0\":null},\"skipped\":[]}",
+ "{\"stations\":{\"world_1_64_0\":{\"characterId\":\"" + HEIR.characterId() + "\"}},\"skipped\":[]}",
+ "{\"stations\":{\"world_1_64_0\":\"" + HEIR.characterId() + "\"}}",
+ "{\"stations\":{\"../outside\":{\"ownerUuid\":\"" + ACCOUNT + "\"}},\"skipped\":[]}");
+ for (String plan : invalidPlans) {
+ Files.writeString(backfillFolder().resolve("plan.json"), plan);
+ assertFalse(backfill().run(StationStore.loadAll()));
+ assertEquals(plan, Files.readString(backfillFolder().resolve("plan.json")));
+ }
+ assertEquals(0, heirLookups);
+ assertNull(loaded(1).getOwnerCharacterId());
+ assertFalse(Files.exists(backfillFolder().resolve("done.json")));
+ }
+
+ @Test
+ void theHeirApiIsProbedSoAnOlderTLibsJustWaits() throws Exception {
+ assertTrue(StationBackfill.heirsAvailable(Characters.class));
+ assertFalse(StationBackfill.heirsAvailable(String.class));
+ save(ACCOUNT, null, 1);
+ // Without an identity provider the heirs are never ready.
+ assertFalse(StationBackfill.forPlugin(folder.toFile(), logger).run(StationStore.loadAll()));
+ // Without the heir API it waits the same way.
+ assertFalse(StationBackfill.forPlugin(folder.toFile(), logger, false).run(StationStore.loadAll()));
+ assertNull(loaded(1).getOwnerCharacterId());
+ }
+
+ private StationBackfill backfill() {
+ Function> lookup = account -> {
+ heirLookups++;
+ return Optional.ofNullable(heirs.get(account));
+ };
+ return new StationBackfill(backfillFolder(), logger, () -> ready, lookup);
+ }
+
+ private StationBackfill.Destination destination(String character) {
+ return new StationBackfill.Destination(ACCOUNT, character);
+ }
+
+ private void writePlan(Map destinations) throws Exception {
+ Files.createDirectories(backfillFolder());
+ Files.writeString(backfillFolder().resolve("plan.json"), new com.google.gson.GsonBuilder().serializeNulls()
+ .create().toJson(new StationBackfill.Plan(destinations, List.of())));
+ }
+
+ private StationBackfill.Result result() throws Exception {
+ return new Gson().fromJson(Files.readString(backfillFolder().resolve("done.json")), StationBackfill.Result.class);
+ }
+
+ private void save(UUID account, String character, int x) {
+ ResearchStation station = new ResearchStation(new Location(world, x, 64, 0), account,
+ new ActiveProject("input", "output", "minecraft:diamond", false));
+ station.setOwnerCharacterId(character);
+ assertTrue(StationStore.saveStation(station));
+ }
+
+ private ResearchStation loaded(int x) {
+ return StationStore.loadAll().stream().filter(station -> station.getLocation().getBlockX() == x)
+ .findFirst().orElseThrow();
+ }
+
+ private Path stationFile(int x) {
+ return folder.resolve("data/stations/world_" + x + "_64_0.json");
+ }
+
+ private Path backfillFolder() {
+ return folder.resolve(StationBackfill.FOLDER);
+ }
+}
diff --git a/src/test/java/net/tfminecraft/research/estate/StationEstateTest.java b/src/test/java/net/tfminecraft/research/estate/StationEstateTest.java
new file mode 100644
index 0000000..9ecec45
--- /dev/null
+++ b/src/test/java/net/tfminecraft/research/estate/StationEstateTest.java
@@ -0,0 +1,387 @@
+package net.tfminecraft.research.estate;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.logging.Logger;
+
+import org.bukkit.Location;
+import org.bukkit.Server;
+import org.bukkit.World;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.plugin.ServicePriority;
+import org.bukkit.plugin.ServicesManager;
+import org.bukkit.scheduler.BukkitScheduler;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockbukkit.mockbukkit.MockBukkit;
+
+import com.google.gson.Gson;
+
+import net.tfminecraft.research.Research;
+import net.tfminecraft.research.ResearchTestState;
+import net.tfminecraft.research.database.StationStore;
+import net.tfminecraft.research.loader.InputLoader;
+import net.tfminecraft.research.loader.OutputLoader;
+import net.tfminecraft.research.manager.PlayerManager;
+import net.tfminecraft.research.manager.ResearchManager;
+import net.tfminecraft.research.model.ActiveProject;
+import net.tfminecraft.research.model.InputDef;
+import net.tfminecraft.research.model.OutputDef;
+import net.tfminecraft.research.model.ResearchStation;
+import net.tfminecraft.tlibs.character.CharacterRef;
+import net.tfminecraft.tlibs.character.estate.EstateDecision;
+import net.tfminecraft.tlibs.character.estate.EstateParticipant;
+import net.tfminecraft.tlibs.character.estate.Holdings;
+import net.tfminecraft.tlibs.character.estate.Outcome;
+import net.tfminecraft.tlibs.character.estate.Recipient;
+
+class StationEstateTest {
+ private static final CharacterRef TESTATOR = new CharacterRef(UUID.randomUUID(), UUID.randomUUID());
+ private static final CharacterRef HEIR = new CharacterRef(UUID.randomUUID(), UUID.randomUUID());
+ private static final CharacterRef ALT = new CharacterRef(TESTATOR.ownerId(), UUID.randomUUID());
+ private static final CharacterRef OTHER = new CharacterRef(UUID.randomUUID(), UUID.randomUUID());
+ private static final UUID DEATH = UUID.randomUUID();
+
+ @TempDir Path folder;
+ private ResearchTestState globals;
+ private World world;
+ private ResearchManager manager;
+ private StationEstate estate;
+ private boolean guard = true;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ world = MockBukkit.mock().addSimpleWorld("world");
+ globals = new ResearchTestState();
+ Research.plugin = mock(Research.class);
+ when(Research.plugin.getDataFolder()).thenReturn(folder.toFile());
+ when(Research.plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
+ InputLoader.get().clear();
+ OutputLoader.get().clear();
+ InputLoader.get().put("input", new InputDef("input", new YamlConfiguration()));
+ YamlConfiguration output = new YamlConfiguration();
+ output.set("aspects.fire.required_points", 10);
+ OutputLoader.get().put("output", new OutputDef("output", output));
+ manager = new ResearchManager(mock(PlayerManager.class));
+ estate = new StationEstate(manager, new StationTransfers(manager, journals()), Logger.getAnonymousLogger());
+ estate.guard = decision -> guard;
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ globals.close();
+ MockBukkit.unmock();
+ }
+
+ @Test
+ void decidedEstateGivesTheTestatorsStationsToTheRecipientOnDisk() throws Exception {
+ save(TESTATOR, 1);
+ save(TESTATOR, 2);
+ save(OTHER, 3);
+ manager.start();
+
+ Outcome outcome = estate.settle(decision(Recipient.character(HEIR), 0));
+
+ assertEquals(Outcome.done(Map.of("stations", 2), List.of("world_1_64_0", "world_2_64_0")),
+ sortedIds(outcome));
+ manager.start(); // reload from disk
+ assertOwner("world_1_64_0", HEIR);
+ assertOwner("world_2_64_0", HEIR);
+ assertOwner("world_3_64_0", OTHER);
+ assertEquals("input", manager.getStationByKey("world_1_64_0").getProject().getInputId());
+ }
+
+ @Test
+ void theManagerListsLoadedStationsReadOnly() {
+ save(TESTATOR, 1);
+ save(OTHER, 2);
+ manager.start();
+
+ assertEquals(2, manager.stations().size());
+ assertThrows(UnsupportedOperationException.class, () -> manager.stations().clear());
+ }
+
+ @Test
+ void sameAccountRecipientKeepsTheAccountAndTakesTheCharacter() throws Exception {
+ save(TESTATOR, 1);
+ manager.start();
+
+ estate.settle(decision(Recipient.character(ALT), 0));
+
+ manager.start();
+ assertOwner("world_1_64_0", ALT);
+ assertEquals(TESTATOR.ownerId(), manager.getStationByKey("world_1_64_0").getOwnerUuid());
+ }
+
+ @Test
+ void settlingAgainMovesNothingTwiceAndStillReportsEveryStation() throws Exception {
+ save(TESTATOR, 1);
+ manager.start();
+ Outcome first = estate.settle(decision(Recipient.character(HEIR), 0));
+ Path file = folder.resolve("data/stations/world_1_64_0.json");
+ String saved = Files.readString(file);
+
+ Outcome again = estate.settle(decision(Recipient.character(HEIR), 1));
+
+ assertEquals(first, again);
+ assertEquals(saved, Files.readString(file));
+ }
+
+ @Test
+ void crashAfterThePlanFinishesTheMoveAndCountsAWriteThatAlreadyLanded() throws Exception {
+ save(TESTATOR, 1);
+ save(HEIR, 2);
+ save(OTHER, 3);
+ manager.start();
+ journal(Map.of("world_1_64_0", planned(), "world_2_64_0", planned(), "world_3_64_0", planned(),
+ "world_9_64_0", planned()));
+ ResearchManager spy = spy(manager);
+ StationEstate replay = new StationEstate(spy, new StationTransfers(spy, journals()), Logger.getAnonymousLogger());
+ replay.guard = decision -> true;
+
+ Outcome outcome = replay.settle(decision(Recipient.character(HEIR), 0));
+
+ assertEquals(Outcome.done(Map.of("stations", 2), List.of("world_1_64_0", "world_2_64_0")), sortedIds(outcome));
+ verify(spy, times(1)).transferStation(any(), eq(HEIR));
+ assertOwner("world_3_64_0", OTHER);
+ String journal = Files.readString(journals().resolve(DEATH + ".json"));
+ assertFalse(journal.contains(StationTransfers.PLANNED));
+ assertTrue(journal.contains(StationTransfers.SUPERSEDED));
+ }
+
+ @Test
+ void waitingAndFrozenPropertyMovesNothing() throws Exception {
+ save(TESTATOR, 1);
+ manager.start();
+ assertEquals(Outcome.done(), estate.settle(decision(Recipient.WAITING, 0)));
+ assertEquals(Outcome.done(), estate.settle(decision(Recipient.FROZEN, 1)));
+ assertOwner("world_1_64_0", TESTATOR);
+ assertFalse(Files.exists(journals()));
+ }
+
+ @Test
+ void failingGuardMovesNothing() throws Exception {
+ save(TESTATOR, 1);
+ manager.start();
+ guard = false;
+ assertEquals(Outcome.Kind.RETRY, estate.settle(decision(Recipient.character(HEIR), 0)).kind());
+ assertOwner("world_1_64_0", TESTATOR);
+ }
+
+ @Test
+ void failedSaveKeepsTheTestatorAsOwnerAndRetries() throws Exception {
+ save(TESTATOR, 1);
+ manager.start();
+ Path file = folder.resolve("data/stations/world_1_64_0.json");
+ Files.delete(file);
+ Files.createDirectories(file.resolve("blocked"));
+
+ Outcome.Retry retry = assertInstanceOf(Outcome.Retry.class, estate.settle(decision(Recipient.character(HEIR), 0)));
+
+ assertTrue(retry.reason().contains("world_1_64_0"));
+ assertOwner("world_1_64_0", TESTATOR);
+ assertTrue(Files.readString(journals().resolve(DEATH + ".json")).contains(StationTransfers.PLANNED));
+
+ Files.delete(file.resolve("blocked"));
+ Files.delete(file);
+ assertEquals(Outcome.done(Map.of("stations", 1), List.of("world_1_64_0")),
+ estate.settle(decision(Recipient.character(HEIR), 0)));
+ }
+
+ @Test
+ void unreadableJournalRetries() throws Exception {
+ save(TESTATOR, 1);
+ manager.start();
+ Files.createDirectories(journals());
+ Files.writeString(journals().resolve(DEATH + ".json"), "{ broken");
+ assertEquals(Outcome.Kind.RETRY, estate.settle(decision(Recipient.character(HEIR), 0)).kind());
+ Files.writeString(journals().resolve(DEATH + ".json"), "{}");
+ assertEquals(Outcome.Kind.RETRY, estate.settle(decision(Recipient.character(HEIR), 0)).kind());
+ assertOwner("world_1_64_0", TESTATOR);
+ }
+
+ @Test
+ void describeListsTheCharactersStations() throws Exception {
+ save(TESTATOR, 2);
+ save(TESTATOR, 1);
+ save(OTHER, 3);
+ manager.start();
+
+ Holdings holdings = estate.describe(TESTATOR.characterId());
+
+ assertEquals(Map.of("stations", 2), holdings.counts());
+ assertEquals(List.of("Research station at world 1, 64, 0 (input)", "Research station at world 2, 64, 0 (input)"),
+ holdings.labels());
+ assertSame(Holdings.NONE, estate.describe(HEIR.characterId()));
+ }
+
+ @Test
+ void describeCopesWithAStationWithoutItsWorldOrProject() {
+ ResearchManager stations = mock(ResearchManager.class);
+ Location unloaded = mock(Location.class);
+ when(unloaded.isWorldLoaded()).thenReturn(false);
+ ResearchStation noWorld = new ResearchStation(unloaded, TESTATOR.ownerId(),
+ new ActiveProject("input", "output", "minecraft:diamond", false));
+ ResearchStation noProject = new ResearchStation(new Location(world, 4, 64, 0), TESTATOR.ownerId(), null);
+ ResearchStation nowhere = new ResearchStation(null, TESTATOR.ownerId(), null);
+ when(stations.stationsOwnedBy(TESTATOR.characterId().toString())).thenReturn(List.of(noWorld, noProject, nowhere));
+ StationEstate described = new StationEstate(stations, new StationTransfers(stations, journals()),
+ Logger.getAnonymousLogger());
+
+ assertEquals(List.of("Research station at an unloaded world", "Research station at an unloaded world (input)",
+ "Research station at world 4, 64, 0"), described.describe(TESTATOR.characterId()).labels());
+ }
+
+ @Test
+ void aJournalFromAnotherDeathIsRefused() throws Exception {
+ save(TESTATOR, 1);
+ save(OTHER, 2);
+ manager.start();
+ StationTransfers.Entry planned = planned();
+ Files.createDirectories(journals());
+ Files.writeString(journals().resolve(DEATH + ".json"), new Gson().toJson(new StationTransfers.Journal(
+ UUID.randomUUID().toString(), TESTATOR.characterId().toString(), Map.of("world_2_64_0", planned))));
+ assertEquals(Outcome.Kind.RETRY, estate.settle(decision(Recipient.character(HEIR), 0)).kind());
+ Files.writeString(journals().resolve(DEATH + ".json"), new Gson().toJson(new StationTransfers.Journal(
+ DEATH.toString(), OTHER.characterId().toString(), Map.of("world_2_64_0", planned))));
+ assertEquals(Outcome.Kind.RETRY, estate.settle(decision(Recipient.character(HEIR), 0)).kind());
+ assertOwner("world_1_64_0", TESTATOR);
+ assertOwner("world_2_64_0", OTHER);
+ }
+
+ @Test
+ void oneFailingEstateDoesNotStopTheReconcileOfTheOthers() throws Exception {
+ save(TESTATOR, 1);
+ manager.start();
+ EstateDecision broken = new EstateDecision(UUID.randomUUID(), OTHER, Optional.empty(), Instant.EPOCH,
+ Optional.empty(), Optional.empty(), Optional.empty(), Recipient.FROZEN, Recipient.FROZEN,
+ Recipient.FROZEN, Recipient.SENDER, 0);
+ estate.guard = decision -> {
+ if (decision == broken) {
+ throw new IllegalStateException("service failed");
+ }
+ return true;
+ };
+ estate.decided = () -> List.of(broken, decision(Recipient.character(HEIR), 0));
+ List reported = new ArrayList<>();
+ estate.reporter = (death, participant, revision, outcome) -> reported.add(death);
+
+ estate.reconcile();
+
+ assertEquals(List.of(DEATH), reported);
+ assertOwner("world_1_64_0", HEIR);
+ }
+
+ @Test
+ void reconcileSettlesDecidedEstatesAndReportsOnlyChanges() throws Exception {
+ save(TESTATOR, 1);
+ save(OTHER, 2);
+ manager.start();
+ EstateDecision blocked = new EstateDecision(UUID.randomUUID(), OTHER, Optional.empty(), Instant.EPOCH,
+ Optional.empty(), Optional.empty(), Optional.empty(), Recipient.FROZEN, Recipient.character(HEIR),
+ Recipient.FROZEN, Recipient.SENDER, 0);
+ estate.guard = decision -> decision != blocked;
+ estate.decided = () -> List.of(blocked, decision(Recipient.character(HEIR), 0));
+ List