From ab220cc046e5d7dc10e7db0f9a3dfd24acd9b30b Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:38:57 +0000 Subject: [PATCH 1/7] fix: save research stations atomically StationStore writes a temporary file, flushes it, moves it over the station file and flushes the folder, instead of truncating the file with a plain FileWriter. saveStation reports whether the station was saved. Co-Authored-By: Claude Opus 5.5 --- .../research/database/DurableFiles.java | 37 +++++++++++++++++++ .../research/database/StationStore.java | 14 +++---- .../database/ResearchPersistenceTest.java | 24 ++++++++++-- 3 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 src/main/java/net/tfminecraft/research/database/DurableFiles.java 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/StationStore.java b/src/main/java/net/tfminecraft/research/database/StationStore.java index f493569..9851a4c 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; } } 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")); From 2ec2d3630a584c549fe571dfa9c8b9136b920c8a Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:38:57 +0000 Subject: [PATCH 2/7] feat: pass a dead character's research stations on with the estate The research estate participant gives each station whose ownerCharacterId is the testator to the property recipient, account and character, and saves it atomically. A failed save puts the old owner back and answers RETRY. A journal per death (data/estates/.json) is written before the first station changes, so a crash is finished at the next settle and DONE always carries every moved station key. Waiting and frozen property answers DONE with nothing moved; the participant acts only while the estate guard holds. describe() lists the character's stations. It registers after stations load, only when TLibs has the estate API, reconciles on the first tick and hourly over decided estates, and reports changed outcomes with the revision answered. Pins TLibs 2.6.0. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 2 +- .../net/tfminecraft/research/Research.java | 7 + .../research/estate/EstateProbe.java | 26 ++ .../research/estate/StationEstate.java | 113 +++++++ .../research/estate/StationTransfers.java | 113 +++++++ .../research/manager/ResearchManager.java | 30 ++ .../research/ResearchLifecycleTest.java | 18 + .../research/estate/StationEstateTest.java | 320 ++++++++++++++++++ 8 files changed, 628 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/tfminecraft/research/estate/EstateProbe.java create mode 100644 src/main/java/net/tfminecraft/research/estate/StationEstate.java create mode 100644 src/main/java/net/tfminecraft/research/estate/StationTransfers.java create mode 100644 src/test/java/net/tfminecraft/research/estate/StationEstateTest.java 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..52802f5 100644 --- a/src/main/java/net/tfminecraft/research/Research.java +++ b/src/main/java/net/tfminecraft/research/Research.java @@ -5,6 +5,8 @@ import org.bukkit.plugin.java.JavaPlugin; import net.tfminecraft.research.command.CommandManager; +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 +53,11 @@ public void onEnable() { getCommand("research").setTabCompleter(commandManager); getServer().getPluginManager().registerEvents(researchManager, this); researchManager.start(); + 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/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..7684128 --- /dev/null +++ b/src/main/java/net/tfminecraft/research/estate/StationEstate.java @@ -0,0 +1,113 @@ +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()) { + 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); + } + } + } + + private static String label(ResearchStation station) { + Location location = station.getLocation(); + return "Research station at " + location.getWorld().getName() + " " + location.getBlockX() + ", " + + location.getBlockY() + ", " + location.getBlockZ() + " (" + 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..9615103 --- /dev/null +++ b/src/main/java/net/tfminecraft/research/estate/StationTransfers.java @@ -0,0 +1,113 @@ +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); + } + 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..fc9856a 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,35 @@ public void unloadAll() { stations.clear(); } + /** 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..734999e 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,22 @@ 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()); + } + @Test void tlibsWithoutEstatesStillEnablesResearchWithoutTheParticipant() { + try (MockedStatic probe = mockStatic(EstateProbe.class)) { + probe.when(() -> EstateProbe.available((String[]) any())).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/estate/StationEstateTest.java b/src/test/java/net/tfminecraft/research/estate/StationEstateTest.java new file mode 100644 index 0000000..a20fb6f --- /dev/null +++ b/src/test/java/net/tfminecraft/research/estate/StationEstateTest.java @@ -0,0 +1,320 @@ +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 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 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"); + } +} From 26557cac8d37507cefe1875b3fced2c5cb0fe539 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:39:04 +0000 Subject: [PATCH 3/7] docs: mention estates in the README Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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. From 38c00c639006e4564d5b9f82446f8cf296009b12 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:51:48 +0000 Subject: [PATCH 4/7] test: stub the estate probe for any class list Co-Authored-By: Claude Opus 5.5 --- .../java/net/tfminecraft/research/ResearchLifecycleTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/net/tfminecraft/research/ResearchLifecycleTest.java b/src/test/java/net/tfminecraft/research/ResearchLifecycleTest.java index 734999e..7f4f547 100644 --- a/src/test/java/net/tfminecraft/research/ResearchLifecycleTest.java +++ b/src/test/java/net/tfminecraft/research/ResearchLifecycleTest.java @@ -59,7 +59,7 @@ class ResearchLifecycleTest { } @Test void tlibsWithoutEstatesStillEnablesResearchWithoutTheParticipant() { try (MockedStatic probe = mockStatic(EstateProbe.class)) { - probe.when(() -> EstateProbe.available((String[]) any())).thenReturn(false); + probe.when(() -> EstateProbe.available(any(String[].class))).thenReturn(false); Research plugin = MockBukkit.load(Research.class); assertTrue(plugin.isEnabled()); verify(plugin.getResearchManager()).start(); From a6d36838c1cf95a852a8430181958055eea7399d Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:34:39 +0000 Subject: [PATCH 5/7] fix: refuse foreign estate journals and keep describe and reconcile safe A journal whose death or testator does not match the estate is refused (RETRY), so another death's stations cannot join this one. describe() no longer throws for a station whose world is unloaded or whose project is missing, and one failing estate no longer stops the hourly reconcile of the others. Co-Authored-By: Claude Opus 5.5 --- .../research/estate/StationEstate.java | 30 ++++++---- .../research/estate/StationTransfers.java | 4 ++ .../research/estate/StationEstateTest.java | 57 +++++++++++++++++++ 3 files changed, 81 insertions(+), 10 deletions(-) diff --git a/src/main/java/net/tfminecraft/research/estate/StationEstate.java b/src/main/java/net/tfminecraft/research/estate/StationEstate.java index 7684128..610edd9 100644 --- a/src/main/java/net/tfminecraft/research/estate/StationEstate.java +++ b/src/main/java/net/tfminecraft/research/estate/StationEstate.java @@ -93,21 +93,31 @@ public Holdings describe(UUID characterId) { /** Settles every decided estate whose guard holds and reports outcomes that changed. */ void reconcile() { for (EstateDecision decision : decided.get()) { - 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); + // 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(); - return "Research station at " + location.getWorld().getName() + " " + location.getBlockX() + ", " - + location.getBlockY() + ", " + location.getBlockZ() + " (" + station.getProject().getInputId() + ")"; + 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 index 9615103..605c257 100644 --- a/src/main/java/net/tfminecraft/research/estate/StationTransfers.java +++ b/src/main/java/net/tfminecraft/research/estate/StationTransfers.java @@ -97,6 +97,10 @@ private Journal read(UUID deathId, UUID testator) throws IOException { 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); diff --git a/src/test/java/net/tfminecraft/research/estate/StationEstateTest.java b/src/test/java/net/tfminecraft/research/estate/StationEstateTest.java index a20fb6f..6990bc8 100644 --- a/src/test/java/net/tfminecraft/research/estate/StationEstateTest.java +++ b/src/test/java/net/tfminecraft/research/estate/StationEstateTest.java @@ -217,6 +217,63 @@ void describeListsTheCharactersStations() throws Exception { 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); From 43ee73cb33cf42e61d384fd182789f8e97931039 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:11:35 +0000 Subject: [PATCH 6/7] feat: give stations without a character to the account's reviewed heir A one-time startup backfill, as 6a did for locks and vehicles. Once TLibs' reviewed heirs are ready, every station with no recorded character is planned to its account's legacy heir (plan.json, written before any station changes and reused after a crash), its original file is kept in backup/, and it is saved atomically through StationStore. Accounts without a heir keep their stations unassigned, as before, and are logged for staff. done.json records the counts and unresolved stations; later boots do nothing. If heirs are not ready, or a station cannot be saved, the backfill tries again at the next boot. Co-Authored-By: Claude Opus 5.5 --- .../net/tfminecraft/research/Research.java | 2 + .../research/database/StationBackfill.java | 187 +++++++++++++ .../research/database/StationStore.java | 3 +- .../research/manager/ResearchManager.java | 5 + .../research/ResearchLifecycleTest.java | 4 + .../database/StationBackfillTest.java | 262 ++++++++++++++++++ .../research/estate/StationEstateTest.java | 10 + 7 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/tfminecraft/research/database/StationBackfill.java create mode 100644 src/test/java/net/tfminecraft/research/database/StationBackfillTest.java diff --git a/src/main/java/net/tfminecraft/research/Research.java b/src/main/java/net/tfminecraft/research/Research.java index 52802f5..aec7899 100644 --- a/src/main/java/net/tfminecraft/research/Research.java +++ b/src/main/java/net/tfminecraft/research/Research.java @@ -5,6 +5,7 @@ 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; @@ -53,6 +54,7 @@ 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 { 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..8d50df8 --- /dev/null +++ b/src/main/java/net/tfminecraft/research/database/StationBackfill.java @@ -0,0 +1,187 @@ +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.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 Plan(Map stations) { + } + + record Result(int filled, List unresolved, String completedAt) { + } + + 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 { + Map plan = plan(stations); + int failed = 0; + for (Map.Entry entry : plan.entrySet()) { + ResearchStation station = find(stations, entry.getKey()); + if (entry.getValue() == null || station == null || station.getOwnerCharacterId() != null) { + continue; + } + if (!fill(station, entry.getKey(), entry.getValue())) { + failed++; + } + } + if (failed > 0) { + logger.severe("[Research] " + failed + " station(s) could not be given to their heir; retrying next boot."); + return false; + } + List unresolved = new ArrayList<>(); + int filled = 0; + for (Map.Entry entry : plan.entrySet()) { + ResearchStation station = find(stations, entry.getKey()); + if (entry.getValue() == null) { + unresolved.add(entry.getKey()); + } else if (station != null && entry.getValue().equalsIgnoreCase(station.getOwnerCharacterId())) { + filled++; + } + } + if (!unresolved.isEmpty()) { + logger.warning("[Research] Stations whose account has no reviewed heir stay unassigned: " + + String.join(", ", unresolved)); + } + DurableFiles.write(marker, JSON.toJson(new Result(filled, unresolved, Instant.now().toString()))); + logger.info("[Research] Gave " + filled + " station(s) to their account's heir; " + unresolved.size() + + " without a heir."); + 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 Map plan(List stations) 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) { + throw new IOException("Incomplete backfill plan " + file); + } + return plan.stations(); + } catch (JsonParseException ex) { + throw new IOException("Unreadable backfill plan " + file, ex); + } + } + Map plan = new LinkedHashMap<>(); + for (ResearchStation station : stations) { + if (station.getOwnerCharacterId() != null) { + continue; + } + UUID account = station.getOwnerUuid(); + // An heir on another account would hand the station to someone else's character. + plan.put(ResearchStation.locationKey(station.getLocation()), heirs.apply(account) + .filter(heir -> heir.ownerId().equals(account)) + .map(heir -> heir.characterId().toString()) + .orElse(null)); + } + DurableFiles.write(file, JSON.toJson(new Plan(plan))); + return plan; + } + + private boolean fill(ResearchStation station, String key, String character) { + try { + Path original = StationStore.fileFor(station.getLocation()).toPath(); + Path backup = folder.resolve("backup").resolve(key + ".json"); + Files.createDirectories(backup.getParent()); + if (Files.exists(original) && !Files.exists(backup)) { + Files.copy(original, backup); + } + } catch (IOException ex) { + logger.severe("[Research] Could not back up station " + key + ": " + ex.getMessage()); + return false; + } + station.setOwnerCharacterId(character); + if (StationStore.saveStation(station)) { + return true; + } + station.setOwnerCharacterId(null); + return false; + } + + 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 9851a4c..f88f1bb 100644 --- a/src/main/java/net/tfminecraft/research/database/StationStore.java +++ b/src/main/java/net/tfminecraft/research/database/StationStore.java @@ -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/manager/ResearchManager.java b/src/main/java/net/tfminecraft/research/manager/ResearchManager.java index fc9856a..c0eaa46 100644 --- a/src/main/java/net/tfminecraft/research/manager/ResearchManager.java +++ b/src/main/java/net/tfminecraft/research/manager/ResearchManager.java @@ -93,6 +93,11 @@ 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(); diff --git a/src/test/java/net/tfminecraft/research/ResearchLifecycleTest.java b/src/test/java/net/tfminecraft/research/ResearchLifecycleTest.java index 7f4f547..5d7d9ae 100644 --- a/src/test/java/net/tfminecraft/research/ResearchLifecycleTest.java +++ b/src/test/java/net/tfminecraft/research/ResearchLifecycleTest.java @@ -56,6 +56,10 @@ class ResearchLifecycleTest { 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)) { 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..9208e78 --- /dev/null +++ b/src/test/java/net/tfminecraft/research/database/StationBackfillTest.java @@ -0,0 +1,262 @@ +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 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(); + + @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 stationsWithoutACharacterGoToTheAccountsHeirWithABackup() throws Exception { + save(ACCOUNT, null, 1); + save(ACCOUNT, "kept", 2); + String original = Files.readString(stationFile(1)); + + assertTrue(backfill().run(StationStore.loadAll())); + + assertEquals(HEIR.characterId().toString(), loaded(1).getOwnerCharacterId()); + assertEquals("kept", 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); + + assertTrue(backfill().run(StationStore.loadAll())); + + 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", HEIR.characterId().toString()); + planned.put("world_2_64_0", HEIR.characterId().toString()); + planned.put("world_3_64_0", null); + Files.createDirectories(backfillFolder().resolve("backup")); + Files.writeString(backfillFolder().resolve("plan.json"), new com.google.gson.GsonBuilder().serializeNulls().create().toJson(new StationBackfill.Plan(planned))); + Files.writeString(backfillFolder().resolve("backup/world_1_64_0.json"), "original of station 1"); + save(ACCOUNT, HEIR.characterId().toString(), 1); + + assertTrue(backfill().run(StationStore.loadAll())); + + assertEquals(0, heirLookups); + assertEquals(HEIR.characterId().toString(), loaded(2).getOwnerCharacterId()); + assertNull(loaded(3).getOwnerCharacterId()); + assertEquals("original of station 1", 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); + Files.delete(file); + Files.createDirectories(file.resolve("blocked")); + + assertFalse(backfill().run(stations)); + + assertNull(stations.getFirst().getOwnerCharacterId()); + assertFalse(Files.exists(backfillFolder().resolve("done.json"))); + Files.delete(file.resolve("blocked")); + Files.delete(file); + 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(contains("Could not back up station world_1_64_0")); + } + + @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-by-another", 1); + Files.createDirectories(backfillFolder()); + Files.writeString(backfillFolder().resolve("plan.json"), new Gson().toJson(new StationBackfill.Plan( + Map.of("world_1_64_0", HEIR.characterId().toString(), "world_9_64_0", HEIR.characterId().toString())))); + + assertTrue(backfill().run(StationStore.loadAll())); + + assertEquals("claimed-by-another", loaded(1).getOwnerCharacterId()); + assertEquals(0, result().filled()); + } + + @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.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 index 6990bc8..9ecec45 100644 --- a/src/test/java/net/tfminecraft/research/estate/StationEstateTest.java +++ b/src/test/java/net/tfminecraft/research/estate/StationEstateTest.java @@ -103,6 +103,16 @@ void decidedEstateGivesTheTestatorsStationsToTheRecipientOnDisk() throws Excepti 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); From 44a35f10f9f7c3a4f075504d7cb049f8390b6f32 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:38:18 +0000 Subject: [PATCH 7/7] fix: backfill persisted station ownership independently of loading --- .../research/database/StationBackfill.java | 200 +++++++++++---- .../database/StationBackfillTest.java | 242 ++++++++++++++++-- 2 files changed, 364 insertions(+), 78 deletions(-) diff --git a/src/main/java/net/tfminecraft/research/database/StationBackfill.java b/src/main/java/net/tfminecraft/research/database/StationBackfill.java index 8d50df8..f5d0516 100644 --- a/src/main/java/net/tfminecraft/research/database/StationBackfill.java +++ b/src/main/java/net/tfminecraft/research/database/StationBackfill.java @@ -17,6 +17,8 @@ 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; @@ -33,10 +35,16 @@ public final class StationBackfill { static final String FOLDER = "data/backfill-character-v1"; - record Plan(Map stations) { + record Destination(UUID ownerUuid, String characterId) { } - record Result(int filled, List unresolved, String completedAt) { + 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(); @@ -87,38 +95,60 @@ public boolean run(List stations) { return false; } try { - Map plan = plan(stations); - int failed = 0; - for (Map.Entry entry : plan.entrySet()) { - ResearchStation station = find(stations, entry.getKey()); - if (entry.getValue() == null || station == null || station.getOwnerCharacterId() != null) { + 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; } - if (!fill(station, entry.getKey(), entry.getValue())) { - failed++; + Metadata metadata; + try { + metadata = read(file, key); + } catch (IOException | RuntimeException ex) { + throw new IOException("Could not read planned station " + key, ex); } - } - if (failed > 0) { - logger.severe("[Research] " + failed + " station(s) could not be given to their heir; retrying next boot."); - return false; - } - List unresolved = new ArrayList<>(); - int filled = 0; - for (Map.Entry entry : plan.entrySet()) { - ResearchStation station = find(stations, entry.getKey()); - if (entry.getValue() == null) { - unresolved.add(entry.getKey()); - } else if (station != null && entry.getValue().equalsIgnoreCase(station.getOwnerCharacterId())) { - filled++; + 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)); } - DurableFiles.write(marker, JSON.toJson(new Result(filled, unresolved, Instant.now().toString()))); + 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."); + + " 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); @@ -127,53 +157,115 @@ public boolean run(List stations) { } // The plan from an interrupted run is reused, so a crash never changes who a station goes to. - private Map plan(List stations) throws IOException { + 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) { + if (plan == null || plan.stations() == null || plan.skipped() == null) { throw new IOException("Incomplete backfill plan " + file); } - return plan.stations(); + 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 plan = new LinkedHashMap<>(); - for (ResearchStation station : stations) { - if (station.getOwnerCharacterId() != null) { - continue; + 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))); + } } - UUID account = station.getOwnerUuid(); - // An heir on another account would hand the station to someone else's character. - plan.put(ResearchStation.locationKey(station.getLocation()), heirs.apply(account) - .filter(heir -> heir.ownerId().equals(account)) - .map(heir -> heir.characterId().toString()) - .orElse(null)); } - DurableFiles.write(file, JSON.toJson(new Plan(plan))); + Plan plan = new Plan(stations, skipped); + DurableFiles.write(file, JSON.toJson(plan)); return plan; } - private boolean fill(ResearchStation station, String key, String character) { - try { - Path original = StationStore.fileFor(station.getLocation()).toPath(); - Path backup = folder.resolve("backup").resolve(key + ".json"); - Files.createDirectories(backup.getParent()); - if (Files.exists(original) && !Files.exists(backup)) { - Files.copy(original, backup); - } - } catch (IOException ex) { - logger.severe("[Research] Could not back up station " + key + ": " + ex.getMessage()); - return false; + 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()); } - station.setOwnerCharacterId(character); - if (StationStore.saveStation(station)) { - return true; + 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"); } - station.setOwnerCharacterId(null); - return false; + return uuid; } private static ResearchStation find(List stations, String key) { diff --git a/src/test/java/net/tfminecraft/research/database/StationBackfillTest.java b/src/test/java/net/tfminecraft/research/database/StationBackfillTest.java index 9208e78..aad91c6 100644 --- a/src/test/java/net/tfminecraft/research/database/StationBackfillTest.java +++ b/src/test/java/net/tfminecraft/research/database/StationBackfillTest.java @@ -23,6 +23,8 @@ 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; @@ -40,6 +42,7 @@ class StationBackfillTest { 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; @@ -75,15 +78,47 @@ void tearDown() throws Exception { } @Test - void stationsWithoutACharacterGoToTheAccountsHeirWithABackup() throws Exception { + void unloadedStationsGetTheirHeirWithoutChangingRecipeOrExtensionFields() throws Exception { save(ACCOUNT, null, 1); - save(ACCOUNT, "kept", 2); + 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("kept", loaded(2).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(); @@ -96,7 +131,8 @@ void stationsWhoseAccountHasNoHeirStayUnassignedAndAreListed() throws Exception save(NO_HEIR, null, 1); save(STRANGER, null, 2); - assertTrue(backfill().run(StationStore.loadAll())); + List stations = StationStore.loadAll(); + assertTrue(backfill().run(stations)); assertNull(loaded(1).getOwnerCharacterId()); assertNull(loaded(2).getOwnerCharacterId()); @@ -142,21 +178,26 @@ void aCrashBeforeTheMarkerFinishesFromTheSamePlan() throws Exception { 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", HEIR.characterId().toString()); - planned.put("world_2_64_0", HEIR.characterId().toString()); - planned.put("world_3_64_0", null); + 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")); - Files.writeString(backfillFolder().resolve("plan.json"), new com.google.gson.GsonBuilder().serializeNulls().create().toJson(new StationBackfill.Plan(planned))); - Files.writeString(backfillFolder().resolve("backup/world_1_64_0.json"), "original of station 1"); + 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(StationStore.loadAll())); + 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 of station 1", Files.readString(backfillFolder().resolve("backup/world_1_64_0.json"))); + 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()); } @@ -166,15 +207,16 @@ void aFailedSaveLeavesTheStationUnassignedAndWritesNoMarker() throws Exception { save(ACCOUNT, null, 1); List stations = StationStore.loadAll(); Path file = stationFile(1); - Files.delete(file); - Files.createDirectories(file.resolve("blocked")); - - assertFalse(backfill().run(stations)); + 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"))); - Files.delete(file.resolve("blocked")); - Files.delete(file); assertTrue(backfill().run(stations)); assertEquals(HEIR.characterId().toString(), stations.getFirst().getOwnerCharacterId()); } @@ -188,7 +230,7 @@ void aStationThatCannotBeBackedUpIsLeftAlone() throws Exception { assertFalse(backfill().run(StationStore.loadAll())); assertNull(loaded(1).getOwnerCharacterId()); - verify(logger).severe(contains("Could not back up station world_1_64_0")); + verify(logger).severe(startsWith("[Research] Station heir backfill failed")); } @Test @@ -205,15 +247,157 @@ void anUnreadablePlanStopsTheBackfill() throws Exception { @Test void plannedStationsThatAreGoneOrClaimedAreNotCounted() throws Exception { - save(ACCOUNT, "claimed-by-another", 1); - Files.createDirectories(backfillFolder()); - Files.writeString(backfillFolder().resolve("plan.json"), new Gson().toJson(new StationBackfill.Plan( - Map.of("world_1_64_0", HEIR.characterId().toString(), "world_9_64_0", HEIR.characterId().toString())))); + 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-by-another", loaded(1).getOwnerCharacterId()); + 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 @@ -236,6 +420,16 @@ private StationBackfill backfill() { 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); }