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(); + } +}