diff --git a/pom.xml b/pom.xml
index 622e1dfd..318034ef 100644
--- a/pom.xml
+++ b/pom.xml
@@ -15,7 +15,7 @@
0.2.4
2.0.1
2.0.0
- 2.8.0
+ 2.10.0
2.1.0
diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java
index 38dfa994..f4fd0ff0 100644
--- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java
+++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java
@@ -574,7 +574,12 @@ private boolean refreshTrackProvinces(Runnable recalculate) {
}
return net.tfminecraft.simplefactions.guild.hub.VehicleFrameworkTrackProvinces.sample(
Cache.worldName, provinceGrid, provinces);
- }, recalculate,
+ }, () -> {
+ // Forget cached rail routes first; that also drops the graph's routes.
+ net.tfminecraft.simplefactions.guild.hub.HubNetwork.forgetRoutes();
+ net.tfminecraft.simplefactions.guild.network.TradeGraph.refresh(provinceManager);
+ recalculate.run();
+ },
message -> getLogger().warning("[SimpleFactions] " + message));
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubNetwork.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubNetwork.java
index f86a8d0e..8fdc12b9 100644
--- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubNetwork.java
+++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubNetwork.java
@@ -12,6 +12,7 @@
import net.tfminecraft.simplefactions.Cache;
import net.tfminecraft.simplefactions.SimpleFactions;
import net.tfminecraft.simplefactions.guild.Guild;
+import net.tfminecraft.simplefactions.guild.network.TradeGraph;
import net.tfminecraft.simplefactions.guild.GuildModifierOverride;
import net.tfminecraft.simplefactions.guild.hub.HubTransport.Link;
import net.tfminecraft.simplefactions.guild.hub.HubTransport.Mode;
@@ -60,6 +61,7 @@ public static List linksFor(Guild guild) {
/** Rebuilds every guild's connections. Server thread only. */
public static void refresh(ProvinceManager provinces) {
+ TradeGraph.refresh(provinces);
Map> built = new HashMap<>();
List guilds = SupplyHubService.allGuilds();
for (Guild guild : guilds) {
@@ -96,6 +98,7 @@ public static void refreshIfLive(ProvinceManager provinces) {
*/
public static void forgetRoutes() {
railDistances.clear();
+ TradeGraph.forgetRoutes();
}
/**
diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/VehicleFrameworkTrackProvinces.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/VehicleFrameworkTrackProvinces.java
index 6c43b85f..22ddda76 100644
--- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/VehicleFrameworkTrackProvinces.java
+++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/VehicleFrameworkTrackProvinces.java
@@ -1,26 +1,16 @@
package net.tfminecraft.simplefactions.guild.hub;
-import java.util.List;
import java.util.Map;
import java.util.Set;
import net.tfminecraft.simplefactions.map.ProvinceGrid;
-import net.tfminecraft.simplefactions.map.infra.TrackProvinceLookup;
-import net.tfminecraft.simplefactions.map.infra.TrackProvinceLookup.Point;
import net.tfminecraft.simplefactions.map.provinces.Province;
-import net.tfminecraft.vehicleframework.VehicleFramework;
-import net.tfminecraft.vehicleframework.tracks.TrackRegistry;
-import net.tfminecraft.vehicleframework.tracks.TrackSamplePoint;
-/** The only track sampler here that names VehicleFramework's sample point type. */
+/** Track infrastructure sampling uses the same VehicleFramework boundary as hub routes. */
public final class VehicleFrameworkTrackProvinces {
private VehicleFrameworkTrackProvinces() {}
public static Set sample(String world, ProvinceGrid grid, Map provinces) {
- TrackRegistry tracks = VehicleFramework.getTrackRegistry();
- if (tracks == null || world == null) return Set.of();
- List samples = tracks.sampleTrack(world, 8);
- return TrackProvinceLookup.collect(grid, samples.stream()
- .map(point -> new Point(point.x(), point.y(), point.z())).toList(), provinces);
+ return VehicleFrameworkTracks.sample(world, grid, provinces);
}
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/VehicleFrameworkTracks.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/VehicleFrameworkTracks.java
index bef0c62d..f12770aa 100644
--- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/VehicleFrameworkTracks.java
+++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/VehicleFrameworkTracks.java
@@ -1,17 +1,27 @@
package net.tfminecraft.simplefactions.guild.hub;
import java.util.OptionalDouble;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import org.bukkit.Bukkit;
+
+import net.tfminecraft.simplefactions.guild.network.RailRoutes.Route;
import net.tfminecraft.simplefactions.installation.Installation;
import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader;
+import net.tfminecraft.simplefactions.map.ProvinceGrid;
+import net.tfminecraft.simplefactions.map.infra.TrackProvinceLookup;
+import net.tfminecraft.simplefactions.map.infra.TrackProvinceLookup.Point;
+import net.tfminecraft.simplefactions.map.provinces.Province;
import net.tfminecraft.vehicleframework.VehicleFramework;
import net.tfminecraft.vehicleframework.tracks.TrackRegistry;
/**
* The only class here that names VehicleFramework types, so it is loaded only once
- * {@link HubNetwork} has checked that the plugin is enabled.
+ * the caller has checked that the plugin is enabled, or through the guarded route method.
*/
-final class VehicleFrameworkTracks {
+public final class VehicleFrameworkTracks {
private VehicleFrameworkTracks() {
}
@@ -29,4 +39,27 @@ static OptionalDouble distance(String world, Installation from, Installation to)
to.getCenterZ(),
InstallationConfigLoader.getRadius(to.getKind()));
}
+
+ /** Empty when VehicleFramework is missing or too old to return route points. */
+ public static Optional route(String world, Installation from, Installation to) {
+ try {
+ if (!Bukkit.getPluginManager().isPluginEnabled("VehicleFramework")) return Optional.empty();
+ TrackRegistry tracks = VehicleFramework.getTrackRegistry();
+ if (tracks == null || world == null) return Optional.empty();
+ return tracks.shortestRoute(world,
+ from.getCenterX(), from.getCenterZ(), InstallationConfigLoader.getRadius(from.getKind()),
+ to.getCenterX(), to.getCenterZ(), InstallationConfigLoader.getRadius(to.getKind()), 8)
+ .map(route -> new Route(route.length(), route.points().stream()
+ .map(point -> new Point(point.x(), point.y(), point.z())).toList()));
+ } catch (RuntimeException | LinkageError e) {
+ return Optional.empty();
+ }
+ }
+
+ static Set sample(String world, ProvinceGrid grid, Map provinces) {
+ TrackRegistry tracks = VehicleFramework.getTrackRegistry();
+ if (tracks == null || world == null) return Set.of();
+ return TrackProvinceLookup.collect(grid, tracks.sampleTrack(world, 8).stream()
+ .map(point -> new Point(point.x(), point.y(), point.z())).toList(), provinces);
+ }
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/network/LiveTradeGraph.java b/src/main/java/net/tfminecraft/simplefactions/guild/network/LiveTradeGraph.java
new file mode 100644
index 00000000..74d7701c
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/guild/network/LiveTradeGraph.java
@@ -0,0 +1,83 @@
+package net.tfminecraft.simplefactions.guild.network;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.bukkit.Bukkit;
+
+import net.tfminecraft.simplefactions.Cache;
+import net.tfminecraft.simplefactions.SimpleFactions;
+import net.tfminecraft.simplefactions.guild.hub.VehicleFrameworkTracks;
+import net.tfminecraft.simplefactions.guild.network.RailRoutes.Route;
+import net.tfminecraft.simplefactions.guild.network.TradeGraphBuilder.ProvinceData;
+import net.tfminecraft.simplefactions.guild.network.TradeGraphBuilder.Site;
+import net.tfminecraft.simplefactions.installation.Installation;
+import net.tfminecraft.simplefactions.installation.InstallationKind;
+import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader;
+import net.tfminecraft.simplefactions.managers.FactionManager;
+import net.tfminecraft.simplefactions.managers.ProvinceManager;
+import net.tfminecraft.simplefactions.map.ProvinceGrid;
+import net.tfminecraft.simplefactions.map.provinces.Province;
+import net.tfminecraft.simplefactions.objects.Faction;
+
+/** Takes the server-thread data needed by the pure builder. */
+final class LiveTradeGraph {
+ private static final RailRouteCache routes = new RailRouteCache(System::currentTimeMillis);
+
+ private LiveTradeGraph() { }
+
+ static TradeGraph build(ProvinceManager provinces) {
+ List sites = new ArrayList<>();
+ Map identities = new IdentityHashMap<>();
+ if (FactionManager.factions != null) {
+ for (Faction faction : FactionManager.factions) {
+ if (faction == null || faction.getId() == null || faction.getInstallationHandler() == null) continue;
+ // Pending construction is separate from getAll(), as in SupplyHubService.findInstallation.
+ for (Installation installation : faction.getInstallationHandler().getAll()) {
+ if (installation == null || installation.getKind() == InstallationKind.FORT) continue;
+ sites.add(new Site(faction.getId(), installation, InstallationConfigLoader.getHubSlots(
+ installation.getKind(), installation.getLevel()), true));
+ identities.put(installation,
+ faction.getId() + ":" + installation.getProvince() + ":" + installation.getId());
+ }
+ }
+ }
+ Map map = new HashMap<>();
+ for (Province province : provinces.getProvinces()) {
+ if (province.isValid()) {
+ map.put(province.getId(), new ProvinceData(province.getTerrain(), province.getNeighbours()));
+ }
+ }
+ SimpleFactions plugin = SimpleFactions.getInstance();
+ ProvinceGrid grid = plugin == null ? null : plugin.getProvinceGrid();
+ String world = Cache.worldName;
+ return TradeGraphBuilder.build(sites, map, (from, to) -> {
+ String left = identities.get(from);
+ String right = identities.get(to);
+ if (left == null || right == null) return Optional.empty();
+ return routes.route(left, right, () -> trackRoute(world, from, to));
+ }, point -> grid == null ? 0 : grid.getAt(
+ (int) Math.floor(point.x()), (int) Math.floor(point.z())));
+ }
+
+ /**
+ * The plugin check sits outside {@link VehicleFrameworkTracks}, matching hub distances.
+ * A missing or older plugin must not fail the refresh.
+ */
+ private static Optional trackRoute(String world, Installation from, Installation to) {
+ try {
+ if (!Bukkit.getPluginManager().isPluginEnabled("VehicleFramework")) return Optional.empty();
+ return VehicleFrameworkTracks.route(world, from, to);
+ } catch (RuntimeException | LinkageError e) {
+ return Optional.empty();
+ }
+ }
+
+ static void forgetRoutes() {
+ routes.forget();
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/network/RailRouteCache.java b/src/main/java/net/tfminecraft/simplefactions/guild/network/RailRouteCache.java
new file mode 100644
index 00000000..7f4a29a8
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/guild/network/RailRouteCache.java
@@ -0,0 +1,41 @@
+package net.tfminecraft.simplefactions.guild.network;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.LongSupplier;
+import java.util.function.Supplier;
+
+import net.tfminecraft.simplefactions.guild.network.RailRoutes.Route;
+
+/** Hits last until forgotten; misses are tried again after thirty seconds. */
+final class RailRouteCache {
+ private record Key(String first, String second) { }
+ private record Cached(Optional route, long expiresAt) { }
+
+ private final Map routes = new HashMap<>();
+ private final LongSupplier clock;
+
+ RailRouteCache(LongSupplier clock) {
+ this.clock = clock;
+ }
+
+ Optional route(String from, String to, Supplier> source) {
+ if (from == null || to == null) return Optional.empty();
+ boolean reverse = from.compareTo(to) > 0;
+ Key key = reverse ? new Key(to, from) : new Key(from, to);
+ long now = clock.getAsLong();
+ Cached known = routes.get(key);
+ if (known != null && (known.route().isPresent() || known.expiresAt() > now)) {
+ return reverse ? known.route().map(Route::reversed) : known.route();
+ }
+ Optional measured = source.get();
+ routes.put(key, new Cached(reverse ? measured.map(Route::reversed) : measured,
+ measured.isPresent() ? Long.MAX_VALUE : now + 30_000L));
+ return measured;
+ }
+
+ void forget() {
+ routes.clear();
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/network/RailRoutes.java b/src/main/java/net/tfminecraft/simplefactions/guild/network/RailRoutes.java
new file mode 100644
index 00000000..2ad4cdb9
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/guild/network/RailRoutes.java
@@ -0,0 +1,23 @@
+package net.tfminecraft.simplefactions.guild.network;
+
+import java.util.List;
+import java.util.Optional;
+
+import net.tfminecraft.simplefactions.installation.Installation;
+import net.tfminecraft.simplefactions.map.infra.TrackProvinceLookup.Point;
+
+/** Routes contain points in order from the first installation to the second. */
+@FunctionalInterface
+public interface RailRoutes {
+ Optional route(Installation from, Installation to);
+
+ record Route(double length, List points) {
+ public Route {
+ points = List.copyOf(points);
+ }
+
+ public Route reversed() {
+ return new Route(length, points.reversed());
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/network/TradeGraph.java b/src/main/java/net/tfminecraft/simplefactions/guild/network/TradeGraph.java
new file mode 100644
index 00000000..497d3f13
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/guild/network/TradeGraph.java
@@ -0,0 +1,127 @@
+package net.tfminecraft.simplefactions.guild.network;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import net.tfminecraft.simplefactions.guild.hub.HubTransport.Mode;
+import net.tfminecraft.simplefactions.installation.InstallationKind;
+import net.tfminecraft.simplefactions.managers.ProvinceManager;
+
+/** Immutable connections shared by every guild. Safe to read on any thread. */
+public final class TradeGraph {
+ public record Node(
+ String ownerFactionId, String installationId, InstallationKind kind, int provinceId,
+ int centerX, int centerZ, int level, int hubSlots, int networkIndex) {
+ }
+
+ /** Provinces run from first to second; neither endpoint province is included. */
+ public record Edge(Node first, Node second, Mode mode, double length, List provinces) {
+ public Edge {
+ provinces = List.copyOf(provinces);
+ }
+
+ public Node other(Node node) {
+ if (first.equals(node)) return second;
+ if (second.equals(node)) return first;
+ throw new IllegalArgumentException("Node is not on this edge");
+ }
+
+ public List provincesFrom(Node node) {
+ if (first.equals(node)) return provinces;
+ if (second.equals(node)) return provinces.reversed();
+ throw new IllegalArgumentException("Node is not on this edge");
+ }
+ }
+
+ public record Network(List nodes, boolean global) {
+ public Network {
+ nodes = List.copyOf(nodes);
+ }
+
+ public int size() {
+ return nodes.size();
+ }
+ }
+
+ private record Key(String owner, String installation) { }
+
+ private static final TradeGraph EMPTY = new TradeGraph(List.of(), List.of(), List.of());
+ private static volatile TradeGraph live = EMPTY;
+
+ private final List nodes;
+ private final List edges;
+ private final List networks;
+ private final Map byInstallation;
+ private final Map> atNode;
+
+ TradeGraph(List nodes, List edges, List networks) {
+ this.nodes = List.copyOf(nodes);
+ this.edges = List.copyOf(edges);
+ this.networks = List.copyOf(networks);
+ Map found = new HashMap<>();
+ Map> adjacent = new HashMap<>();
+ for (Node node : nodes) {
+ found.put(key(node.ownerFactionId(), node.installationId()), node);
+ adjacent.put(node, new java.util.ArrayList<>());
+ }
+ for (Edge edge : edges) {
+ adjacent.get(edge.first()).add(edge);
+ adjacent.get(edge.second()).add(edge);
+ }
+ adjacent.replaceAll((node, links) -> List.copyOf(links));
+ byInstallation = Map.copyOf(found);
+ atNode = Map.copyOf(adjacent);
+ }
+
+ public static TradeGraph live() {
+ return live;
+ }
+
+ /** Rebuilds from installations and track. Server thread only. */
+ public static void refresh(ProvinceManager provinces) {
+ live = LiveTradeGraph.build(provinces);
+ }
+
+ public static void forgetRoutes() {
+ LiveTradeGraph.forgetRoutes();
+ }
+
+ /** Replaces the snapshot tests read. Null clears it. */
+ public static void setLiveForTests(TradeGraph replacement) {
+ live = replacement == null ? EMPTY : replacement;
+ }
+
+ public List nodes() {
+ return nodes;
+ }
+
+ public List edges() {
+ return edges;
+ }
+
+ /** Largest first; a node's networkIndex refers to this list. */
+ public List networks() {
+ return networks;
+ }
+
+ /** Returns null when this installation is not a trade node. */
+ public Node node(String ownerFactionId, String installationId) {
+ if (ownerFactionId == null || installationId == null) return null;
+ return byInstallation.get(key(ownerFactionId, installationId));
+ }
+
+ public List edgesAt(Node node) {
+ return atNode.getOrDefault(node, List.of());
+ }
+
+ /** Returns null for a node outside this snapshot. */
+ public Network networkOf(Node node) {
+ return atNode.containsKey(node) ? networks.get(node.networkIndex()) : null;
+ }
+
+ private static Key key(String owner, String installation) {
+ return new Key(owner.toLowerCase(Locale.ROOT), installation.toLowerCase(Locale.ROOT));
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/network/TradeGraphBuilder.java b/src/main/java/net/tfminecraft/simplefactions/guild/network/TradeGraphBuilder.java
new file mode 100644
index 00000000..780ed08b
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/guild/network/TradeGraphBuilder.java
@@ -0,0 +1,201 @@
+package net.tfminecraft.simplefactions.guild.network;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.ToIntFunction;
+
+import net.tfminecraft.simplefactions.enums.Terrain;
+import net.tfminecraft.simplefactions.guild.hub.HubTransport.Mode;
+import net.tfminecraft.simplefactions.guild.network.TradeGraph.Edge;
+import net.tfminecraft.simplefactions.guild.network.TradeGraph.Network;
+import net.tfminecraft.simplefactions.guild.network.TradeGraph.Node;
+import net.tfminecraft.simplefactions.installation.Installation;
+import net.tfminecraft.simplefactions.installation.InstallationKind;
+import net.tfminecraft.simplefactions.map.infra.TrackProvinceLookup.Point;
+
+/** Graph decisions use only supplied data, with no live world or faction reads. */
+public final class TradeGraphBuilder {
+ /** Finished means registered as an installation, rather than pending construction. */
+ public record Site(String ownerFactionId, Installation installation, int hubSlots, boolean finished) { }
+
+ public record ProvinceData(Terrain terrain, Set neighbours) {
+ public ProvinceData {
+ neighbours = Set.copyOf(neighbours);
+ }
+
+ boolean water() {
+ return terrain == Terrain.SEA || terrain == Terrain.WATER;
+ }
+ }
+
+ private TradeGraphBuilder() { }
+
+ public static TradeGraph build(
+ Collection sites, Map provinces,
+ RailRoutes rail, ToIntFunction provinceAt) {
+ List finished = sites.stream()
+ .filter(site -> site.finished() && site.installation() != null
+ && site.ownerFactionId() != null && site.installation().getId() != null
+ && tradeKind(site.installation().getKind()))
+ .sorted(Comparator.comparing(Site::ownerFactionId)
+ .thenComparing(site -> site.installation().getId()))
+ .toList();
+ List nodes = new ArrayList<>();
+ for (Site site : finished) {
+ Installation installation = site.installation();
+ nodes.add(new Node(site.ownerFactionId(), installation.getId(), installation.getKind(),
+ installation.getProvince(), installation.getCenterX(), installation.getCenterZ(),
+ installation.getLevel(), site.hubSlots(), -1));
+ }
+ List edges = new ArrayList<>();
+ Map> access = new HashMap<>();
+ for (Node node : nodes) {
+ if (node.kind() == InstallationKind.PORT) {
+ access.put(node, waterAccess(node.provinceId(), provinces));
+ }
+ }
+ for (int i = 0; i < nodes.size(); i++) {
+ Node first = nodes.get(i);
+ Map seaPaths = first.kind() == InstallationKind.PORT
+ ? flood(access.get(first), provinces) : Map.of();
+ for (int j = i + 1; j < nodes.size(); j++) {
+ Node second = nodes.get(j);
+ double distance = Math.hypot(
+ (double) first.centerX() - second.centerX(),
+ (double) first.centerZ() - second.centerZ());
+ if (first.kind() == InstallationKind.PORT && second.kind() == InstallationKind.PORT) {
+ List path = seaPath(seaPaths, access.get(second));
+ if (!path.isEmpty()) {
+ edges.add(new Edge(first, second, Mode.SEA, distance,
+ withoutEnds(path, first, second)));
+ }
+ }
+ if (first.kind() == InstallationKind.AIRPORT && second.kind() == InstallationKind.AIRPORT) {
+ edges.add(new Edge(first, second, Mode.AIR, distance, List.of()));
+ }
+ rail.route(finished.get(i).installation(), finished.get(j).installation())
+ .filter(route -> Double.isFinite(route.length()) && route.length() >= 0)
+ .ifPresent(route -> {
+ // Consecutive repeats and the two endpoint provinces go. A fort along the way stays.
+ List path = new ArrayList<>();
+ for (Point point : route.points()) {
+ int id = provinceAt.applyAsInt(point);
+ if (id == 0 || !provinces.containsKey(id)) continue;
+ if (path.isEmpty() || path.getLast() != id) path.add(id);
+ }
+ edges.add(new Edge(first, second, Mode.RAIL, route.length(),
+ withoutEnds(path, first, second)));
+ });
+ }
+ }
+ return components(nodes, edges);
+ }
+
+ private static boolean tradeKind(InstallationKind kind) {
+ return kind == InstallationKind.PORT || kind == InstallationKind.AIRPORT
+ || kind == InstallationKind.TRAIN_STATION;
+ }
+
+ private static Set waterAccess(int provinceId, Map provinces) {
+ ProvinceData province = provinces.get(provinceId);
+ if (province == null) return Set.of();
+ Set access = new HashSet<>();
+ for (int id : province.neighbours()) {
+ ProvinceData neighbour = provinces.get(id);
+ if (id != 0 && neighbour != null && neighbour.water()) access.add(id);
+ }
+ return access;
+ }
+
+ /** Insertion order is breadth-first, so the first reached goal is a shortest path. */
+ private static Map flood(Set starts, Map provinces) {
+ Map parents = new LinkedHashMap<>();
+ ArrayDeque queue = new ArrayDeque<>();
+ for (int start : starts.stream().sorted().toList()) {
+ parents.put(start, 0);
+ queue.add(start);
+ }
+ while (!queue.isEmpty()) {
+ int current = queue.remove();
+ for (int id : provinces.get(current).neighbours().stream().sorted().toList()) {
+ ProvinceData next = provinces.get(id);
+ if (id == 0 || parents.containsKey(id) || next == null || !next.water()) continue;
+ parents.put(id, current);
+ queue.add(id);
+ }
+ }
+ return parents;
+ }
+
+ private static List seaPath(Map parents, Set goals) {
+ for (int reached : parents.keySet()) {
+ if (!goals.contains(reached)) continue;
+ List path = new ArrayList<>();
+ for (int id = reached; id != 0; id = parents.get(id)) path.add(id);
+ return path.reversed();
+ }
+ return List.of();
+ }
+
+ private static List withoutEnds(List path, Node first, Node second) {
+ List corridor = new ArrayList<>();
+ for (int id : path) {
+ if (id == first.provinceId() || id == second.provinceId()) continue;
+ if (corridor.isEmpty() || corridor.getLast() != id) corridor.add(id);
+ }
+ return corridor;
+ }
+
+ private static TradeGraph components(List nodes, List edges) {
+ Map> adjacent = new HashMap<>();
+ for (Node node : nodes) adjacent.put(node, new ArrayList<>());
+ for (Edge edge : edges) {
+ adjacent.get(edge.first()).add(edge.second());
+ adjacent.get(edge.second()).add(edge.first());
+ }
+ Set visited = new HashSet<>();
+ List> groups = new ArrayList<>();
+ for (Node start : nodes) {
+ if (!visited.add(start)) continue;
+ List group = new ArrayList<>();
+ ArrayDeque queue = new ArrayDeque<>();
+ queue.add(start);
+ while (!queue.isEmpty()) {
+ Node node = queue.remove();
+ group.add(node);
+ for (Node next : adjacent.get(node)) {
+ if (visited.add(next)) queue.add(next);
+ }
+ }
+ groups.add(group);
+ }
+ groups.sort(Comparator.comparingInt((List group) -> group.size()).reversed());
+ boolean uniqueLargest = !groups.isEmpty()
+ && (groups.size() == 1 || groups.get(0).size() > groups.get(1).size());
+ Map indexed = new HashMap<>();
+ List networks = new ArrayList<>();
+ for (int i = 0; i < groups.size(); i++) {
+ List group = new ArrayList<>();
+ for (Node node : groups.get(i)) {
+ Node replacement = new Node(node.ownerFactionId(), node.installationId(), node.kind(),
+ node.provinceId(), node.centerX(), node.centerZ(), node.level(), node.hubSlots(), i);
+ indexed.put(node, replacement);
+ group.add(replacement);
+ }
+ boolean global = i == 0 && uniqueLargest && group.stream()
+ .map(node -> node.ownerFactionId().toLowerCase(java.util.Locale.ROOT)).distinct().count() > 1;
+ networks.add(new Network(group, global));
+ }
+ return new TradeGraph(nodes.stream().map(indexed::get).toList(),
+ edges.stream().map(edge -> new Edge(indexed.get(edge.first()), indexed.get(edge.second()),
+ edge.mode(), edge.length(), edge.provinces())).toList(), networks);
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/network/TradeGraphCommand.java b/src/main/java/net/tfminecraft/simplefactions/guild/network/TradeGraphCommand.java
new file mode 100644
index 00000000..c47beb47
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/guild/network/TradeGraphCommand.java
@@ -0,0 +1,56 @@
+package net.tfminecraft.simplefactions.guild.network;
+
+import java.util.Locale;
+
+import org.bukkit.command.CommandSender;
+
+import net.tfminecraft.simplefactions.Cache;
+import net.tfminecraft.simplefactions.guild.network.TradeGraph.Edge;
+import net.tfminecraft.simplefactions.guild.network.TradeGraph.Network;
+import net.tfminecraft.simplefactions.guild.network.TradeGraph.Node;
+import net.tfminecraft.simplefactions.utils.Permissions;
+
+/** Staff inspection of the current snapshot, also available from the console. */
+public final class TradeGraphCommand {
+ private TradeGraphCommand() { }
+
+ public static boolean handle(CommandSender sender, String[] args) {
+ if (!Permissions.isAdmin(sender)) {
+ sender.sendMessage("§a[SimpleFactions]§c You do not have access to this command");
+ return true;
+ }
+ if (!Cache.requireProvinces(sender)) return true;
+ if (args.length != 1) {
+ sender.sendMessage("§cUsage: §e/faction tradegraph");
+ return true;
+ }
+ TradeGraph graph = TradeGraph.live();
+ if (graph.networks().isEmpty()) sender.sendMessage("§7There are no trade networks.");
+ for (int i = 0; i < graph.networks().size(); i++) {
+ Network network = graph.networks().get(i);
+ sender.sendMessage("§6Network " + (i + 1) + ". §7" + count(network.size(), "node")
+ + ", " + (network.global() ? "global" : "not global") + ".");
+ for (Node node : network.nodes()) {
+ sender.sendMessage("§e" + label(node) + " §7" + node.kind().getDisplayName()
+ + ", province " + node.provinceId() + ", level " + node.level()
+ + ", " + count(node.hubSlots(), "hub slot") + ".");
+ for (Edge edge : graph.edgesAt(node)) {
+ if (!edge.first().equals(node)) continue;
+ sender.sendMessage("§7" + label(edge.first()) + " to " + label(edge.second())
+ + ". " + edge.mode().getKey() + ", "
+ + String.format(Locale.ROOT, "%.1f", edge.length()) + " blocks, "
+ + count(edge.provinces().size(), "province") + ".");
+ }
+ }
+ }
+ return true;
+ }
+
+ private static String count(int amount, String singular) {
+ return amount + " " + singular + (amount == 1 ? "" : "s");
+ }
+
+ private static String label(Node node) {
+ return node.ownerFactionId() + "/" + node.installationId();
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java
index 6af829b3..d5a2962d 100644
--- a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java
+++ b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java
@@ -71,6 +71,8 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String
}
private boolean dispatchCommand(CommandSender sender, Command cmd, String label, String[] args) {
+ if (cmd.getName().equalsIgnoreCase(cmd1) && args.length >= 1 && args[0].equalsIgnoreCase("tradegraph"))
+ return net.tfminecraft.simplefactions.guild.network.TradeGraphCommand.handle(sender, args);
if (cmd.getName().equalsIgnoreCase(cmd1) && args.length >= 1 && args[0].equalsIgnoreCase("reloadespionage"))
return EspionageCommands.reload(sender, args);
if(cmd.getName().equalsIgnoreCase(cmd1) && args.length >= 1 && args[0].equalsIgnoreCase("addguildbank")) {
diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java b/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java
index 81fc2436..df2cf3ae 100644
--- a/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java
+++ b/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java
@@ -381,6 +381,7 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le
completions.add("granttitle");
completions.add("usurp");
completions.add(TitleAdminCommand.SUBCOMMAND);
+ completions.add("tradegraph");
}
completions.add("reloadconfigs");
completions.add("transfersubject");
diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/network/RailRouteCacheTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/network/RailRouteCacheTest.java
new file mode 100644
index 00000000..8770367f
--- /dev/null
+++ b/src/test/java/net/tfminecraft/simplefactions/guild/network/RailRouteCacheTest.java
@@ -0,0 +1,113 @@
+package net.tfminecraft.simplefactions.guild.network;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Test;
+
+import net.tfminecraft.simplefactions.guild.network.RailRoutes.Route;
+import net.tfminecraft.simplefactions.map.infra.TrackProvinceLookup.Point;
+
+class RailRouteCacheTest {
+ @Test
+ void aMeasuredRouteIsKeptUntilForgotten() {
+ long[] now = {1_000};
+ RailRouteCache cache = new RailRouteCache(() -> now[0]);
+ int[] calls = {0};
+ Optional route = Optional.of(new Route(10, List.of(new Point(1, 2, 3), new Point(4, 5, 6))));
+
+ assertEquals(route, cache.route("a", "b", () -> {
+ calls[0]++;
+ return route;
+ }));
+ now[0] += 1_000_000;
+ assertEquals(route, cache.route("a", "b", () -> {
+ calls[0]++;
+ return Optional.empty();
+ }));
+ assertEquals(1, calls[0]);
+
+ cache.forget();
+ cache.route("a", "b", () -> {
+ calls[0]++;
+ return route;
+ });
+ assertEquals(2, calls[0]);
+ }
+
+ @Test
+ void theOppositeDirectionReusesTheCachedPointsInReverse() {
+ RailRouteCache cache = new RailRouteCache(() -> 0L);
+ int[] calls = {0};
+ Route measured = new Route(10, List.of(new Point(1, 2, 3), new Point(4, 5, 6)));
+
+ Optional forward = cache.route("b", "a", () -> {
+ calls[0]++;
+ return Optional.of(measured);
+ });
+ Optional back = cache.route("a", "b", () -> {
+ calls[0]++;
+ return Optional.empty();
+ });
+
+ assertEquals(1, calls[0]);
+ assertEquals(List.of(new Point(1, 2, 3), new Point(4, 5, 6)), forward.orElseThrow().points());
+ assertEquals(List.of(new Point(4, 5, 6), new Point(1, 2, 3)), back.orElseThrow().points());
+ assertEquals(10, back.orElseThrow().length(), 1e-9);
+ }
+
+ @Test
+ void aMissIsRetriedOnlyAfterThirtySeconds() {
+ long[] now = {1_000};
+ RailRouteCache cache = new RailRouteCache(() -> now[0]);
+ int[] calls = {0};
+
+ assertTrue(cache.route("a", "b", () -> {
+ calls[0]++;
+ return Optional.empty();
+ }).isEmpty());
+ assertTrue(cache.route("b", "a", () -> {
+ calls[0]++;
+ return Optional.empty();
+ }).isEmpty());
+ now[0] += 29_999;
+ cache.route("a", "b", () -> {
+ calls[0]++;
+ return Optional.empty();
+ });
+ assertEquals(1, calls[0]);
+
+ now[0] += 1;
+ cache.route("a", "b", () -> {
+ calls[0]++;
+ return Optional.empty();
+ });
+ assertEquals(2, calls[0]);
+ }
+
+ @Test
+ void aMissingPairCanBeMeasuredAgainOnceForgotten() {
+ RailRouteCache cache = new RailRouteCache(() -> 5_000L);
+ int[] calls = {0};
+
+ cache.route("a", "b", () -> {
+ calls[0]++;
+ return Optional.empty();
+ });
+ cache.route("a", "b", () -> {
+ calls[0]++;
+ return Optional.of(new Route(3, List.of()));
+ });
+ assertEquals(1, calls[0]);
+
+ cache.forget();
+ assertTrue(cache.route("a", "b", () -> {
+ calls[0]++;
+ return Optional.of(new Route(3, List.of()));
+ }).isPresent());
+ assertEquals(2, calls[0]);
+ }
+}
diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/network/TradeGraphCommandTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/network/TradeGraphCommandTest.java
new file mode 100644
index 00000000..1087c01b
--- /dev/null
+++ b/src/test/java/net/tfminecraft/simplefactions/guild/network/TradeGraphCommandTest.java
@@ -0,0 +1,112 @@
+package net.tfminecraft.simplefactions.guild.network;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.bukkit.command.CommandSender;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import net.tfminecraft.simplefactions.Cache;
+import net.tfminecraft.simplefactions.enums.Terrain;
+import net.tfminecraft.simplefactions.guild.network.TradeGraphBuilder.ProvinceData;
+import net.tfminecraft.simplefactions.guild.network.TradeGraphBuilder.Site;
+import net.tfminecraft.simplefactions.installation.Installation;
+import net.tfminecraft.simplefactions.installation.InstallationKind;
+import net.tfminecraft.simplefactions.utils.Permissions;
+
+class TradeGraphCommandTest {
+ private boolean provincesWereEnabled;
+
+ @BeforeEach
+ void enableProvinces() {
+ provincesWereEnabled = Cache.provincesEnabled;
+ Cache.provincesEnabled = true;
+ TradeGraph.setLiveForTests(null);
+ }
+
+ @AfterEach
+ void restore() {
+ TradeGraph.setLiveForTests(null);
+ Cache.provincesEnabled = provincesWereEnabled;
+ }
+
+ @Test
+ void aSenderWithoutPermissionIsRefused() {
+ CommandSender sender = mock(CommandSender.class);
+
+ assertTrue(TradeGraphCommand.handle(sender, new String[] {"tradegraph"}));
+
+ verify(sender).sendMessage("§a[SimpleFactions]§c You do not have access to this command");
+ verify(sender, times(1)).sendMessage(anyString());
+ }
+
+ @Test
+ void staffOnTheConsoleSeeEachNetworkNodeAndEdge() {
+ CommandSender sender = admin();
+ TradeGraph.setLiveForTests(sample());
+
+ assertTrue(TradeGraphCommand.handle(sender, new String[] {"tradegraph"}));
+
+ ArgumentCaptor messages = ArgumentCaptor.forClass(String.class);
+ verify(sender, times(4)).sendMessage(messages.capture());
+ assertEquals(List.of(
+ "§6Network 1. §72 nodes, global.",
+ "§ealpha/a §7port, province 1, level 1, 2 hub slots.",
+ "§7alpha/a to beta/b. sea, 500.0 blocks, 2 provinces.",
+ "§ebeta/b §7port, province 4, level 1, 1 hub slot."), messages.getAllValues());
+ }
+
+ @Test
+ void extraWordsShowTheUsageAndAnEmptyGraphSaysSo() {
+ CommandSender sender = admin();
+
+ assertTrue(TradeGraphCommand.handle(sender, new String[] {"tradegraph", "more"}));
+ verify(sender).sendMessage("§cUsage: §e/faction tradegraph");
+
+ assertTrue(TradeGraphCommand.handle(sender, new String[] {"tradegraph"}));
+ verify(sender).sendMessage("§7There are no trade networks.");
+ }
+
+ @Test
+ void theCommandWaitsUntilProvincesAreEnabled() {
+ CommandSender sender = admin();
+ Cache.provincesEnabled = false;
+ TradeGraph.setLiveForTests(sample());
+
+ assertTrue(TradeGraphCommand.handle(sender, new String[] {"tradegraph"}));
+
+ verify(sender).sendMessage(Cache.PROVINCES_DISABLED_MESSAGE);
+ verify(sender, times(1)).sendMessage(anyString());
+ }
+
+ private static CommandSender admin() {
+ CommandSender sender = mock(CommandSender.class);
+ when(sender.hasPermission(Permissions.Permission_Admin)).thenReturn(true);
+ return sender;
+ }
+
+ private static TradeGraph sample() {
+ Map provinces = new HashMap<>();
+ provinces.put(1, new ProvinceData(Terrain.PLAINS, java.util.Set.of(2)));
+ provinces.put(2, new ProvinceData(Terrain.SEA, java.util.Set.of(1, 3)));
+ provinces.put(3, new ProvinceData(Terrain.SEA, java.util.Set.of(2, 4)));
+ provinces.put(4, new ProvinceData(Terrain.PLAINS, java.util.Set.of(3)));
+ return TradeGraphBuilder.build(List.of(
+ new Site("alpha", new Installation("a", "a", InstallationKind.PORT, 1, 0, 0, 1L), 2, true),
+ new Site("beta", new Installation("b", "b", InstallationKind.PORT, 4, 300, 400, 1L), 1, true)),
+ provinces, (from, to) -> Optional.empty(), point -> 0);
+ }
+}
diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/network/TradeGraphTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/network/TradeGraphTest.java
new file mode 100644
index 00000000..bcd0cdd4
--- /dev/null
+++ b/src/test/java/net/tfminecraft/simplefactions/guild/network/TradeGraphTest.java
@@ -0,0 +1,436 @@
+package net.tfminecraft.simplefactions.guild.network;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import net.tfminecraft.simplefactions.enums.Terrain;
+import net.tfminecraft.simplefactions.guild.hub.HubTransport.Mode;
+import net.tfminecraft.simplefactions.guild.network.RailRoutes.Route;
+import net.tfminecraft.simplefactions.guild.network.TradeGraph.Edge;
+import net.tfminecraft.simplefactions.guild.network.TradeGraph.Network;
+import net.tfminecraft.simplefactions.guild.network.TradeGraph.Node;
+import net.tfminecraft.simplefactions.guild.network.TradeGraphBuilder.ProvinceData;
+import net.tfminecraft.simplefactions.guild.network.TradeGraphBuilder.Site;
+import net.tfminecraft.simplefactions.installation.Installation;
+import net.tfminecraft.simplefactions.installation.InstallationKind;
+import net.tfminecraft.simplefactions.map.infra.TrackProvinceLookup.Point;
+
+class TradeGraphTest {
+ private static final RailRoutes NO_TRACK = (from, to) -> Optional.empty();
+
+ @AfterEach
+ void clearLiveSnapshot() {
+ TradeGraph.setLiveForTests(null);
+ }
+
+ @Test
+ void aSeaEdgeRunsThroughWater() {
+ TradeGraph graph = sea(Terrain.WATER);
+
+ Edge edge = between(graph, "a", "b", Mode.SEA);
+ assertNotNull(edge);
+ assertEquals(List.of(2, 3), edge.provinces());
+ assertEquals(500, edge.length(), 1e-9);
+ assertContiguous(edge.provinces(), coast(Terrain.WATER));
+ assertEquals(Mode.SEA, edge.mode());
+ }
+
+ @Test
+ void aSeaEdgeRunsThroughSea() {
+ TradeGraph graph = sea(Terrain.SEA);
+
+ Edge edge = between(graph, "a", "b", Mode.SEA);
+ assertNotNull(edge);
+ assertEquals(List.of(2, 3), edge.provinces());
+ assertEquals(500, edge.length(), 1e-9);
+ assertContiguous(edge.provinces(), coast(Terrain.SEA));
+ }
+
+ @Test
+ void portsOnDifferentWaterBodiesHaveNoEdge() {
+ Map provinces = new HashMap<>();
+ provinces.put(1, land(2, 9));
+ provinces.put(2, water(Terrain.SEA, 1));
+ provinces.put(9, land(1, 3));
+ provinces.put(3, land(9, 4));
+ provinces.put(4, water(Terrain.WATER, 3));
+ provinces.put(7, land(8));
+ provinces.put(8, land(7));
+
+ TradeGraph graph = build(List.of(
+ site("alpha", "sea", InstallationKind.PORT, 1, 0, 0),
+ site("beta", "lake", InstallationKind.PORT, 3, 10, 0),
+ site("gamma", "inland", InstallationKind.PORT, 7, 20, 0)), provinces, NO_TRACK);
+
+ assertEquals(3, graph.nodes().size());
+ assertTrue(graph.edges().isEmpty());
+ }
+
+ @Test
+ void theStoredSeaPathIsTheShortestOrderedContiguousRoute() {
+ Map provinces = new HashMap<>();
+ provinces.put(1, land(10));
+ provinces.put(10, water(Terrain.SEA, 1, 11, 20));
+ provinces.put(11, water(Terrain.SEA, 10, 12));
+ provinces.put(12, water(Terrain.WATER, 11, 2));
+ provinces.put(20, water(Terrain.SEA, 10, 2));
+ provinces.put(2, land(12, 20));
+
+ TradeGraph graph = build(List.of(
+ site("alpha", "a", InstallationKind.PORT, 1, 0, 0, 2),
+ site("alpha", "b", InstallationKind.PORT, 2, 300, 400, 1)), provinces, NO_TRACK);
+
+ Edge edge = between(graph, "a", "b", Mode.SEA);
+ assertNotNull(edge);
+ assertEquals(List.of(10, 20), edge.provinces());
+ assertEquals(500, edge.length(), 1e-9);
+ assertContiguous(edge.provinces(), provinces);
+ assertTrue(provinces.get(1).neighbours().contains(edge.provinces().getFirst()));
+ assertTrue(provinces.get(2).neighbours().contains(edge.provinces().getLast()));
+ assertFalse(edge.provinces().contains(1));
+ assertFalse(edge.provinces().contains(2));
+
+ Node first = graph.node("alpha", "a");
+ Node second = graph.node("ALPHA", "B");
+ assertEquals(first, edge.first());
+ assertEquals(second, edge.second());
+ assertEquals(List.of(20, 10), edge.provincesFrom(second));
+ assertSame(edge, graph.edgesAt(first).getFirst());
+ assertSame(edge, graph.edgesAt(second).getFirst());
+ assertEquals(1, first.level());
+ assertEquals(2, first.hubSlots());
+ assertEquals(0, first.centerX());
+ assertEquals(400, second.centerZ());
+ assertNull(graph.networkOf(new Node("no", "no", InstallationKind.PORT, 1, 0, 0, 1, 1, 0)));
+ assertTrue(graph.edgesAt(new Node("no", "no", InstallationKind.PORT, 1, 0, 0, 1, 1, 0)).isEmpty());
+ }
+
+ @Test
+ void everyPairOfAirportsIsAnEdge() {
+ TradeGraph graph = build(List.of(
+ site("alpha", "a", InstallationKind.AIRPORT, 1, 0, 0),
+ site("beta", "b", InstallationKind.AIRPORT, 2, 300, 400),
+ site("gamma", "c", InstallationKind.AIRPORT, 3, 0, 600),
+ site("alpha", "station", InstallationKind.TRAIN_STATION, 4, 10, 10)), Map.of(
+ 1, land(), 2, land(), 3, land(), 4, land()), NO_TRACK);
+
+ assertEquals(3, graph.edges().size());
+ assertAir(graph, "a", "b", 500);
+ assertAir(graph, "a", "c", 600);
+ assertAir(graph, "b", "c", Math.hypot(300, 200));
+ assertTrue(graph.edgesAt(graph.node("alpha", "station")).isEmpty());
+ assertEquals(3, graph.networkOf(graph.node("gamma", "c")).size());
+ assertEquals(1, graph.networkOf(graph.node("alpha", "station")).size());
+ }
+
+ @Test
+ void aRailEdgeKeepsRouteOrderAndDropsTheEnds() {
+ Map provinces = new HashMap<>();
+ provinces.put(1, land());
+ provinces.put(2, land());
+ provinces.put(4, land());
+ provinces.put(5, land());
+ provinces.put(8, land());
+
+ TradeGraph graph = build(List.of(
+ site("alpha", "a", InstallationKind.TRAIN_STATION, 1, 0, 0),
+ site("alpha", "b", InstallationKind.PORT, 5, 10, 0),
+ site("alpha", "c", InstallationKind.TRAIN_STATION, 8, 20, 0)), provinces, (from, to) -> {
+ String left = from.getId();
+ String right = to.getId();
+ if (left.equals("a") && right.equals("b")) {
+ return Optional.of(new Route(1500, List.of(
+ new Point(1, 64, 0),
+ new Point(2, 64, 0),
+ new Point(2, 70, 0),
+ new Point(0, 64, 0),
+ new Point(99, 64, 0),
+ new Point(4, 64, 0),
+ new Point(5, 64, 0))));
+ }
+ if (left.equals("a") && right.equals("c")) return Optional.of(new Route(Double.NaN, List.of()));
+ if (left.equals("b") && right.equals("c")) return Optional.of(new Route(-5, List.of()));
+ return Optional.empty();
+ });
+
+ Edge edge = between(graph, "a", "b", Mode.RAIL);
+ assertNotNull(edge);
+ assertEquals(1500, edge.length(), 1e-9);
+ assertEquals(List.of(2, 4), edge.provinces());
+ assertEquals(List.of(4, 2), edge.provincesFrom(edge.second()));
+ assertNull(between(graph, "a", "c", Mode.RAIL));
+ assertNull(between(graph, "b", "c", Mode.RAIL));
+ assertNotNull(graph.node("alpha", "c"));
+ }
+
+ @Test
+ void railPastAFortProvinceStillJoinsTheStations() {
+ TradeGraph graph = build(List.of(
+ site("alpha", "east", InstallationKind.TRAIN_STATION, 1, 0, 0),
+ site("alpha", "west", InstallationKind.TRAIN_STATION, 5, 100, 0),
+ site("alpha", "keep", InstallationKind.FORT, 3, 50, 0)), Map.of(
+ 1, land(), 3, land(), 5, land()), (from, to) -> {
+ if (from.getId().equals("east") && to.getId().equals("west")) {
+ return Optional.of(new Route(400, List.of(
+ new Point(1, 64, 0),
+ new Point(3, 64, 0),
+ new Point(5, 64, 0))));
+ }
+ return Optional.empty();
+ });
+
+ Edge edge = between(graph, "east", "west", Mode.RAIL);
+ assertNotNull(edge);
+ assertEquals(400, edge.length(), 1e-9);
+ assertEquals(List.of(3), edge.provinces());
+ assertNull(graph.node("alpha", "keep"));
+ assertSame(graph.networkOf(graph.node("alpha", "east")), graph.networkOf(graph.node("alpha", "west")));
+ assertEquals(2, graph.nodes().size());
+ }
+
+ @Test
+ void seaAndRailBothRemainWhenAPairHasBoth() {
+ Map provinces = coast(Terrain.SEA);
+ TradeGraph graph = build(List.of(
+ site("alpha", "a", InstallationKind.PORT, 1, 0, 0),
+ site("beta", "b", InstallationKind.PORT, 4, 300, 400)), provinces,
+ (from, to) -> Optional.of(new Route(800, List.of(
+ new Point(1, 64, 0), new Point(9, 64, 0), new Point(4, 64, 0)))));
+
+ assertNotNull(between(graph, "a", "b", Mode.SEA));
+ Edge rail = between(graph, "a", "b", Mode.RAIL);
+ assertNotNull(rail);
+ assertEquals(800, rail.length(), 1e-9);
+ assertEquals(2, graph.edges().size());
+ }
+
+ @Test
+ void componentsFollowSeaAirAndRail() {
+ TradeGraph graph = connectedWorld();
+
+ assertEquals(List.of(3, 2, 1), graph.networks().stream().map(Network::size).toList());
+ assertEquals(Set.of("pa", "sa", "pb"), ids(graph.networks().get(0)));
+ assertEquals(Set.of("ab", "ag"), ids(graph.networks().get(1)));
+ assertEquals(Set.of("sg"), ids(graph.networks().get(2)));
+ Node port = graph.node("alpha", "pa");
+ assertEquals(0, port.networkIndex());
+ assertSame(graph.networks().get(0), graph.networkOf(port));
+ assertEquals(2, graph.edgesAt(port).size());
+ assertEquals(List.of(2, 3), between(graph, "pa", "pb", Mode.SEA).provinces());
+ }
+
+ @Test
+ void theSingleLargestNetworkWithTwoOwnersIsGlobal() {
+ TradeGraph graph = connectedWorld();
+
+ assertEquals(1, globals(graph));
+ assertTrue(graph.networks().get(0).global());
+ assertFalse(graph.networks().get(1).global());
+ assertFalse(graph.networks().get(2).global());
+ }
+
+ @Test
+ void aTieForTheLargestNetworkIsNotGlobal() {
+ Map provinces = new HashMap<>();
+ provinces.put(1, land());
+ provinces.put(2, land());
+ provinces.put(3, land(5));
+ provinces.put(5, water(Terrain.SEA, 3, 4));
+ provinces.put(4, land(5));
+ TradeGraph graph = build(List.of(
+ site("alpha", "p1", InstallationKind.PORT, 3, 0, 0),
+ site("alpha", "s1", InstallationKind.TRAIN_STATION, 1, 0, 0),
+ site("beta", "s2", InstallationKind.TRAIN_STATION, 2, 10, 0),
+ site("gamma", "p2", InstallationKind.PORT, 4, 20, 0)), provinces, (from, to) -> {
+ if (from.getId().equals("s1") && to.getId().equals("s2")) {
+ return Optional.of(new Route(10, List.of()));
+ }
+ return Optional.empty();
+ });
+
+ assertEquals(2, graph.networks().size());
+ assertEquals(2, graph.networks().get(0).size());
+ assertEquals(2, graph.networks().get(1).size());
+ assertEquals(0, globals(graph));
+ }
+
+ @Test
+ void aNetworkOwnedByOneFactionIsNotGlobal() {
+ TradeGraph graph = build(List.of(
+ site("alpha", "s1", InstallationKind.TRAIN_STATION, 1, 0, 0),
+ site("alpha", "s2", InstallationKind.TRAIN_STATION, 2, 10, 0),
+ site("alpha", "s3", InstallationKind.TRAIN_STATION, 3, 20, 0),
+ site("beta", "p", InstallationKind.PORT, 9, 30, 0)), Map.of(
+ 1, land(), 2, land(), 3, land(), 9, land()), (from, to) -> {
+ String left = from.getId();
+ String right = to.getId();
+ if (left.equals("s1") && right.equals("s2") || left.equals("s2") && right.equals("s3")) {
+ return Optional.of(new Route(10, List.of()));
+ }
+ return Optional.empty();
+ });
+
+ assertEquals(List.of(3, 1), graph.networks().stream().map(Network::size).toList());
+ assertEquals(Set.of("alpha"), owners(graph.networks().get(0)));
+ assertEquals(0, globals(graph));
+ }
+
+ @Test
+ void anUnfinishedInstallationIsNotANode() {
+ Map provinces = coast(Terrain.SEA);
+ TradeGraph graph = build(List.of(
+ site("alpha", "ready", InstallationKind.PORT, 1, 0, 0, 4),
+ new Site("alpha", installation("building", InstallationKind.PORT, 4, 30, 40), 2, false),
+ site("alpha", "keep", InstallationKind.FORT, 6, 0, 0)), provinces, NO_TRACK);
+
+ assertEquals(List.of("ready"), graph.nodes().stream().map(Node::installationId).toList());
+ Node ready = graph.node("Alpha", "Ready");
+ assertNotNull(ready);
+ assertEquals(1, ready.provinceId());
+ assertEquals(1, ready.level());
+ assertEquals(4, ready.hubSlots());
+ assertEquals(InstallationKind.PORT, ready.kind());
+ assertNull(graph.node("alpha", "building"));
+ assertNull(graph.node("alpha", "keep"));
+ assertTrue(graph.edges().isEmpty());
+ assertFalse(graph.networkOf(ready).global());
+ }
+
+ @Test
+ void testsCanReplaceTheLiveSnapshot() {
+ assertTrue(TradeGraph.live().nodes().isEmpty());
+ TradeGraph graph = sea(Terrain.SEA);
+ TradeGraph.setLiveForTests(graph);
+ assertSame(graph, TradeGraph.live());
+ TradeGraph.setLiveForTests(null);
+ assertTrue(TradeGraph.live().edges().isEmpty());
+ }
+
+ private static TradeGraph connectedWorld() {
+ Map provinces = new HashMap<>();
+ provinces.put(1, land(2));
+ provinces.put(2, water(Terrain.SEA, 1, 3));
+ provinces.put(3, water(Terrain.SEA, 2, 4));
+ provinces.put(4, land(3));
+ provinces.put(5, land());
+ provinces.put(6, land());
+ provinces.put(7, land());
+ provinces.put(8, land());
+ return build(List.of(
+ site("alpha", "pa", InstallationKind.PORT, 1, 0, 0),
+ site("alpha", "sa", InstallationKind.TRAIN_STATION, 5, 5, 0),
+ site("beta", "ab", InstallationKind.AIRPORT, 6, 6, 0),
+ site("beta", "pb", InstallationKind.PORT, 4, 30, 40),
+ site("gamma", "ag", InstallationKind.AIRPORT, 7, 7, 0),
+ site("gamma", "sg", InstallationKind.TRAIN_STATION, 8, 8, 0)), provinces, (from, to) -> {
+ if (from.getId().equals("pa") && to.getId().equals("sa")) {
+ return Optional.of(new Route(100, List.of()));
+ }
+ return Optional.empty();
+ });
+ }
+
+ private static TradeGraph sea(Terrain water) {
+ return build(List.of(
+ site("alpha", "a", InstallationKind.PORT, 1, 0, 0),
+ site("beta", "b", InstallationKind.PORT, 4, 300, 400)), coast(water), NO_TRACK);
+ }
+
+ /** Land, water, water, land. Ports sit on the land ends. */
+ private static Map coast(Terrain water) {
+ Map provinces = new HashMap<>();
+ provinces.put(1, land(2));
+ provinces.put(2, water(water, 1, 3));
+ provinces.put(3, water(water, 2, 4));
+ provinces.put(4, land(3));
+ return provinces;
+ }
+
+ private static TradeGraph build(List sites, Map provinces, RailRoutes rail) {
+ return TradeGraphBuilder.build(sites, provinces, rail, point -> (int) Math.floor(point.x()));
+ }
+
+ private static Site site(String faction, String id, InstallationKind kind, int province, int x, int z) {
+ return site(faction, id, kind, province, x, z, 1);
+ }
+
+ private static Site site(
+ String faction, String id, InstallationKind kind, int province, int x, int z, int hubSlots) {
+ return new Site(faction, installation(id, kind, province, x, z), hubSlots, true);
+ }
+
+ private static Installation installation(String id, InstallationKind kind, int province, int x, int z) {
+ return new Installation(id, id, kind, province, x, z, 1L);
+ }
+
+ private static ProvinceData land(int... neighbours) {
+ return new ProvinceData(Terrain.PLAINS, ids(neighbours));
+ }
+
+ private static ProvinceData water(Terrain terrain, int... neighbours) {
+ return new ProvinceData(terrain, ids(neighbours));
+ }
+
+ private static Set ids(int... values) {
+ Set ids = new HashSet<>();
+ for (int value : values) ids.add(value);
+ return ids;
+ }
+
+ private static void assertAir(TradeGraph graph, String a, String b, double length) {
+ Edge edge = between(graph, a, b, Mode.AIR);
+ assertNotNull(edge);
+ assertTrue(edge.provinces().isEmpty());
+ assertEquals(length, edge.length(), 1e-9);
+ }
+
+ private static void assertContiguous(List path, Map provinces) {
+ assertFalse(path.isEmpty());
+ for (int i = 1; i < path.size(); i++) {
+ int previous = path.get(i - 1);
+ int next = path.get(i);
+ assertTrue(provinces.get(previous).neighbours().contains(next),
+ previous + " should neighbour " + next);
+ }
+ }
+
+ private static Edge between(TradeGraph graph, String a, String b, Mode mode) {
+ for (Edge edge : graph.edges()) {
+ if (edge.mode() != mode) continue;
+ String first = edge.first().installationId();
+ String second = edge.second().installationId();
+ if (first.equals(a) && second.equals(b) || first.equals(b) && second.equals(a)) return edge;
+ }
+ return null;
+ }
+
+ private static Set ids(Network network) {
+ Set ids = new HashSet<>();
+ for (Node node : network.nodes()) ids.add(node.installationId());
+ return ids;
+ }
+
+ private static Set owners(Network network) {
+ Set owners = new HashSet<>();
+ for (Node node : network.nodes()) owners.add(node.ownerFactionId());
+ return owners;
+ }
+
+ private static long globals(TradeGraph graph) {
+ return graph.networks().stream().filter(Network::global).count();
+ }
+}