diff --git a/src/main/java/net/tfminecraft/vfbuilders/data/Database.java b/src/main/java/net/tfminecraft/vfbuilders/data/Database.java index ad5e692..b085ed4 100644 --- a/src/main/java/net/tfminecraft/vfbuilders/data/Database.java +++ b/src/main/java/net/tfminecraft/vfbuilders/data/Database.java @@ -2,7 +2,10 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.google.gson.reflect.TypeToken; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -13,7 +16,6 @@ import net.tfminecraft.vfbuilders.loaders.StationLoader; import java.io.*; -import java.lang.reflect.Type; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -36,6 +38,13 @@ public class Database { // Guarded by WRITE_LOCK: the newest snapshot that has started writing. private static long newestWrite; private static ExecutorService writer; + /** + * Saved stations whose world (or building site's world) is not loaded on this server: a stations + * file copied from another server, or a world that is not loaded yet. Their JSON is kept exactly + * as read and written back with every save, and they become stations again when their worlds + * load. Turning them into stations without a world made every save fail, cancelling every build. + */ + private static List parked = new ArrayList<>(); /** The stations file, under the plugin's current data folder. */ static Path path() { @@ -131,7 +140,14 @@ private static Snapshot snapshot(Map stations) { dataList.add(data); } - return new Snapshot(snapshots.incrementAndGet(), file(), dataList); + JsonArray records = new JsonArray(); + for (StationData data : dataList) { + records.add(gson.toJsonTree(data)); + } + for (JsonObject raw : parked) { + records.add(raw.deepCopy()); + } + return new Snapshot(snapshots.incrementAndGet(), file(), records); } private static boolean write(Snapshot snapshot) { @@ -142,7 +158,7 @@ private static boolean write(Snapshot snapshot) { } } - private static boolean writeFile(File file, List dataList) { + private static boolean writeFile(File file, JsonArray records) { try { var directory = file.toPath().getParent(); Files.createDirectories(directory); @@ -150,7 +166,7 @@ private static boolean writeFile(File file, List dataList) { boolean replaced = false; try { try (FileOutputStream output = new FileOutputStream(temporary.toFile())) { - output.write(gson.toJson(dataList).getBytes(StandardCharsets.UTF_8)); + output.write(gson.toJson(records).getBytes(StandardCharsets.UTF_8)); output.getFD().sync(); } Files.move(temporary, file.toPath(), @@ -167,36 +183,35 @@ private static boolean writeFile(File file, List dataList) { } // The target is resolved on the calling thread, so the writer thread never reads plugin state. - private record Snapshot(long sequence, File target, List stations) {} + private record Snapshot(long sequence, File target, JsonArray stations) {} public static HashMap loadStations() { HashMap loaded = new HashMap<>(); + parked = new ArrayList<>(); File file = file(); if (!file.exists()) return loaded; try (Reader reader = new FileReader(file)) { - Type listType = new TypeToken>(){}.getType(); - List dataList = gson.fromJson(reader, listType); - - for (StationData data : dataList) { - Location loc = toBukkitLoc(data.location); - Station baseStation = StationLoader.getByString(data.stationId); - if (baseStation == null) continue; - - ActiveStation station = new ActiveStation( - loc, - baseStation, - data.blueprintId, - data.timeLeft, - data.spawnLocation != null ? toBukkitLoc(data.spawnLocation) : null, - toUuid(data.constructorUuid), - // Stations saved before 2.2.0 have no character; SimpleFactions resolves the heir. - toUuid(data.constructorCharacterId) - ); - station.setOutputUuid(data.outputUuid); - - loaded.put(loc, station); + JsonElement root = JsonParser.parseReader(reader); + Set unloadedWorlds = new TreeSet<>(); + for (JsonElement element : root.isJsonArray() ? root.getAsJsonArray() : new JsonArray()) { + if (!element.isJsonObject()) continue; + JsonObject raw = element.getAsJsonObject(); + StationData data = gson.fromJson(raw, StationData.class); + String missing = missingWorld(data); + if (missing != null) { + parked.add(raw); + unloadedWorlds.add(missing); + continue; + } + ActiveStation station = toStation(data); + if (station != null) loaded.put(station.getLocation(), station); + } + if (!parked.isEmpty()) { + VFBuilders.plugin.getLogger().warning("Kept " + parked.size() + + " saved station(s) whose world is not loaded here (" + String.join(", ", unloadedWorlds) + + "); they are saved back unchanged and come back when the world loads."); } } catch (IOException e) { e.printStackTrace(); @@ -205,6 +220,66 @@ public static HashMap loadStations() { return loaded; } + /** + * Kept stations whose worlds are loaded now (a world loaded after startup), as stations; they + * leave the kept list. Unknown station types are dropped, as on a normal load. + */ + public static List activateParked() { + List activated = new ArrayList<>(); + Iterator kept = parked.iterator(); + while (kept.hasNext()) { + StationData data = gson.fromJson(kept.next(), StationData.class); + if (missingWorld(data) != null) continue; + kept.remove(); + ActiveStation station = toStation(data); + if (station != null) activated.add(station); + } + return activated; + } + + /** + * Whether a kept record sits at this block. Such a block must not become a new, empty station: + * when the record comes back, its saved construction would lose to the new station. + */ + public static boolean isParkedAt(Location loc) { + if (loc == null || loc.getWorld() == null) return false; + for (JsonObject raw : parked) { + SerializableLoc at = gson.fromJson(raw, StationData.class).location; + if (at != null && loc.getWorld().getName().equals(at.world) + && loc.getBlockX() == at.x && loc.getBlockY() == at.y && loc.getBlockZ() == at.z) { + return true; + } + } + return false; + } + + /** The world a saved station needs that is not loaded (its own, then its site's), or null. */ + private static String missingWorld(StationData data) { + if (data.location == null) return "no location"; + if (Bukkit.getWorld(String.valueOf(data.location.world)) == null) return String.valueOf(data.location.world); + if (data.spawnLocation != null && Bukkit.getWorld(String.valueOf(data.spawnLocation.world)) == null) { + return String.valueOf(data.spawnLocation.world); + } + return null; + } + + private static ActiveStation toStation(StationData data) { + Station baseStation = StationLoader.getByString(data.stationId); + if (baseStation == null) return null; + ActiveStation station = new ActiveStation( + toBukkitLoc(data.location), + baseStation, + data.blueprintId, + data.timeLeft, + data.spawnLocation != null ? toBukkitLoc(data.spawnLocation) : null, + toUuid(data.constructorUuid), + // Stations saved before 2.2.0 have no character; SimpleFactions resolves the heir. + toUuid(data.constructorCharacterId) + ); + station.setOutputUuid(data.outputUuid); + return station; + } + // === Serializable Helpers === private static String toText(UUID id) { return id != null ? id.toString() : null; diff --git a/src/main/java/net/tfminecraft/vfbuilders/managers/StationManager.java b/src/main/java/net/tfminecraft/vfbuilders/managers/StationManager.java index 840ad63..fa5b157 100644 --- a/src/main/java/net/tfminecraft/vfbuilders/managers/StationManager.java +++ b/src/main/java/net/tfminecraft/vfbuilders/managers/StationManager.java @@ -60,6 +60,8 @@ public class StationManager implements Listener { public ActiveStation getStation(Block b) { if(stations.containsKey(b.getLocation())) return stations.get(b.getLocation()); + // A saved station waiting for its building site's world to load stays inert until then. + if (Database.isParkedAt(b.getLocation())) return null; BlockChecker checker = TLibs.getBlockAPI().getChecker(); for(Station station : StationLoader.get().values()) { if(checker.checkBlock(b, station.getBlock())) { @@ -89,6 +91,19 @@ public void run() { }.runTaskTimer(VFBuilders.plugin, 600L, 600L); } + /** A world loaded after startup: its kept stations become stations again, construction and all. */ + @EventHandler + public void onWorldLoad(org.bukkit.event.world.WorldLoadEvent event) { + boolean added = false; + for (ActiveStation station : Database.activateParked()) { + if (stations.putIfAbsent(station.getLocation(), station) == null) { + bindCheckpoint(station); + added = true; + } + } + if (added) refreshAllDisplays(); + } + public void stop() { for (ActiveStation station : stations.values()) { station.removeDisplay(); diff --git a/src/test/java/net/tfminecraft/vfbuilders/DatabaseTest.java b/src/test/java/net/tfminecraft/vfbuilders/DatabaseTest.java index e131758..a4a7606 100644 --- a/src/test/java/net/tfminecraft/vfbuilders/DatabaseTest.java +++ b/src/test/java/net/tfminecraft/vfbuilders/DatabaseTest.java @@ -152,6 +152,87 @@ void savesRestoresLegacyAndOwnedProjectsAndHandlesIoFailures() throws Exception } } + @Test + void stationsInWorldsNotLoadedHereAreKeptAndDoNotBreakSaves() throws Exception { + var server = MockBukkit.mock(); + Path temp = DatabaseFixture.folder(); + try { + VFBuilders.plugin = mock(VFBuilders.class); + var logger = mock(java.util.logging.Logger.class); + when(VFBuilders.plugin.getLogger()).thenReturn(logger); + when(VFBuilders.plugin.getDataFolder()).thenReturn(temp.toFile()); + Files.createDirectories(temp.resolve("data")); + var file = temp.resolve("data/stations.json"); + var world = server.addSimpleWorld("build-world"); + var loc = new Location(world, 3, 64, 5); + var definition = mock(Station.class); + when(definition.getId()).thenReturn("dock"); + StationLoader.get().clear(); + BlueprintLoader.get().clear(); + StationLoader.get().put("dock", definition); + // A file copied from a server with another world, plus an entry without a location. + Files.writeString( + file, + """ + [{"location": {"world": "other-map", "x": 408, "y": 281, "z": 1780}, "stationId": "dock", "timeLeft": 7, + "newerField": {"kept": true}}, + {"stationId": "dock", "timeLeft": 0}, + {"location": {"world": "build-world", "x": 9, "y": 64, "z": 9}, "stationId": "dock", + "spawnLocation": {"world": "site-world", "x": 0, "y": 64, "z": 0}}, + 17, + {"location": {"world": "build-world", "x": 3, "y": 64, "z": 5}, "stationId": "dock", "timeLeft": 0}] + """); + var loaded = Database.loadStations(); + assertEquals(Set.of(loc), loaded.keySet()); + // a station whose building site is in a missing world is kept too; a stray number is skipped + verify(logger).warning(contains("Kept 3 saved station(s) whose world is not loaded here (no location, other-map, site-world)")); + // Saving works (it used to throw on the world-less station) and keeps the other server's entries. + assertTrue(Database.saveStationsNow(loaded)); + String saved = Files.readString(file); + assertTrue(saved.contains("other-map")); + assertTrue(saved.contains("\"timeLeft\": 7")); + assertTrue(saved.contains("newerField")); // fields this version does not know survive + assertEquals(Set.of(loc), Database.loadStations().keySet()); + // The block of a kept station (its world loaded, its site's not) is known; other blocks are not. + assertTrue(Database.isParkedAt(new Location(world, 9, 64, 9))); + assertFalse(Database.isParkedAt(new Location(world, 9, 65, 9))); + assertFalse(Database.isParkedAt(new Location(world, 9, 64, 8))); + assertFalse(Database.isParkedAt(new Location(world, 8, 64, 9))); + assertFalse(Database.isParkedAt(new Location(server.addSimpleWorld("third"), 9, 64, 9))); + assertFalse(Database.isParkedAt(new Location(null, 9, 64, 9))); + assertFalse(Database.isParkedAt(null)); + // Worlds that load later bring their stations back; the rest stay kept. + assertTrue(Database.activateParked().isEmpty()); + server.addSimpleWorld("site-world"); + server.addSimpleWorld("other-map"); + var back = Database.activateParked(); + assertEquals(2, back.size()); // the location-less entry stays kept + assertTrue(back.stream().anyMatch(s -> s.getLocation().getWorld().getName().equals("other-map") && s.getTimeLeft() == 7)); + assertTrue(Database.activateParked().isEmpty()); + // A kept station of a type this server no longer has is dropped when its world loads, as on load. + Files.writeString(file, "[{\"location\": {\"world\": \"late-world\", \"x\": 1, \"y\": 2, \"z\": 3}, \"stationId\": \"gone\"}]"); + assertTrue(Database.loadStations().isEmpty()); + server.addSimpleWorld("late-world"); + assertTrue(Database.activateParked().isEmpty()); + assertTrue(Database.activateParked().isEmpty()); + // A file that is not a list loads nothing. + Files.writeString(file, "{}"); + assertTrue(Database.loadStations().isEmpty()); + // A file without such entries logs nothing more. + Database.saveStationsNow(Map.of(loc, new ActiveStation(loc, definition))); + clearInvocations(logger); + Files.writeString(file, "[{\"location\": {\"world\": \"build-world\", \"x\": 3, \"y\": 64, \"z\": 5}, \"stationId\": \"dock\"}]"); + assertEquals(Set.of(loc), Database.loadStations().keySet()); + verifyNoInteractions(logger); + } finally { + VFBuilders.plugin = null; + MockBukkit.unmock(); + try (var paths = Files.walk(temp)) { + for (var p : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(p); + } + } + } + @Test void constructorCharacterRoundTripsAndOlderSavesLoadWithoutOne() throws Exception { var server = MockBukkit.mock(); diff --git a/src/test/java/net/tfminecraft/vfbuilders/ManagerTest.java b/src/test/java/net/tfminecraft/vfbuilders/ManagerTest.java index d183961..04833ea 100644 --- a/src/test/java/net/tfminecraft/vfbuilders/ManagerTest.java +++ b/src/test/java/net/tfminecraft/vfbuilders/ManagerTest.java @@ -67,6 +67,26 @@ Map placements() throws Exception { return (Map) f.get(manager); } + @Test + void aKeptStationsBlockDoesNotBecomeANewStation() { + database.when(() -> Database.isParkedAt(block.getLocation())).thenReturn(true); + assertNull(manager.getStation(block)); // inert until its building site's world loads + database.when(() -> Database.isParkedAt(block.getLocation())).thenReturn(false); + assertNotNull(manager.getStation(block)); + } + + @Test + void stationsKeptForAWorldThatLoadsLaterComeBack() { + var back = new ActiveStation(block.getLocation(), definition); + var sameSpot = new ActiveStation(block.getLocation(), definition); + database.when(Database::activateParked).thenReturn(List.of(back, sameSpot), List.of()); + var event = new org.bukkit.event.world.WorldLoadEvent(player.getWorld()); + manager.onWorldLoad(event); + assertSame(back, manager.getStation(block)); // the first one at a spot wins + manager.onWorldLoad(event); // nothing left to bring back + assertSame(back, manager.getStation(block)); + } + @Test void lookupCachesOnlyMatchingStations() { assertNull(manager.getStation(player.getWorld().getBlockAt(9, 1, 1)));