Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 102 additions & 27 deletions src/main/java/net/tfminecraft/vfbuilders/data/Database.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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<JsonObject> parked = new ArrayList<>();

/** The stations file, under the plugin's current data folder. */
static Path path() {
Expand Down Expand Up @@ -131,7 +140,14 @@ private static Snapshot snapshot(Map<Location, ActiveStation> 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) {
Expand All @@ -142,15 +158,15 @@ private static boolean write(Snapshot snapshot) {
}
}

private static boolean writeFile(File file, List<StationData> dataList) {
private static boolean writeFile(File file, JsonArray records) {
try {
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.write(gson.toJson(records).getBytes(StandardCharsets.UTF_8));
output.getFD().sync();
}
Files.move(temporary, file.toPath(),
Expand All @@ -167,36 +183,35 @@ private static boolean writeFile(File file, List<StationData> 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<StationData> stations) {}
private record Snapshot(long sequence, File target, JsonArray stations) {}

public static HashMap<Location, ActiveStation> loadStations() {
HashMap<Location, ActiveStation> loaded = new HashMap<>();
parked = new ArrayList<>();

File file = file();
if (!file.exists()) return loaded;

try (Reader reader = new FileReader(file)) {
Type listType = new TypeToken<List<StationData>>(){}.getType();
List<StationData> 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<String> 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();
Expand All @@ -205,6 +220,66 @@ public static HashMap<Location, ActiveStation> 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<ActiveStation> activateParked() {
List<ActiveStation> activated = new ArrayList<>();
Iterator<JsonObject> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())) {
Expand Down Expand Up @@ -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();
Expand Down
81 changes: 81 additions & 0 deletions src/test/java/net/tfminecraft/vfbuilders/DatabaseTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
20 changes: 20 additions & 0 deletions src/test/java/net/tfminecraft/vfbuilders/ManagerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,26 @@
return (Map<UUID, ActivePlacement>) 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)));
Expand Down Expand Up @@ -422,9 +442,9 @@
void quitAndBreakRemoveOnlyTheirOwnState() throws Exception {
var s = manager.getStation(block);
placements().put(player.getUniqueId(), new ActivePlacement(player, s, mock(Blueprint.class)));
manager.onPlayerQuit(new PlayerQuitEvent(player, "quit"));

Check warning on line 445 in src/test/java/net/tfminecraft/vfbuilders/ManagerTest.java

View workflow job for this annotation

GitHub Actions / build

PlayerQuitEvent(org.bukkit.entity.Player,java.lang.String) in org.bukkit.event.player.PlayerQuitEvent has been deprecated and marked for removal
assertTrue(placements().isEmpty());
manager.onPlayerQuit(new PlayerQuitEvent(player, "quit"));

Check warning on line 447 in src/test/java/net/tfminecraft/vfbuilders/ManagerTest.java

View workflow job for this annotation

GitHub Actions / build

PlayerQuitEvent(org.bukkit.entity.Player,java.lang.String) in org.bukkit.event.player.PlayerQuitEvent has been deprecated and marked for removal
manager.onBlockBreak(new BlockBreakEvent(player.getWorld().getBlockAt(50, 64, 50), player));
manager.onBlockBreak(new BlockBreakEvent(block, player));
assertNotSame(s, manager.getStation(block));
Expand Down