diff --git a/src/main/java/net/tfminecraft/vfbuilders/core/ActiveStation.java b/src/main/java/net/tfminecraft/vfbuilders/core/ActiveStation.java index c36ef02..a798bf8 100644 --- a/src/main/java/net/tfminecraft/vfbuilders/core/ActiveStation.java +++ b/src/main/java/net/tfminecraft/vfbuilders/core/ActiveStation.java @@ -1,6 +1,7 @@ package net.tfminecraft.vfbuilders.core; import java.util.UUID; +import java.util.function.BooleanSupplier; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -9,6 +10,7 @@ import org.bukkit.entity.TextDisplay; import net.tfminecraft.vfbuilders.api.ConstructionFreezes; +import net.tfminecraft.vfbuilders.VFBuilders; import net.tfminecraft.vfbuilders.display.StationTimerDisplay; import net.tfminecraft.vfbuilders.events.VehicleConstructEvent; import net.tfminecraft.vfbuilders.events.VehicleConstructionCancelEvent; @@ -16,6 +18,8 @@ import net.tfminecraft.vfbuilders.loaders.StationLoader; import net.tfminecraft.vehicleframework.VFLogger; import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.database.VehiclePersistence; +import net.tfminecraft.vehicleframework.enums.VehicleRemoveReason; import net.tfminecraft.vehicleframework.managers.VehicleManager; import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; @@ -29,6 +33,9 @@ public class ActiveStation { private Location spawnLoc; private UUID constructorUuid; private UUID constructorCharacterId; + private String outputUuid; + private boolean constructionEventSent; + private BooleanSupplier checkpoint = () -> true; private TextDisplay timerDisplay; private String freezeReason; @@ -93,6 +100,8 @@ public void selectBlueprint(Blueprint b, UUID constructorUuid, UUID constructorC timeLeft = b.getTime(); this.constructorUuid = constructorUuid; this.constructorCharacterId = constructorCharacterId; + outputUuid = null; + constructionEventSent = false; freezeReason = null; ensureDisplay(); } @@ -135,16 +144,46 @@ public void complete() { } VehicleManager manager = VehicleFramework.getVehicleManager(); - ActiveVehicle vehicle = manager.spawn(spawnLoc, blueprint.getVehicle()); + VehiclePersistence persistence = VehiclePersistence.current(); + if (persistence == null) { + VFLogger.log("Cannot complete construction: SQLite is not open at " + loc); + return; + } + if (outputUuid != null && persistence.repository().find(outputUuid).isPresent()) { + VFLogger.info(VFBuilders.plugin, "Construction output already stored: station=" + loc + + " vehicle=" + outputUuid); + clearConstructionDurably(); + return; + } + ActiveVehicle vehicle = outputUuid == null ? null : manager.getByUUID(outputUuid); + if (vehicle == null) { + vehicle = manager.spawn(spawnLoc, blueprint.getVehicle()); + if (vehicle != null) { + outputUuid = vehicle.getUUID(); + constructionEventSent = false; + if (!checkpoint.getAsBoolean()) { + vehicle.remove(VehicleRemoveReason.UNLOAD); + outputUuid = null; + return; + } + } + } if (vehicle == null) { VFLogger.log("Failed to spawn vehicle for blueprint " + blueprint.getId() + " at station " + loc); return; } Location completedSpawn = spawnLoc.clone(); - Bukkit.getPluginManager().callEvent( - new VehicleConstructEvent( - constructorUuid, constructorCharacterId, vehicle, blueprint, completedSpawn, this)); + if (!constructionEventSent) { + constructionEventSent = true; + Bukkit.getPluginManager().callEvent( + new VehicleConstructEvent( + constructorUuid, constructorCharacterId, vehicle, blueprint, completedSpawn, this)); + } + if (!persistence.saveLive(vehicle)) { + VFLogger.log("Cannot save constructed vehicle " + outputUuid + " at " + loc); + return; + } // Visual effects Location center = completedSpawn.clone().add(0.5, 1, 0.5); @@ -156,26 +195,66 @@ public void complete() { center.getWorld().playSound(center, Sound.ENTITY_IRON_GOLEM_REPAIR, 1f, 1.2f); center.getWorld().playSound(center, Sound.BLOCK_BEACON_ACTIVATE, 0.8f, 1.6f); - spawnLoc = null; - blueprint = null; - constructorUuid = null; - constructorCharacterId = null; + clearConstructionDurably(); } public void cancelConstruction() { - if (blueprint != null) { - blueprint.drop(loc.clone().add(0.5, 1, 0.5)); + if (outputUuid != null) { + VehiclePersistence persistence = VehiclePersistence.current(); + if (persistence != null && persistence.repository().find(outputUuid).isPresent()) { + if (clearConstructionDurably()) { + removeHolograms(); + freezeReason = null; + timeLeft = 0; + } + } + return; + } + Blueprint cancelled = blueprint; + UUID account = constructorUuid; + UUID character = constructorCharacterId; + if (!clearConstructionDurably()) return; + if (cancelled != null) { + cancelled.drop(loc.clone().add(0.5, 1, 0.5)); Bukkit.getPluginManager().callEvent( new VehicleConstructionCancelEvent( - constructorUuid, constructorCharacterId, blueprint, this)); + account, character, cancelled, this)); } removeHolograms(); - spawnLoc = null; + freezeReason = null; + timeLeft = 0; + } + + private boolean clearConstructionDurably() { + Blueprint previousBlueprint = blueprint; + Location previousSpawn = spawnLoc; + UUID previousAccount = constructorUuid; + UUID previousCharacter = constructorCharacterId; + String previousOutput = outputUuid; blueprint = null; + spawnLoc = null; constructorUuid = null; constructorCharacterId = null; - freezeReason = null; - timeLeft = 0; + outputUuid = null; + if (checkpoint.getAsBoolean()) return true; + blueprint = previousBlueprint; + spawnLoc = previousSpawn; + constructorUuid = previousAccount; + constructorCharacterId = previousCharacter; + outputUuid = previousOutput; + return false; + } + + public void setCheckpoint(BooleanSupplier checkpoint) { + this.checkpoint = checkpoint; + } + + public String getOutputUuid() { + return outputUuid; + } + + public void setOutputUuid(String outputUuid) { + this.outputUuid = outputUuid; } public UUID getUuid() { diff --git a/src/main/java/net/tfminecraft/vfbuilders/data/Database.java b/src/main/java/net/tfminecraft/vfbuilders/data/Database.java index aa7b7ce..43afe2b 100644 --- a/src/main/java/net/tfminecraft/vfbuilders/data/Database.java +++ b/src/main/java/net/tfminecraft/vfbuilders/data/Database.java @@ -14,6 +14,9 @@ import java.io.*; import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.util.*; public class Database { @@ -22,28 +25,48 @@ public class Database { private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); public static void saveStations(Map stations) { - List dataList = new ArrayList<>(); - - for (ActiveStation station : stations.values()) { - station.removeHolograms(); - StationData data = new StationData(); - data.location = toSerializableLoc(station.getLocation()); - data.stationId = station.getStation().getId(); // Save station identifier - data.spawnLocation = station.hasSpawnLocation() ? toSerializableLoc(station.getSpawnLocation()) : null; - data.blueprintId = station.hasBlueprint() ? station.getBlueprint().getId() : null; - data.timeLeft = station.getTimeLeft(); - if (station.hasBlueprint()) { - data.constructorUuid = toText(station.getConstructorUuid()); - data.constructorCharacterId = toText(station.getConstructorCharacterId()); - } + saveStationsNow(stations); + } - dataList.add(data); - } + public static boolean saveStationsNow(Map stations) { + try { + List dataList = new ArrayList<>(); + + for (ActiveStation station : stations.values()) { + StationData data = new StationData(); + data.location = toSerializableLoc(station.getLocation()); + data.stationId = station.getStation().getId(); // Save station identifier + data.spawnLocation = station.hasSpawnLocation() ? toSerializableLoc(station.getSpawnLocation()) : null; + data.blueprintId = station.hasBlueprint() ? station.getBlueprint().getId() : null; + data.timeLeft = station.getTimeLeft(); + data.outputUuid = station.getOutputUuid(); + if (station.hasBlueprint()) { + data.constructorUuid = toText(station.getConstructorUuid()); + data.constructorCharacterId = toText(station.getConstructorCharacterId()); + } + + dataList.add(data); + } - try (Writer writer = new FileWriter(file)) { - gson.toJson(dataList, writer); - } catch (IOException e) { + var directory = file.toPath().getParent(); + Files.createDirectories(directory); + var temporary = Files.createTempFile(directory, "stations-", ".tmp"); + boolean replaced = false; + try { + try (FileOutputStream output = new FileOutputStream(temporary.toFile())) { + output.write(gson.toJson(dataList).getBytes(StandardCharsets.UTF_8)); + output.getFD().sync(); + } + Files.move(temporary, file.toPath(), + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + replaced = true; + } finally { + if (!replaced) Files.deleteIfExists(temporary); + } + return true; + } catch (IOException | RuntimeException e) { e.printStackTrace(); + return false; } } @@ -71,6 +94,7 @@ public static HashMap loadStations() { // Stations saved before 2.2.0 have no character; SimpleFactions resolves the heir. toUuid(data.constructorCharacterId) ); + station.setOutputUuid(data.outputUuid); loaded.put(loc, station); } @@ -125,6 +149,7 @@ private static class StationData { int timeLeft; String constructorUuid; String constructorCharacterId; + String outputUuid; } } diff --git a/src/main/java/net/tfminecraft/vfbuilders/managers/StationManager.java b/src/main/java/net/tfminecraft/vfbuilders/managers/StationManager.java index 77a7027..6f1a0c9 100644 --- a/src/main/java/net/tfminecraft/vfbuilders/managers/StationManager.java +++ b/src/main/java/net/tfminecraft/vfbuilders/managers/StationManager.java @@ -50,6 +50,7 @@ import net.tfminecraft.vfbuilders.loaders.BlueprintLoader; import net.tfminecraft.vfbuilders.loaders.CategoryLoader; import net.tfminecraft.vfbuilders.loaders.StationLoader; +import net.tfminecraft.vehicleframework.VFLogger; public class StationManager implements Listener { private HashMap stations = new HashMap<>(); @@ -64,6 +65,7 @@ public ActiveStation getStation(Block b) { if(checker.checkBlock(b, station.getBlock())) { ActiveStation a = new ActiveStation(b.getLocation(), station); stations.put(b.getLocation(), a); + bindCheckpoint(a); return a; } } @@ -72,9 +74,18 @@ public ActiveStation getStation(Block b) { public void start() { stations = Database.loadStations(); + stations.values().forEach(this::bindCheckpoint); refreshAllDisplays(); startStationTrailLoop(); tickCycle(); + new BukkitRunnable() { + @Override + public void run() { + if (stations.values().stream().anyMatch(ActiveStation::hasBlueprint)) { + Database.saveStationsNow(stations); + } + } + }.runTaskTimer(VFBuilders.plugin, 600L, 600L); } public void stop() { @@ -89,6 +100,7 @@ public void rebindAfterReload(Runnable reloadDefinitions) { for (ActiveStation station : stations.values()) { station.rebindDefinitions(); } + Database.saveStationsNow(stations); refreshAllDisplays(); } @@ -104,6 +116,10 @@ public void run() { }.runTaskTimer(VFBuilders.plugin, 0L, 20L); // every 5 ticks (0.25s) } + private void bindCheckpoint(ActiveStation station) { + station.setCheckpoint(() -> Database.saveStationsNow(stations)); + } + public void startStationTrailLoop() { new BukkitRunnable() { @Override @@ -262,6 +278,11 @@ public void onPlayerLeftClick(PlayerInteractEvent e) { } ActiveStation station = placement.getStation(); + if (station.hasBlueprint()) { + activePlacements.remove(uuid); + p.sendMessage("§cThis station is already constructing a vehicle."); + return; + } // Credit the character playing now; without a current session the build still starts. UUID characterId = ConstructorCharacter.of(p); BeginVehicleConstructionEvent beginEvent = new BeginVehicleConstructionEvent( @@ -279,6 +300,18 @@ public void onPlayerLeftClick(PlayerInteractEvent e) { placement.getStation().setSpawnLocation(clickLoc); placement.setFinalSpawnLocation(clickLoc); station.selectBlueprint(blueprint, p.getUniqueId(), characterId); + if (!Database.saveStationsNow(stations)) { + // The atomic write kept the previous idle snapshot; undo only this rejected start. + station.setCheckpoint(() -> true); + station.cancelConstruction(); + bindCheckpoint(station); + activePlacements.remove(uuid); + p.sendMessage("§cConstruction could not be saved. Your materials have been returned."); + return; + } + VFLogger.info(VFBuilders.plugin, "Construction started: station=" + stationLoc + + " blueprint=" + blueprint.getId() + " account=" + p.getUniqueId() + + " character=" + characterId); p.sendMessage("§aBuilding site chosen!"); p.sendTitle("", "§eStarted Constructing "+blueprint.getVehicle().getName(), 10, 60, 10); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); @@ -360,8 +393,11 @@ public void onBlockBreak(BlockBreakEvent e) { Location loc = block.getLocation(); if (!stations.containsKey(loc)) return; - e.getPlayer().sendMessage("§cStation removed."); - remove(loc); + if (remove(loc)) e.getPlayer().sendMessage("§cStation removed."); + else { + e.setCancelled(true); + e.getPlayer().sendMessage("§cThe station cannot be removed right now. Try again."); + } } @EventHandler @@ -370,19 +406,29 @@ public void onFurnitureBreak(FurnitureBreakEvent e) { Location loc = e.getBukkitEntity().getLocation().getBlock().getLocation(); if (!stations.containsKey(loc)) return; - e.getPlayer().sendMessage("§cStation removed."); - remove(loc); + if (remove(loc)) e.getPlayer().sendMessage("§cStation removed."); + else { + e.setCancelled(true); + e.getPlayer().sendMessage("§cThe station cannot be removed right now. Try again."); + } } - private void remove(Location loc) { - ActiveStation station = stations.remove(loc); + private boolean remove(Location loc) { + ActiveStation station = stations.get(loc); station.removeHolograms(); if (station.hasBlueprint()) { station.cancelConstruction(); + if (station.hasBlueprint()) return false; } + stations.remove(loc); station.setSpawnLocation(null); // Important to stop the particle trail + if (!Database.saveStationsNow(stations)) { + stations.put(loc, station); + return false; + } + return true; } } diff --git a/src/test/java/net/tfminecraft/vfbuilders/DatabaseTest.java b/src/test/java/net/tfminecraft/vfbuilders/DatabaseTest.java index 3958cfa..e131758 100644 --- a/src/test/java/net/tfminecraft/vfbuilders/DatabaseTest.java +++ b/src/test/java/net/tfminecraft/vfbuilders/DatabaseTest.java @@ -13,6 +13,82 @@ import org.mockbukkit.mockbukkit.*; class DatabaseTest { + @Test + void temporaryCreationAndSerializationFailuresKeepTheLastSnapshot() throws Exception { + Path temp = DatabaseFixture.folder(); + VFBuilders.plugin = mock(VFBuilders.class); + when(VFBuilders.plugin.getDataFolder()).thenReturn(temp.toFile()); + var directory = Files.createDirectories(temp.resolve("data")); + var file = directory.resolve("stations.json"); + Files.writeString(file, "[]"); + try { + try (var files = mockStatic(Files.class, CALLS_REAL_METHODS)) { + files + .when(() -> Files.createTempFile(eq(directory), eq("stations-"), eq(".tmp"))) + .thenThrow(new java.io.IOException("simulated temporary creation failure")); + assertFalse(Database.saveStationsNow(Map.of())); + } + assertEquals("[]", Files.readString(file)); + var detached = mock(ActiveStation.class); + when(detached.getLocation()).thenReturn(new Location(null, 1, 2, 3)); + assertFalse(Database.saveStationsNow(Map.of(new Location(null, 1, 2, 3), detached))); + assertEquals("[]", Files.readString(file)); + } finally { + VFBuilders.plugin = null; + Files.deleteIfExists(file); + } + } + + @Test + void failedAtomicReplacementPreservesTheLastSnapshotAndCleansTemporaryFiles() throws Exception { + Path temp = DatabaseFixture.folder(); + VFBuilders.plugin = mock(VFBuilders.class); + when(VFBuilders.plugin.getDataFolder()).thenReturn(temp.toFile()); + var directory = Files.createDirectories(temp.resolve("data")); + var file = directory.resolve("stations.json"); + Files.writeString(file, "[]"); + try { + try (var files = mockStatic(Files.class, CALLS_REAL_METHODS)) { + files + .when(() -> Files.move(any(Path.class), eq(file), any(CopyOption[].class))) + .thenThrow(new java.io.IOException("simulated replacement failure")); + assertFalse(Database.saveStationsNow(Map.of())); + } + assertEquals("[]", Files.readString(file)); + try (var paths = Files.list(directory)) { + assertEquals(List.of(file), paths.toList()); + } + assertTrue(Database.saveStationsNow(Map.of())); + assertEquals("[]", Files.readString(file)); + } finally { + VFBuilders.plugin = null; + Files.deleteIfExists(file); + } + } + + @Test + void checkpointDoesNotRemoveConstructionDisplays() throws Exception { + Path temp = DatabaseFixture.folder(); + VFBuilders.plugin = mock(VFBuilders.class); + when(VFBuilders.plugin.getDataFolder()).thenReturn(temp.toFile()); + Files.createDirectories(temp.resolve("data")); + var world = mock(World.class); + when(world.getName()).thenReturn("build-world"); + var loc = new Location(world, 3, 64, 5); + var definition = mock(Station.class); + when(definition.getId()).thenReturn("dock"); + var station = mock(ActiveStation.class); + when(station.getLocation()).thenReturn(loc); + when(station.getStation()).thenReturn(definition); + try { + Database.saveStations(Map.of(loc, station)); + verify(station, never()).removeHolograms(); + } finally { + VFBuilders.plugin = null; + Files.deleteIfExists(temp.resolve("data/stations.json")); + } + } + @Test void savesRestoresLegacyAndOwnedProjectsAndHandlesIoFailures() throws Exception { var server = MockBukkit.mock(); @@ -102,14 +178,17 @@ void constructorCharacterRoundTripsAndOlderSavesLoadWithoutOne() throws Exceptio world.unloadChunk(0, 0); var station = new ActiveStation(loc, definition); station.selectBlueprint(blueprint, account, character); + station.setOutputUuid("constructed-output"); Database.saveStations(Map.of(loc, station)); var restored = Database.loadStations().get(loc); assertEquals(account, restored.getConstructorUuid()); assertEquals(character, restored.getConstructorCharacterId()); + assertEquals("constructed-output", restored.getOutputUuid()); station.selectBlueprint(blueprint, account, null); Database.saveStations(Map.of(loc, station)); assertFalse(Files.readString(file).contains("constructorCharacterId")); + assertFalse(Files.readString(file).contains("outputUuid")); restored = Database.loadStations().get(loc); assertEquals(account, restored.getConstructorUuid()); assertNull(restored.getConstructorCharacterId()); diff --git a/src/test/java/net/tfminecraft/vfbuilders/ManagerTest.java b/src/test/java/net/tfminecraft/vfbuilders/ManagerTest.java index e9a3312..1f40bf0 100644 --- a/src/test/java/net/tfminecraft/vfbuilders/ManagerTest.java +++ b/src/test/java/net/tfminecraft/vfbuilders/ManagerTest.java @@ -7,6 +7,7 @@ import net.tfminecraft.tlibs.TLibs; import net.tfminecraft.tlibs.objects.api.BlockAPI; import net.tfminecraft.vfbuilders.core.*; +import net.tfminecraft.vfbuilders.data.Database; import net.tfminecraft.vfbuilders.loaders.*; import net.tfminecraft.vfbuilders.managers.*; import org.bukkit.*; @@ -22,6 +23,7 @@ class ManagerTest { ServerMock server; StationManager manager; MockedStatic libs; + MockedStatic database; BlockAPI api; Station definition; Block block; @@ -38,6 +40,8 @@ void setup() { when(VFBuilders.plugin.getServer()).thenReturn(server); api = mock(BlockAPI.class, RETURNS_DEEP_STUBS); libs = mockStatic(TLibs.class); + database = mockStatic(Database.class); + database.when(() -> Database.saveStationsNow(anyMap())).thenReturn(true); libs.when(TLibs::getBlockAPI).thenReturn(api); StationLoader.get().clear(); definition = mock(Station.class); @@ -51,6 +55,7 @@ void setup() { @AfterEach void teardown() { libs.close(); + database.close(); MockBukkit.unmock(); VFBuilders.plugin = null; } @@ -223,6 +228,112 @@ void placementDistanceMaterialsAndSuccessfulCommit() throws Exception { // No character provider: the build starts without a character. assertNull(s.getConstructorCharacterId()); assertEquals(player.getLocation(), s.getSpawnLocation()); + database.verify(() -> Database.saveStationsNow(anyMap())); + } + + @Test + void failedStartSaveCancelsAndRefundsConstruction() throws Exception { + database.when(() -> Database.saveStationsNow(anyMap())).thenReturn(false); + var station = manager.getStation(block); + var blueprint = mock(Blueprint.class); + when(blueprint.hasInputs(player)).thenReturn(true); + player.teleport(block.getLocation()); + placements().put(player.getUniqueId(), new ActivePlacement(player, station, blueprint)); + manager.onPlayerLeftClick(left()); + verify(blueprint).takeInputs(player); + verify(blueprint).drop(any(Location.class)); + assertFalse(station.hasBlueprint()); + assertTrue(placements().isEmpty()); + assertTrue(((org.mockbukkit.mockbukkit.entity.PlayerMock) player).nextMessage().contains("could not be saved")); + } + + @Test + void stalePlacementCannotOverwriteAnAcceptedBuild() throws Exception { + var station = manager.getStation(block); + var first = mock(Blueprint.class); + var replacement = mock(Blueprint.class); + when(replacement.hasInputs(player)).thenReturn(true); + station.selectBlueprint(first, UUID.randomUUID()); + player.teleport(block.getLocation()); + placements().put(player.getUniqueId(), new ActivePlacement(player, station, replacement)); + manager.onPlayerLeftClick(left()); + assertSame(first, station.getBlueprint()); + verify(replacement, never()).takeInputs(player); + assertTrue(placements().isEmpty()); + } + + @Test + void failedCancellationSavePreventsBreakingTheStationAndRefundingTwice() { + var station = manager.getStation(block); + var blueprint = mock(Blueprint.class); + station.selectBlueprint(blueprint, UUID.randomUUID()); + database.when(() -> Database.saveStationsNow(anyMap())).thenReturn(false); + var event = new BlockBreakEvent(block, player); + manager.onBlockBreak(event); + assertTrue(event.isCancelled()); + assertSame(station, manager.getStation(block)); + assertSame(blueprint, station.getBlueprint()); + verify(blueprint, never()).drop(any()); + } + + @Test + void failedRemovalSaveRestoresAnIdleFurnitureStation() { + var station = manager.getStation(block); + database.when(() -> Database.saveStationsNow(anyMap())).thenReturn(false); + var event = mock(dev.lone.itemsadder.api.Events.FurnitureBreakEvent.class); + var entity = mock(org.bukkit.entity.Entity.class); + when(entity.getLocation()).thenReturn(block.getLocation()); + when(event.getBukkitEntity()).thenReturn(entity); + when(event.getPlayer()).thenReturn(player); + manager.onFurnitureBreak(event); + verify(event).setCancelled(true); + assertSame(station, manager.getStation(block)); + } + + @Test + void removalFailureAfterADurableCancellationDoesNotRestoreOrRefundTheBuildAgain() { + var station = manager.getStation(block); + var blueprint = mock(Blueprint.class); + station.selectBlueprint(blueprint, UUID.randomUUID()); + database.when(() -> Database.saveStationsNow(anyMap())).thenReturn(true, false); + var event = new BlockBreakEvent(block, player); + manager.onBlockBreak(event); + assertTrue(event.isCancelled()); + assertSame(station, manager.getStation(block)); + assertFalse(station.hasBlueprint()); + verify(blueprint).drop(any()); + } + + @Test + void acceptedBuildIsRestorableBeforeAnyShutdown() throws Exception { + var folder = DatabaseFixture.folder(); + when(VFBuilders.plugin.getDataFolder()).thenReturn(folder.toFile()); + database.close(); + database = mockStatic(Database.class, CALLS_REAL_METHODS); + when(definition.getId()).thenReturn("dock"); + var blueprint = mock(Blueprint.class); + when(blueprint.getId()).thenReturn("boat"); + when(blueprint.getTime()).thenReturn(120); + when(blueprint.hasInputs(player)).thenReturn(true); + var vehicle = mock(net.tfminecraft.vehicleframework.vehicles.Vehicle.class); + when(vehicle.getName()).thenReturn("Boat"); + when(blueprint.getVehicle()).thenReturn(vehicle); + BlueprintLoader.get().put("boat", blueprint); + player.teleport(block.getLocation()); + try (var display = mockStatic(net.tfminecraft.vfbuilders.display.StationTimerDisplay.class)) { + var station = manager.getStation(block); + placements().put(player.getUniqueId(), new ActivePlacement(player, station, blueprint)); + manager.onPlayerLeftClick(left()); + var restored = Database.loadStations().get(block.getLocation()); + assertNotNull(restored); + assertSame(blueprint, restored.getBlueprint()); + assertEquals(player.getUniqueId(), restored.getConstructorUuid()); + assertEquals(120, restored.getTimeLeft()); + assertEquals(player.getLocation(), restored.getSpawnLocation()); + } finally { + java.nio.file.Files.deleteIfExists(folder.resolve("data/stations.json")); + BlueprintLoader.get().clear(); + } } @Test diff --git a/src/test/java/net/tfminecraft/vfbuilders/SchedulerTest.java b/src/test/java/net/tfminecraft/vfbuilders/SchedulerTest.java index 5195258..d03af0d 100644 --- a/src/test/java/net/tfminecraft/vfbuilders/SchedulerTest.java +++ b/src/test/java/net/tfminecraft/vfbuilders/SchedulerTest.java @@ -73,6 +73,15 @@ void ticksPauseIncompleteWorkFinishReadyWorkAndRefreshOnlyLoadedChunks() throws manager.rebindAfterReload(reload); verify(reload).run(); verify(waiting).rebindDefinitions(); + db.clearInvocations(); + server.getScheduler().performTicks(579); + db.verify(() -> Database.saveStationsNow(map)); + when(ready.hasBlueprint()).thenReturn(false); + when(waiting.hasBlueprint()).thenReturn(false); + when(detached.hasBlueprint()).thenReturn(false); + db.clearInvocations(); + server.getScheduler().performTicks(600); + db.verify(() -> Database.saveStationsNow(map), never()); manager.stop(); db.verify(() -> Database.saveStations(map)); } finally { diff --git a/src/test/java/net/tfminecraft/vfbuilders/StationTest.java b/src/test/java/net/tfminecraft/vfbuilders/StationTest.java index d7a24c3..f41e519 100644 --- a/src/test/java/net/tfminecraft/vfbuilders/StationTest.java +++ b/src/test/java/net/tfminecraft/vfbuilders/StationTest.java @@ -5,6 +5,7 @@ import java.util.*; import net.tfminecraft.vehicleframework.*; +import net.tfminecraft.vehicleframework.database.VehiclePersistence; import net.tfminecraft.vehicleframework.managers.VehicleManager; import net.tfminecraft.vehicleframework.vehicles.*; import net.tfminecraft.vehicleframework.vehicles.Vehicle; @@ -26,10 +27,14 @@ class StationTest { Station definition; MockedStatic displays; MockedStatic freezes; + MockedStatic persistenceAccess; + VehiclePersistence persistence; @BeforeEach void setup() { server = MockBukkit.mock(); + VFBuilders.plugin = mock(VFBuilders.class); + when(VFBuilders.plugin.getName()).thenReturn("VFBuilders"); world = mock(World.class); loc = new Location(world, 1, 64, 3); b = mock(Blueprint.class); @@ -39,6 +44,10 @@ void setup() { when(definition.getId()).thenReturn("dock"); displays = mockStatic(StationTimerDisplay.class); freezes = mockStatic(ConstructionFreezes.class); + persistence = mock(VehiclePersistence.class, RETURNS_DEEP_STUBS); + when(persistence.saveLive(any(ActiveVehicle.class))).thenReturn(true); + persistenceAccess = mockStatic(VehiclePersistence.class); + persistenceAccess.when(VehiclePersistence::current).thenReturn(persistence); BlueprintLoader.get().clear(); StationLoader.get().clear(); } @@ -47,7 +56,9 @@ void setup() { void cleanup() { displays.close(); freezes.close(); + persistenceAccess.close(); MockBukkit.unmock(); + VFBuilders.plugin = null; } @Test @@ -228,4 +239,189 @@ void completionWaitsForNearbyPlayersAndRetriesFailedSpawn() { .playSound(any(Location.class), eq(Sound.BLOCK_BEACON_ACTIVATE), eq(.8f), eq(1.6f)); } } + + ActiveStation readyStation() { + var station = new ActiveStation(loc, definition); + station.selectBlueprint(b, UUID.randomUUID(), UUID.randomUUID()); + station.setSpawnLocation(loc); + station.setTimeLeft(0); + var player = mock(Player.class); + when(player.getLocation()).thenReturn(loc); + when(world.getPlayers()).thenReturn(List.of(player)); + return station; + } + + @Test + void completionRecordsOutputBeforeEventAndSavesVehicleBeforeClearingTheJob() { + var station = readyStation(); + var vehicle = mock(ActiveVehicle.class); + when(vehicle.getUUID()).thenReturn("created-vehicle"); + var states = new ArrayList(); + station.setCheckpoint(() -> { + states.add(station.getOutputUuid() + ":" + station.hasBlueprint()); + return true; + }); + when(persistence.saveLive(vehicle)).thenAnswer(invocation -> { + states.add("vehicle-saved"); + return true; + }); + var plugin = MockBukkit.createMockPlugin(); + server.getPluginManager().registerEvent( + net.tfminecraft.vfbuilders.events.VehicleConstructEvent.class, + new org.bukkit.event.Listener() {}, org.bukkit.event.EventPriority.MONITOR, + (listener, event) -> states.add("event"), plugin); + try (var vf = mockStatic(VehicleFramework.class)) { + var manager = mock(VehicleManager.class); + vf.when(VehicleFramework::getVehicleManager).thenReturn(manager); + when(manager.spawn(loc, (Vehicle) null)).thenReturn(vehicle); + station.complete(); + assertEquals(List.of("created-vehicle:true", "event", "vehicle-saved", "null:false"), states); + verify(persistence).saveLive(vehicle); + assertFalse(station.hasBlueprint()); + assertNull(station.getOutputUuid()); + } + } + + @Test + void failedOutputCheckpointRemovesTheUnsavedEntityAndLeavesTheBuildQueued() { + var station = readyStation(); + station.setCheckpoint(() -> false); + var vehicle = mock(ActiveVehicle.class); + when(vehicle.getUUID()).thenReturn("created-vehicle"); + try (var vf = mockStatic(VehicleFramework.class)) { + var manager = mock(VehicleManager.class); + vf.when(VehicleFramework::getVehicleManager).thenReturn(manager); + when(manager.spawn(loc, (Vehicle) null)).thenReturn(vehicle); + station.complete(); + verify(vehicle).remove(net.tfminecraft.vehicleframework.enums.VehicleRemoveReason.UNLOAD); + verify(persistence, never()).saveLive(any(ActiveVehicle.class)); + assertTrue(station.hasBlueprint()); + assertNull(station.getOutputUuid()); + } + } + + @Test + void failedVehicleSaveRetriesTheSameEntityAndDoesNotRepeatTheConstructionEvent() { + var station = readyStation(); + var vehicle = mock(ActiveVehicle.class); + when(vehicle.getUUID()).thenReturn("created-vehicle"); + when(persistence.saveLive(vehicle)).thenReturn(false, true); + var events = new ArrayList(); + var plugin = MockBukkit.createMockPlugin(); + server.getPluginManager().registerEvent( + net.tfminecraft.vfbuilders.events.VehicleConstructEvent.class, + new org.bukkit.event.Listener() {}, org.bukkit.event.EventPriority.MONITOR, + (listener, event) -> events.add(event), plugin); + try (var vf = mockStatic(VehicleFramework.class)) { + var manager = mock(VehicleManager.class); + vf.when(VehicleFramework::getVehicleManager).thenReturn(manager); + when(manager.spawn(loc, (Vehicle) null)).thenReturn(vehicle); + when(manager.getByUUID("created-vehicle")).thenReturn(vehicle); + station.complete(); + assertTrue(station.hasBlueprint()); + assertEquals("created-vehicle", station.getOutputUuid()); + station.cancelConstruction(); + assertTrue(station.hasBlueprint()); + station.complete(); + assertFalse(station.hasBlueprint()); + assertEquals(1, events.size()); + verify(manager).spawn(loc, (Vehicle) null); + } + } + + @Test + void savedOutputIncludingADeletedVehicleCannotSpawnAgainAfterAnInterruptedClear() { + var station = readyStation(); + station.setOutputUuid("saved-output"); + when(persistence.repository().find("saved-output")) + .thenReturn(Optional.of(mock(net.tfminecraft.vehicleframework.database.VehicleSnapshot.class))); + var allowSave = new java.util.concurrent.atomic.AtomicBoolean(false); + station.setCheckpoint(allowSave::get); + try (var vf = mockStatic(VehicleFramework.class)) { + var manager = mock(VehicleManager.class); + vf.when(VehicleFramework::getVehicleManager).thenReturn(manager); + station.complete(); + assertTrue(station.hasBlueprint()); + assertEquals("saved-output", station.getOutputUuid()); + assertNotNull(station.getConstructorUuid()); + assertNotNull(station.getConstructorCharacterId()); + assertEquals(loc, station.getSpawnLocation()); + allowSave.set(true); + station.complete(); + assertFalse(station.hasBlueprint()); + verifyNoInteractions(manager); + verify(b, never()).drop(any()); + } + } + + @Test + void missingOutputAfterRestartCanBeRebuiltButUnavailablePersistenceMustWait() { + var station = readyStation(); + station.setOutputUuid("lost-runtime-output"); + persistenceAccess.when(VehiclePersistence::current).thenReturn(null); + station.complete(); + station.cancelConstruction(); + assertTrue(station.hasBlueprint()); + persistenceAccess.when(VehiclePersistence::current).thenReturn(persistence); + var vehicle = mock(ActiveVehicle.class); + when(vehicle.getUUID()).thenReturn("replacement-output"); + try (var vf = mockStatic(VehicleFramework.class)) { + var manager = mock(VehicleManager.class); + vf.when(VehicleFramework::getVehicleManager).thenReturn(manager); + when(manager.spawn(loc, (Vehicle) null)).thenReturn(vehicle); + station.complete(); + assertFalse(station.hasBlueprint()); + verify(manager).spawn(loc, (Vehicle) null); + } + } + + @Test + void cancellationSaveFailureKeepsTheBuildAndStoredOutputCancellationDoesNotRefund() { + var station = readyStation(); + station.setCheckpoint(() -> false); + station.cancelConstruction(); + assertTrue(station.hasBlueprint()); + verify(b, never()).drop(any()); + station.setOutputUuid("saved-output"); + when(persistence.repository().find("saved-output")) + .thenReturn(Optional.of(mock(net.tfminecraft.vehicleframework.database.VehicleSnapshot.class))); + station.cancelConstruction(); + assertTrue(station.hasBlueprint()); + station.setCheckpoint(() -> true); + station.setTimeLeft(40); + station.cancelConstruction(); + assertFalse(station.hasBlueprint()); + assertEquals(0, station.getTimeLeft()); + verify(b, never()).drop(any()); + } + + @Test + void failedSaveWithAnExistingStoredOutputRecoversWithoutAnotherEventOrSpawn() { + var station = readyStation(); + var vehicle = mock(ActiveVehicle.class); + when(vehicle.getUUID()).thenReturn("stored-output"); + when(persistence.saveLive(vehicle)).thenAnswer(invocation -> { + when(persistence.repository().find("stored-output")) + .thenReturn(Optional.of(mock(net.tfminecraft.vehicleframework.database.VehicleSnapshot.class))); + return false; + }); + var events = new ArrayList(); + var plugin = MockBukkit.createMockPlugin(); + server.getPluginManager().registerEvent( + net.tfminecraft.vfbuilders.events.VehicleConstructEvent.class, + new org.bukkit.event.Listener() {}, org.bukkit.event.EventPriority.MONITOR, + (listener, event) -> events.add(event), plugin); + try (var vf = mockStatic(VehicleFramework.class)) { + var manager = mock(VehicleManager.class); + vf.when(VehicleFramework::getVehicleManager).thenReturn(manager); + when(manager.spawn(loc, (Vehicle) null)).thenReturn(vehicle); + station.complete(); + assertTrue(station.hasBlueprint()); + station.complete(); + assertFalse(station.hasBlueprint()); + assertEquals(1, events.size()); + verify(manager).spawn(loc, (Vehicle) null); + verify(persistence).saveLive(vehicle); + } + } }