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 reports = new ArrayList<>(); + estate.reporter = (death, participant, revision, outcome) -> reports.add(new Object[] {death, participant, revision, outcome}); + + estate.reconcile(); + estate.reconcile(); + + assertEquals(1, reports.size()); + assertArrayEquals(new Object[] {DEATH, "research", 0L, + Outcome.done(Map.of("stations", 1), List.of("world_1_64_0"))}, reports.get(0)); + assertOwner("world_2_64_0", OTHER); + + estate.decided = () -> List.of(decision(Recipient.character(HEIR), 1)); + estate.reconcile(); + assertEquals(2, reports.size()); + } + + @Test + void reconcileReportsAgainWhenNoServiceTookTheReport() { + manager.start(); + estate.decided = () -> List.of(decision(Recipient.FROZEN, 0)); + List reports = new ArrayList<>(); + estate.reporter = (death, participant, revision, outcome) -> reports.add(outcome) && false; + + estate.reconcile(); + estate.reconcile(); + + assertEquals(List.of(Outcome.done(), Outcome.done()), reports); + } + + @Test + void startRegistersTheParticipantAndReconcilesOnTheFirstTickThenHourly() { + Plugin plugin = mock(Plugin.class); + Server server = mock(Server.class); + ServicesManager services = mock(ServicesManager.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getServer()).thenReturn(server); + when(plugin.getDataFolder()).thenReturn(folder.toFile()); + when(server.getServicesManager()).thenReturn(services); + when(server.getScheduler()).thenReturn(scheduler); + + StationEstate.start(plugin, manager); + + var participant = org.mockito.ArgumentCaptor.forClass(EstateParticipant.class); + verify(services).register(eq(EstateParticipant.class), participant.capture(), eq(plugin), eq(ServicePriority.Normal)); + assertEquals("research", participant.getValue().id()); + verify(scheduler).runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(72_000L)); + } + + @Test + void probeFindsTheEstateApiAndMissesAbsentClasses() { + assertTrue(EstateProbe.available(EstateProbe.CLASSES)); + assertFalse(EstateProbe.available("net.tfminecraft.tlibs.character.estate.Missing")); + } + + private static Outcome sortedIds(Outcome outcome) { + Outcome.Done done = assertInstanceOf(Outcome.Done.class, outcome); + return Outcome.done(done.counts(), done.movedIds().stream().sorted().toList()); + } + + private EstateDecision decision(Recipient property, long revision) { + return new EstateDecision(DEATH, TESTATOR, Optional.of(Instant.EPOCH), Instant.EPOCH, Optional.empty(), + Optional.empty(), Optional.empty(), Recipient.FROZEN, property, Recipient.FROZEN, Recipient.SENDER, revision); + } + + private void save(CharacterRef owner, int x) { + ResearchStation station = new ResearchStation(new Location(world, x, 64, 0), owner.ownerId(), + new ActiveProject("input", "output", "minecraft:diamond", false)); + station.setOwnerCharacterId(owner.characterId().toString()); + assertTrue(StationStore.saveStation(station)); + } + + private void assertOwner(String key, CharacterRef owner) { + ResearchStation station = manager.getStationByKey(key); + assertEquals(owner.ownerId(), station.getOwnerUuid()); + assertEquals(owner.characterId().toString(), station.getOwnerCharacterId()); + } + + private StationTransfers.Entry planned() { + return new StationTransfers.Entry(HEIR.ownerId().toString(), HEIR.characterId().toString(), StationTransfers.PLANNED); + } + + private void journal(Map stations) throws Exception { + Files.createDirectories(journals()); + Files.writeString(journals().resolve(DEATH + ".json"), new Gson().toJson( + new StationTransfers.Journal(DEATH.toString(), TESTATOR.characterId().toString(), stations))); + } + + private Path journals() { + return folder.resolve("data/estates"); + } +}