From eeb3e042841ca21d2463a03394097a2742fe388e Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:03:11 +0000 Subject: [PATCH 1/2] Add per-province infrastructure that raises effective terrain. Guilds on poor land can close part of the gap to plains when infrastructure is present, and income is unchanged while every source is zero. --- .../net/tfminecraft/simplefactions/Cache.java | 8 + .../enums/FactionModifiers.java | 1 + .../simplefactions/enums/GuildModifier.java | 1 + .../simplefactions/laws/LawEffect.java | 14 + .../simplefactions/loaders/ConfigLoader.java | 7 + .../managers/ProvinceManager.java | 25 ++ .../map/infra/EffectiveTerrain.java | 11 + .../map/infra/InfrastructureAccess.java | 71 +++++ .../map/infra/InfrastructureSources.java | 45 ++++ .../map/infra/InfrastructureSpread.java | 49 ++++ .../map/provinces/Province.java | 21 +- src/main/resources/config.yml | 10 + src/main/resources/laws.yml | 12 + .../ConfigLoaderInfrastructureTest.java | 90 +++++++ .../map/infra/EffectiveTerrainTest.java | 39 +++ .../map/infra/InfrastructureAccessTest.java | 198 ++++++++++++++ .../map/infra/InfrastructureSourcesTest.java | 150 +++++++++++ .../map/infra/InfrastructureSpreadTest.java | 135 ++++++++++ .../map/infra/ProvinceInfrastructureTest.java | 249 ++++++++++++++++++ 19 files changed, 1132 insertions(+), 4 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/map/infra/EffectiveTerrain.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/map/infra/InfrastructureAccess.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/map/infra/InfrastructureSources.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/map/infra/InfrastructureSpread.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/loaders/ConfigLoaderInfrastructureTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/map/infra/EffectiveTerrainTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/map/infra/InfrastructureAccessTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/map/infra/InfrastructureSourcesTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/map/infra/InfrastructureSpreadTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/map/infra/ProvinceInfrastructureTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/Cache.java b/src/main/java/net/tfminecraft/simplefactions/Cache.java index dbba107a..5d8ae5cd 100644 --- a/src/main/java/net/tfminecraft/simplefactions/Cache.java +++ b/src/main/java/net/tfminecraft/simplefactions/Cache.java @@ -167,6 +167,14 @@ public static boolean requireProvinces(org.bukkit.command.CommandSender sender) public static double supplyHubMaxTax = 50.0; + public static double infrastructureFull = 20; + public static double infrastructureTarget = 0.75; + public static double infrastructureWildernessSpread = 0.25; + public static double infrastructureSpreadFloor = 0.5; + public static double infrastructureStation = 10; + public static double infrastructurePort = 10; + public static double infrastructureAirport = 5; + public static Map baseEffects = new HashMap<>(); public static Map tradeCarry = new HashMap<>(); diff --git a/src/main/java/net/tfminecraft/simplefactions/enums/FactionModifiers.java b/src/main/java/net/tfminecraft/simplefactions/enums/FactionModifiers.java index c88c8ba4..141e3bb2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/enums/FactionModifiers.java +++ b/src/main/java/net/tfminecraft/simplefactions/enums/FactionModifiers.java @@ -14,6 +14,7 @@ public enum FactionModifiers { STABILITY_INFLUENCE(true, false), TRADE_POWER(true, true), PRODUCTION(true, true), + INFRASTRUCTURE_ACCESS(true, true), DIPLOMATIC_CAPACITY_MULTIPLIER(true, false), ADMIN_POWER_MULTIPLIER(true, false), ADMIN_POWER_GAIN_MULTIPLIER(true, false); diff --git a/src/main/java/net/tfminecraft/simplefactions/enums/GuildModifier.java b/src/main/java/net/tfminecraft/simplefactions/enums/GuildModifier.java index 13e2491a..bd8dc54e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/enums/GuildModifier.java +++ b/src/main/java/net/tfminecraft/simplefactions/enums/GuildModifier.java @@ -5,6 +5,7 @@ public enum GuildModifier { TRADE_POWER("#92d665Trade Power", true), TRADE_UPKEEP("#d6645aTrade Upkeep", false), PRODUCTION("#f2c94cProduction", true), + INFRASTRUCTURE("#86d1b0Infrastructure", true), TRADE_CARRY("#86d1b0Trade Carry", true), HUB_LIMIT("#c99b70Supply Hub Limit", true), HUB_UPKEEP("#d6645aSupply Hub Upkeep", false), diff --git a/src/main/java/net/tfminecraft/simplefactions/laws/LawEffect.java b/src/main/java/net/tfminecraft/simplefactions/laws/LawEffect.java index ef5ff9c4..725f1d44 100644 --- a/src/main/java/net/tfminecraft/simplefactions/laws/LawEffect.java +++ b/src/main/java/net/tfminecraft/simplefactions/laws/LawEffect.java @@ -13,6 +13,7 @@ import net.tfminecraft.simplefactions.objects.Bracket; import net.tfminecraft.simplefactions.objects.FactionModifier; import net.tfminecraft.simplefactions.enums.Brackets; +import net.tfminecraft.simplefactions.enums.FactionModifiers; import net.tfminecraft.simplefactions.enums.Region; import net.tfminecraft.simplefactions.enums.Rules; import net.tfminecraft.simplefactions.enums.Scope; @@ -171,6 +172,19 @@ public Map> getRegionModifiers() { return regionModifiers; } + /** Null means absent; an explicit zero still overrides a default. */ + public Double getModifierAmount(FactionModifiers type, Region region) { + Double amount = null; + List modifiers = new ArrayList<>(globalModifiers); + modifiers.addAll(regionModifiers.getOrDefault(region, List.of())); + for (FactionModifier modifier : modifiers) { + if (modifier.getType() == type) { + amount = (amount == null ? 0 : amount) + modifier.getAmount(); + } + } + return amount; + } + public Map getRules() { return rules; } diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java index e3bf4efb..72ec396b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java @@ -73,6 +73,13 @@ public void loadConfig(File configFile) { Cache.mercenaryDefaultBreachRefund = config.getDouble("mercenary-default-breach-refund", 500.0); net.tfminecraft.simplefactions.guild.hub.SupplyHubService.loadConfig(config); net.tfminecraft.simplefactions.guild.hub.HubTransport.loadConfig(config); + Cache.infrastructureFull = config.getDouble("infrastructure.full", 20); + Cache.infrastructureTarget = config.getDouble("infrastructure.target", 0.75); + Cache.infrastructureWildernessSpread = config.getDouble("infrastructure.wilderness-spread", 0.25); + Cache.infrastructureSpreadFloor = config.getDouble("infrastructure.spread-floor", 0.5); + Cache.infrastructureStation = config.getDouble("infrastructure.station", 10); + Cache.infrastructurePort = config.getDouble("infrastructure.port", 10); + Cache.infrastructureAirport = config.getDouble("infrastructure.airport", 5); Cache.settlementLargePopulationThreshold = config.getInt("settlement-large-population-threshold", 8); Cache.portSeaProximityBlocks = config.getInt("port-sea-proximity-blocks", 20); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java index 60fc7011..5014f703 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import org.bukkit.Bukkit; @@ -18,6 +19,9 @@ import net.tfminecraft.simplefactions.guild.hub.HubTransport.Link; import net.tfminecraft.simplefactions.map.provinces.Province; import net.tfminecraft.simplefactions.map.provinces.ProvinceDataEntry; +import net.tfminecraft.simplefactions.map.infra.InfrastructureAccess; +import net.tfminecraft.simplefactions.map.infra.InfrastructureSources; +import net.tfminecraft.simplefactions.map.infra.InfrastructureSpread; import net.tfminecraft.simplefactions.objects.Bracket; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.Cache; @@ -107,6 +111,7 @@ public void recalculate() { return; } HubNetwork.refreshIfLive(this); + recalculateInfrastructure(); dropMissingGuilds(); for(Guild g : FactionManager.getAllGuilds()) { if (!g.hasCapital()) continue; @@ -124,6 +129,7 @@ public void recalculateForSingleGuild(Guild g, boolean save) { if (!Cache.provincesEnabled) { return; } + recalculateInfrastructure(); if (!g.hasCapital()) return; HubNetwork.refreshIfLive(this); dropMissingGuilds(); @@ -137,6 +143,24 @@ public void recalculateForSingleGuild(Guild g, boolean save) { recalculateProsperity(); } + private void recalculateInfrastructure() { + Map graph = new HashMap<>(); + for (Province province : provinces.values()) { + Faction realm = InfrastructureAccess.topRealm(province.getOwner()); + String origin = realm == null ? null : realm.getId().toLowerCase(Locale.ROOT); + graph.put(province.getId(), new InfrastructureSpread.Node( + province.getTradeCarry(), !province.isSea(), origin, province.getNeighbours())); + } + Map sources = InfrastructureSources.collect( + provinces, FactionManager.getAllGuilds(), FactionManager.getCopy()); + Map infrastructure = InfrastructureSpread.spread( + graph, sources, Cache.infrastructureWildernessSpread, Cache.infrastructureSpreadFloor); + for (Province province : provinces.values()) { + InfrastructureSpread.Arrival arrival = infrastructure.get(province.getId()); + province.setInfrastructure(arrival == null ? 0 : arrival.amount()); + } + } + private void recalculateProsperity() { for (Province p : provinces.values()) { p.calculateProsperity(); @@ -326,6 +350,7 @@ public void copyAllDataFrom(ProvinceManager source) { } dst.setProsperity(src.getProsperity()); + dst.setInfrastructure(src.getInfrastructure()); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/map/infra/EffectiveTerrain.java b/src/main/java/net/tfminecraft/simplefactions/map/infra/EffectiveTerrain.java new file mode 100644 index 00000000..6f1f9b03 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/map/infra/EffectiveTerrain.java @@ -0,0 +1,11 @@ +package net.tfminecraft.simplefactions.map.infra; + +public final class EffectiveTerrain { + private EffectiveTerrain() {} + + public static double calculate(double terrain, double infrastructure, double full, double target, double access) { + if (terrain >= target || infrastructure <= 0 || full <= 0) return terrain; + double fill = Math.min(1, infrastructure / full); + return terrain + (target - terrain) * fill * access; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/map/infra/InfrastructureAccess.java b/src/main/java/net/tfminecraft/simplefactions/map/infra/InfrastructureAccess.java new file mode 100644 index 00000000..34e9c9e8 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/map/infra/InfrastructureAccess.java @@ -0,0 +1,71 @@ +package net.tfminecraft.simplefactions.map.infra; + +import java.util.HashSet; +import java.util.Set; + +import net.tfminecraft.simplefactions.enums.FactionModifiers; +import net.tfminecraft.simplefactions.enums.Region; +import net.tfminecraft.simplefactions.enums.Scope; +import net.tfminecraft.simplefactions.guild.income.IncomePreviewContext; +import net.tfminecraft.simplefactions.laws.Law; +import net.tfminecraft.simplefactions.laws.LawEffect; +import net.tfminecraft.simplefactions.laws.LawGroup; +import net.tfminecraft.simplefactions.objects.Faction; + +public final class InfrastructureAccess { + private InfrastructureAccess() {} + + public static double forGuild(Faction guildFaction, Faction owner) { + if (owner == null) return 1; + if (guildFaction != null && sameFaction(topRealm(guildFaction), topRealm(owner))) return 1; + double grant = amount(economyLaw(owner), Scope.FOREIGN_GUILDS, Region.OUR_TERRITORY); + double reach = amount(economyLaw(guildFaction), Scope.DOMESTIC_GUILDS, Region.FOREIGN_TERRITORY); + return Math.max(0, Math.min(1, grant + reach)); + } + + public static Faction topRealm(Faction faction) { + Set seen = new HashSet<>(); + Faction current = faction; + while (current != null) { + // Ignore a cyclic overlord chain rather than granting access through it. + if (!seen.add(current)) return faction; + Faction overlord = current.getOverlord(); + if (overlord == null) return current; + current = overlord; + } + return null; + } + + private static boolean sameFaction(Faction first, Faction second) { + return first == second || (first != null && second != null && first.getId() != null + && first.getId().equalsIgnoreCase(second.getId())); + } + + private static Law economyLaw(Faction faction) { + if (faction == null || faction.getLawHandler() == null) return null; + LawGroup group = faction.getLawHandler().getGroup("economy"); + if (group == null) return null; + IncomePreviewContext context = IncomePreviewContext.current(); + Law overlay = context != null && context.previewsLaw(faction) + ? IncomePreviewContext.overlayLaw(group) : null; + return overlay != null ? overlay : group.getCurrent(); + } + + public static double amount(Law law, Scope scope, Region region) { + if (law == null) return 0; + LawEffect effect = law.getScopedEffects().get(scope); + Double explicit = effect == null ? null : effect.getModifierAmount(FactionModifiers.INFRASTRUCTURE_ACCESS, region); + if (explicit != null) return explicit; + boolean grant = scope == Scope.FOREIGN_GUILDS && region == Region.OUR_TERRITORY; + boolean reach = scope == Scope.DOMESTIC_GUILDS && region == Region.FOREIGN_TERRITORY; + if (!grant && !reach) return 0; + return switch (law.getId()) { + case "free_trade" -> grant ? 0.80 : 0.20; + case "mercantilism" -> grant ? 0.50 : 0.20; + case "decentralized" -> grant ? 0.50 : 0.00; + case "protectionism" -> grant ? 0.20 : -0.10; + case "isolationism" -> grant ? 0.00 : -0.30; + default -> 0; + }; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/map/infra/InfrastructureSources.java b/src/main/java/net/tfminecraft/simplefactions/map/infra/InfrastructureSources.java new file mode 100644 index 00000000..0a14d0bc --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/map/infra/InfrastructureSources.java @@ -0,0 +1,45 @@ +package net.tfminecraft.simplefactions.map.infra; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.enums.GuildModifier; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.GuildModifierOverride; +import net.tfminecraft.simplefactions.installation.Installation; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Faction; + +public final class InfrastructureSources { + private InfrastructureSources() {} + + public static Map collect( + Map provinces, Collection guilds, Collection factions) { + Map sources = new HashMap<>(); + for (Guild guild : guilds) { + if (guild == null || !guild.isBase() || !guild.hasCapital()) continue; + add(provinces, sources, guild.getCapital(), GuildModifierOverride.resolve(guild, GuildModifier.INFRASTRUCTURE)); + } + for (Faction faction : factions) { + if (faction == null || faction.getInstallationHandler() == null) continue; + for (Installation installation : faction.getInstallationHandler().getAll()) { + double amount = switch (installation.getKind()) { + case TRAIN_STATION -> Cache.infrastructureStation; + case PORT -> Cache.infrastructurePort; + case AIRPORT -> Cache.infrastructureAirport; + default -> 0; + }; + add(provinces, sources, installation.getProvince(), amount); + } + } + return sources; + } + + private static void add(Map provinces, Map sources, int id, double amount) { + Province province = provinces.get(id); + if (province == null || province.isSea() || amount <= 0) return; + sources.merge(id, amount, Double::sum); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/map/infra/InfrastructureSpread.java b/src/main/java/net/tfminecraft/simplefactions/map/infra/InfrastructureSpread.java new file mode 100644 index 00000000..5c41a59d --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/map/infra/InfrastructureSpread.java @@ -0,0 +1,49 @@ +package net.tfminecraft.simplefactions.map.infra; + +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.PriorityQueue; + +public final class InfrastructureSpread { + private InfrastructureSpread() {} + + public record Node(double terrain, boolean land, String realm, Collection neighbours) {} + public record Arrival(double amount, String origin) {} + private record Step(int province, Arrival arrival) {} + + public static Map spread( + Map provinces, Map sources, double wilderness, double floor) { + Map best = new HashMap<>(); + PriorityQueue pending = new PriorityQueue<>( + Comparator.comparingDouble((Step step) -> step.arrival().amount()).reversed() + .thenComparingInt(Step::province)); + for (Map.Entry source : sources.entrySet()) { + Node province = provinces.get(source.getKey()); + if (province == null || !province.land() || source.getValue() <= 0) continue; + Arrival arrival = new Arrival(source.getValue(), province.realm()); + best.put(source.getKey(), arrival); + pending.add(new Step(source.getKey(), arrival)); + } + while (!pending.isEmpty()) { + Step step = pending.remove(); + Arrival here = step.arrival(); + if (!here.equals(best.get(step.province())) || here.amount() < floor) continue; + for (Integer neighbour : provinces.get(step.province()).neighbours()) { + Node entered = provinces.get(neighbour); + if (entered == null || !entered.land()) continue; + if (entered.realm() != null && !Objects.equals(entered.realm(), here.origin())) continue; + double amount = here.amount() * entered.terrain(); + if (entered.realm() == null) amount *= wilderness; + Arrival previous = best.get(neighbour); + if (amount < floor || (previous != null && amount <= previous.amount())) continue; + Arrival arrival = new Arrival(amount, here.origin()); + best.put(neighbour, arrival); + pending.add(new Step(neighbour, arrival)); + } + } + return best; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/map/provinces/Province.java b/src/main/java/net/tfminecraft/simplefactions/map/provinces/Province.java index bdb01857..cc0661b2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/map/provinces/Province.java +++ b/src/main/java/net/tfminecraft/simplefactions/map/provinces/Province.java @@ -17,6 +17,8 @@ import net.tfminecraft.simplefactions.managers.TitleManager; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.objects.FactionModifier; +import net.tfminecraft.simplefactions.map.infra.EffectiveTerrain; +import net.tfminecraft.simplefactions.map.infra.InfrastructureAccess; import net.tfminecraft.simplefactions.utils.ModifierMerger; import net.tfminecraft.simplefactions.enums.FactionModifiers; import net.tfminecraft.simplefactions.enums.GuildModifier; @@ -32,6 +34,7 @@ public class Province { private int centerX; private int centerZ; private double prosperity = 0; + private double infrastructure = 0; private final Set neighbours = new HashSet<>(); public Province() { @@ -88,7 +91,7 @@ public void calculateTrade( double amount; double carry = GuildModifierOverride.resolve(guild, GuildModifier.TRADE_CARRY); double effectiveDistance = distance / Math.pow(carry, 1.1); - double factor = Math.pow(getTradeCarry(), effectiveDistance); + double factor = Math.pow(getTradeCarry(guild), effectiveDistance); if (prev == -1) { // Capital province amount = net.tfminecraft.simplefactions.government.stability.StabilityDebuffs.realmSeed( @@ -130,7 +133,7 @@ public void calculateProduction( int distance ) { double amount; - double terrainFactor = Math.pow(getTradeCarry(), 0.5); + double terrainFactor = Math.pow(getTradeCarry(guild), 0.5); double factor = terrainFactor*getTradeFactor(guild); if (prev == null) { @@ -167,7 +170,7 @@ public void calculateProduction( public double getTradeFactor(Guild guild) { double trade = getGuildTrade(guild); if(trade == 0) return 0.05; - double K = 2.5 / Math.pow(getTradeCarry(), 0.5); + double K = 2.5 / Math.pow(getTradeCarry(guild), 0.5); return (trade / (trade + K)); } @@ -320,7 +323,7 @@ private void spreadHubProduction( ProvinceDataEntry prev, int distance ) { - double factor = Math.pow(getTradeCarry(), 0.5) * getTradeFactor(guild); + double factor = Math.pow(getTradeCarry(guild), 0.5) * getTradeFactor(guild); double amount = prev.getProduction() * factor; if (amount < 0.1) return; @@ -346,6 +349,16 @@ private void spreadHubProduction( public double getTradeCarry() { return Cache.tradeCarry.getOrDefault(terrain, 0.5); } + + public double getTradeCarry(Guild guild) { + double terrain = getTradeCarry(); + if (guild == null || isSea() || infrastructure <= 0 || terrain >= Cache.infrastructureTarget) return terrain; + return EffectiveTerrain.calculate(terrain, infrastructure, Cache.infrastructureFull, + Cache.infrastructureTarget, InfrastructureAccess.forGuild(guild.getFaction(), getOwner())); + } + + public double getInfrastructure() { return infrastructure; } + public void setInfrastructure(double amount) { infrastructure = amount; } public Set getNeighbours() { return Collections.unmodifiableSet(neighbours); } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index e22b0d0a..39ab4746 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -184,6 +184,16 @@ elevation-size-multiplier: 1.0 elevation-exponent: 1.1 eviction-multiplier: 2.0 +# Infrastructure closes the gap from raw land terrain to target at full strength. +infrastructure: + full: 20 + target: 0.75 + wilderness-spread: 0.25 + spread-floor: 0.5 + station: 10 + port: 10 + airport: 5 + terrain-modifiers: - FARMLAND 0.95 - PLAINS 0.85 diff --git a/src/main/resources/laws.yml b/src/main/resources/laws.yml index 669cd868..39f24ff0 100644 --- a/src/main/resources/laws.yml +++ b/src/main/resources/laws.yml @@ -669,9 +669,11 @@ economy: foreign_guilds: our_territory: - trade_power(5.0) + - infrastructure_access(0.50) domestic_guilds: foreign_territory: - trade_power(-5.0) + - infrastructure_access(0.00) wilderness: - trade_power(-5.0) isolationism: @@ -695,12 +697,14 @@ economy: our_territory: - trade_power(-80.0) - production(-50.0) + - infrastructure_access(0.00) domestic_guilds: our_territory: - production(30.0) foreign_territory: - trade_power(-60.0) - production(-80.0) + - infrastructure_access(-0.30) wilderness: - trade_power(-60.0) - production(-50.0) @@ -725,6 +729,7 @@ economy: our_territory: - trade_power(-25.0) - production(-15.0) + - infrastructure_access(0.20) domestic_guilds: our_territory: - production(20.0) @@ -732,6 +737,7 @@ economy: foreign_territory: - trade_power(-20.0) - production(-20.0) + - infrastructure_access(-0.10) wilderness: - trade_power(-15.0) - production(-10.0) @@ -752,9 +758,13 @@ economy: brackets: tariffs: "0-25" hub_tax: "0-25" + foreign_guilds: + our_territory: + - infrastructure_access(0.50) domestic_guilds: foreign_territory: - trade_power(10.0) + - infrastructure_access(0.20) wilderness: - trade_power(10.0) free_trade: @@ -778,9 +788,11 @@ economy: foreign_guilds: our_territory: - trade_power(15.0) + - infrastructure_access(0.80) domestic_guilds: foreign_territory: - trade_power(15.0) + - infrastructure_access(0.20) wilderness: - trade_power(15.0) diff --git a/src/test/java/net/tfminecraft/simplefactions/loaders/ConfigLoaderInfrastructureTest.java b/src/test/java/net/tfminecraft/simplefactions/loaders/ConfigLoaderInfrastructureTest.java new file mode 100644 index 00000000..3a1ec253 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/loaders/ConfigLoaderInfrastructureTest.java @@ -0,0 +1,90 @@ +package net.tfminecraft.simplefactions.loaders; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import net.tfminecraft.simplefactions.Cache; + +class ConfigLoaderInfrastructureTest { + @TempDir + Path tempDir; + private final double[] previous = values(); + + @AfterEach + void restore() { + Cache.infrastructureFull = previous[0]; + Cache.infrastructureTarget = previous[1]; + Cache.infrastructureWildernessSpread = previous[2]; + Cache.infrastructureSpreadFloor = previous[3]; + Cache.infrastructureStation = previous[4]; + Cache.infrastructurePort = previous[5]; + Cache.infrastructureAirport = previous[6]; + } + + @Test + void missingSectionUsesDefaultsAndResetsPreviousSettings() throws IOException { + load(""" + infrastructure: + full: 30 + target: 0.80 + wilderness-spread: 0.10 + spread-floor: 0.25 + station: 12 + port: 8 + airport: 4 + """); + load("enable-map: false\n"); + + assertValues(20, 0.75, 0.25, 0.5, 10, 10, 5); + } + + @Test + void missingKeysUseDefaultsIndependently() throws IOException { + load(""" + infrastructure: + full: 30 + """); + + assertValues(30, 0.75, 0.25, 0.5, 10, 10, 5); + } + + @Test + void configuredKeysAreReadIntoCache() throws IOException { + load(""" + infrastructure: + full: 30 + target: 0.80 + wilderness-spread: 0.10 + spread-floor: 0.25 + station: 12 + port: 8 + airport: 4 + """); + + assertValues(30, 0.80, 0.10, 0.25, 12, 8, 4); + } + + private void load(String yaml) throws IOException { + Path file = tempDir.resolve("config.yml"); + Files.writeString(file, yaml); + new ConfigLoader().loadConfig(file.toFile()); + } + + private static double[] values() { + return new double[] {Cache.infrastructureFull, Cache.infrastructureTarget, + Cache.infrastructureWildernessSpread, Cache.infrastructureSpreadFloor, + Cache.infrastructureStation, Cache.infrastructurePort, Cache.infrastructureAirport}; + } + + private static void assertValues(double... expected) { + double[] actual = values(); + for (int i = 0; i < expected.length; i++) assertEquals(expected[i], actual[i], 1e-9); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/map/infra/EffectiveTerrainTest.java b/src/test/java/net/tfminecraft/simplefactions/map/infra/EffectiveTerrainTest.java new file mode 100644 index 00000000..d791740c --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/map/infra/EffectiveTerrainTest.java @@ -0,0 +1,39 @@ +package net.tfminecraft.simplefactions.map.infra; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class EffectiveTerrainTest { + @Test + void zeroInfrastructureLeavesRawTerrainAlone() { + for (double terrain : new double[] {0.80, 0.75, 0.40, 0.30}) { + assertEquals(terrain, EffectiveTerrain.calculate(terrain, 0, 20, 0.75, 1)); + } + } + + @Test + void fullInfrastructureFillsTheGapAndStopsAtFull() { + assertEquals(0.75, EffectiveTerrain.calculate(0.40, 20, 20, 0.75, 1), 1e-9); + assertEquals(0.75, EffectiveTerrain.calculate(0.40, 40, 20, 0.75, 1), 1e-9); + } + + @Test + void halfInfrastructureFillsHalfTheGap() { + assertEquals(0.575, EffectiveTerrain.calculate(0.40, 10, 20, 0.75, 1), 1e-9); + } + + @Test + void accessScalesTheGap() { + assertEquals(0.575, EffectiveTerrain.calculate(0.40, 20, 20, 0.75, 0.5), 1e-9); + assertEquals(0.40, EffectiveTerrain.calculate(0.40, 20, 20, 0.75, 0), 1e-9); + } + + @Test + void terrainAtOrAboveTheTargetIsUnchangedAtAnyFill() { + for (double infrastructure : new double[] {0, 10, 20, 40}) { + assertEquals(0.80, EffectiveTerrain.calculate(0.80, infrastructure, 20, 0.75, 1)); + assertEquals(0.75, EffectiveTerrain.calculate(0.75, infrastructure, 20, 0.75, 1)); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/map/infra/InfrastructureAccessTest.java b/src/test/java/net/tfminecraft/simplefactions/map/infra/InfrastructureAccessTest.java new file mode 100644 index 00000000..abf292de --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/map/infra/InfrastructureAccessTest.java @@ -0,0 +1,198 @@ +package net.tfminecraft.simplefactions.map.infra; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.enums.FactionModifiers; +import net.tfminecraft.simplefactions.enums.Region; +import net.tfminecraft.simplefactions.enums.Scope; +import net.tfminecraft.simplefactions.guild.income.IncomePreviewContext; +import net.tfminecraft.simplefactions.laws.Law; +import net.tfminecraft.simplefactions.laws.LawGroup; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.handler.LawHandler; + +class InfrastructureAccessTest { + private YamlConfiguration config; + + @BeforeEach + void setUp() { + config = YamlConfiguration.loadConfiguration(new InputStreamReader( + getClass().getClassLoader().getResourceAsStream("laws.yml"), StandardCharsets.UTF_8)); + } + + @AfterEach + void restore() { + IncomePreviewContext.clear(); + } + + @Test + void foreignAccessMatchesTheDesignExamples() { + Faction free = faction("free", "free_trade"); + Faction mercantilist = faction("merchant", "mercantilism"); + Faction protectionist = faction("protected", "protectionism"); + + assertEquals(1, InfrastructureAccess.forGuild(mercantilist, free), 1e-9); + assertEquals(0.70, InfrastructureAccess.forGuild(protectionist, free), 1e-9); + assertEquals(0.40, InfrastructureAccess.forGuild(free, protectionist), 1e-9); + } + + @Test + void siblingsUnderTheSameTopOverlordHaveFullAccess() { + Faction top = faction("top", "isolationism"); + Faction middle = faction("middle", "isolationism"); + Faction guild = faction("guild", "isolationism"); + Faction owner = faction("owner", "isolationism"); + when(middle.getOverlord()).thenReturn(top); + when(guild.getOverlord()).thenReturn(middle); + when(owner.getOverlord()).thenReturn(top); + + assertEquals(1, InfrastructureAccess.forGuild(guild, owner)); + assertEquals(1, InfrastructureAccess.forGuild(owner, guild)); + assertEquals(1, InfrastructureAccess.forGuild(top, guild)); + } + + @Test + void unownedLandHasFullAccess() { + assertEquals(1, InfrastructureAccess.forGuild(faction("guild", "isolationism"), null)); + } + + @Test + void explicitZeroDoesNotFallThroughToTheTable() { + config.set("economy.laws.free_trade.effects.foreign_guilds.our_territory", + List.of("infrastructure_access(0.00)")); + config.set("economy.laws.free_trade.effects.domestic_guilds.foreign_territory", + List.of("infrastructure_access(0.00)")); + Law law = group().getLaw("free_trade"); + + assertEquals(0, grant(law)); + assertEquals(0, reach(law)); + assertTrue(law.affectsEconomy()); + } + + @Test + void allMissingLinesFallBackToTheTable() { + for (String id : config.getConfigurationSection("economy.laws").getKeys(false)) { + config.set("economy.laws." + id + ".effects.foreign_guilds.our_territory", List.of("trade_power(5)")); + config.set("economy.laws." + id + ".effects.domestic_guilds.foreign_territory", List.of("trade_power(5)")); + } + assertTable(group()); + } + + @Test + void bundledLawLinesMatchTheTable() { + assertTable(group()); + for (Law law : group().getLaws().values()) { + assertTrue(law.getScopedEffects().get(Scope.FOREIGN_GUILDS) + .getModifierAmount(FactionModifiers.INFRASTRUCTURE_ACCESS, Region.OUR_TERRITORY) != null); + assertTrue(law.getScopedEffects().get(Scope.DOMESTIC_GUILDS) + .getModifierAmount(FactionModifiers.INFRASTRUCTURE_ACCESS, Region.FOREIGN_TERRITORY) != null); + } + } + + @Test + void everyMatchingModifierIsSummedForItsScopeAndRegion() { + config.set("economy.laws.free_trade.effects.foreign_guilds.modifiers", + List.of("infrastructure_access(0.10)")); + config.set("economy.laws.free_trade.effects.foreign_guilds.our_territory", + List.of("infrastructure_access(0.20)", "infrastructure_access(0.30)", "trade_power(50)")); + config.set("economy.laws.free_trade.effects.foreign_guilds.foreign_territory", + List.of("infrastructure_access(0.90)")); + + assertEquals(0.60, grant(group().getLaw("free_trade")), 1e-9); + } + + @Test + void unknownEconomyLawHasNoFallbackAccess() { + Law law = new Law("economy", "unknown", new YamlConfiguration()); + assertEquals(0, grant(law)); + assertEquals(0, reach(law)); + } + + @Test + void foreignAccessIsClampedAtZero() { + assertEquals(0, InfrastructureAccess.forGuild( + faction("guild", "isolationism"), faction("owner", "protectionism"))); + } + + @Test + void previewsChangeBothGrantAndReachWithoutChangingLiveLaws() { + Faction owner = faction("owner", "isolationism"); + Faction guild = faction("guild", "protectionism"); + LawGroup ownerGroup = owner.getLawHandler().getGroup("economy"); + LawGroup guildGroup = guild.getLawHandler().getGroup("economy"); + Law old = ownerGroup.getCurrent(); + + IncomePreviewContext.open(IncomePreviewContext.law(owner, ownerGroup, ownerGroup.getLaw("free_trade"))); + assertEquals(0.70, InfrastructureAccess.forGuild(guild, owner), 1e-9); + IncomePreviewContext.clear(); + assertSame(old, ownerGroup.getCurrent()); + assertEquals(0, InfrastructureAccess.forGuild(guild, owner)); + + ownerGroup.setCurrent(ownerGroup.getLaw("protectionism")); + IncomePreviewContext.open(IncomePreviewContext.law(guild, guildGroup, guildGroup.getLaw("free_trade"))); + assertEquals(0.40, InfrastructureAccess.forGuild(guild, owner), 1e-9); + IncomePreviewContext.clear(); + assertEquals("protectionism", guildGroup.getCurrent().getId()); + assertEquals(0.10, InfrastructureAccess.forGuild(guild, owner), 1e-9); + } + + @Test + void cyclicOverlordChainsAreIgnored() { + Faction first = faction("first", "isolationism"); + Faction second = faction("second", "isolationism"); + when(first.getOverlord()).thenReturn(second); + when(second.getOverlord()).thenReturn(first); + + assertSame(first, InfrastructureAccess.topRealm(first)); + assertEquals(0, InfrastructureAccess.forGuild(first, second)); + } + + private Faction faction(String id, String law) { + Faction faction = mock(Faction.class); + when(faction.getId()).thenReturn(id); + LawHandler handler = mock(LawHandler.class); + when(faction.getLawHandler()).thenReturn(handler); + LawGroup group = group(); + group.setCurrent(group.getLaw(law)); + when(handler.getGroup("economy")).thenReturn(group); + return faction; + } + + private LawGroup group() { + return new LawGroup("economy", config.getConfigurationSection("economy")); + } + + private static double grant(Law law) { + return InfrastructureAccess.amount(law, Scope.FOREIGN_GUILDS, Region.OUR_TERRITORY); + } + + private static double reach(Law law) { + return InfrastructureAccess.amount(law, Scope.DOMESTIC_GUILDS, Region.FOREIGN_TERRITORY); + } + + private static void assertTable(LawGroup group) { + for (Map.Entry entry : Map.of( + "free_trade", new double[] {0.80, 0.20}, + "mercantilism", new double[] {0.50, 0.20}, + "decentralized", new double[] {0.50, 0.00}, + "protectionism", new double[] {0.20, -0.10}, + "isolationism", new double[] {0.00, -0.30}).entrySet()) { + assertEquals(entry.getValue()[0], grant(group.getLaw(entry.getKey())), 1e-9, entry.getKey()); + assertEquals(entry.getValue()[1], reach(group.getLaw(entry.getKey())), 1e-9, entry.getKey()); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/map/infra/InfrastructureSourcesTest.java b/src/test/java/net/tfminecraft/simplefactions/map/infra/InfrastructureSourcesTest.java new file mode 100644 index 00000000..7193ea89 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/map/infra/InfrastructureSourcesTest.java @@ -0,0 +1,150 @@ +package net.tfminecraft.simplefactions.map.infra; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.enums.GuildModifier; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.GuildModifierOverride; +import net.tfminecraft.simplefactions.installation.Installation; +import net.tfminecraft.simplefactions.installation.InstallationKind; +import net.tfminecraft.simplefactions.installation.handler.InstallationHandler; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Faction; + +class InfrastructureSourcesTest { + private final double station = Cache.infrastructureStation; + private final double port = Cache.infrastructurePort; + private final double airport = Cache.infrastructureAirport; + + @AfterEach + void restore() { + GuildModifierOverride.clear(); + Cache.infrastructureStation = station; + Cache.infrastructurePort = port; + Cache.infrastructureAirport = airport; + } + + @Test + void realmCapitalUsesTheResolvedModifierOnce() { + Guild guild = guild(true, 1, 12.5); + Map sources = InfrastructureSources.collect( + Map.of(1, province(1, "forest")), List.of(guild), List.of()); + + assertEquals(12.5, sources.get(1)); + verify(guild, times(1)).getModifier(GuildModifier.INFRASTRUCTURE); + assertTrue(GuildModifier.INFRASTRUCTURE.isPositive()); + assertEquals(GuildModifier.PRODUCTION.scalesWithInactivity(), GuildModifier.INFRASTRUCTURE.scalesWithInactivity()); + } + + @Test + void capitalUsesPreviewOverrideInsteadOfTheLiveModifier() { + Guild guild = guild(true, 1, 12.5); + GuildModifierOverride.use(guild, Map.of(GuildModifier.INFRASTRUCTURE, 17.0)); + Map sources = InfrastructureSources.collect( + Map.of(1, province(1, "forest")), List.of(guild), List.of()); + + assertEquals(17, sources.get(1)); + verify(guild, never()).getModifier(GuildModifier.INFRASTRUCTURE); + } + + @Test + void nonBaseGuildsAndGuildsWithoutCapitalsAddNothing() { + Guild regular = guild(false, 1, 12.5); + Guild noCapital = guild(true, 1, 12.5); + when(noCapital.hasCapital()).thenReturn(false); + + assertTrue(InfrastructureSources.collect(Map.of(1, province(1, "forest")), + List.of(regular, noCapital), List.of()).isEmpty()); + verify(regular, never()).getModifier(GuildModifier.INFRASTRUCTURE); + verify(noCapital, never()).getModifier(GuildModifier.INFRASTRUCTURE); + } + + @Test + void installationsUseConfigAmountsWithoutScalingByLevelAndFortsAddNothing() { + Cache.infrastructureStation = 7; + Cache.infrastructurePort = 11; + Cache.infrastructureAirport = 3; + Installation station = installation(InstallationKind.TRAIN_STATION, 1); + Installation port = installation(InstallationKind.PORT, 2); + Installation airport = installation(InstallationKind.AIRPORT, 3); + Installation fort = installation(InstallationKind.FORT, 4); + Map sources = InfrastructureSources.collect(Map.of( + 1, province(1, "forest"), 2, province(2, "forest"), + 3, province(3, "forest"), 4, province(4, "forest")), List.of(), + List.of(faction(station, port, airport, fort))); + + assertEquals(Map.of(1, 7.0, 2, 11.0, 3, 3.0), sources); + verify(station, never()).getLevel(); + verify(port, never()).getLevel(); + verify(airport, never()).getLevel(); + } + + @Test + void sourcesInOneProvinceAddBeforeTheySpread() { + Cache.infrastructureStation = 10; + Guild guild = guild(true, 1, 10); + Map sources = InfrastructureSources.collect(Map.of( + 1, province(1, "forest"), 2, province(2, "plains")), List.of(guild), + List.of(faction(installation(InstallationKind.TRAIN_STATION, 1)))); + Map result = InfrastructureSpread.spread(Map.of( + 1, new InfrastructureSpread.Node(0.6, true, "realm", List.of(2)), + 2, new InfrastructureSpread.Node(0.75, true, "realm", List.of(1))), sources, 0.25, 0.5); + + assertEquals(20, sources.get(1)); + assertEquals(15, result.get(2).amount()); + } + + @Test + void sourcesOutsideTheManagerAndOnSeaOrWaterAreIgnored() { + Map sources = InfrastructureSources.collect(Map.of( + 1, province(1, "sea"), 2, province(2, "water")), + List.of(guild(true, 1, 10), guild(true, 2, 10), guild(true, 3, 10)), + List.of(faction(installation(InstallationKind.PORT, 1), + installation(InstallationKind.TRAIN_STATION, 2), + installation(InstallationKind.AIRPORT, 3)))); + + assertTrue(sources.isEmpty()); + } + + private static Province province(int id, String terrain) { + return new Province(id, terrain, 0); + } + + private static Guild guild(boolean base, int capital, double amount) { + Guild guild = mock(Guild.class); + when(guild.isBase()).thenReturn(base); + when(guild.hasCapital()).thenReturn(true); + when(guild.getCapital()).thenReturn(capital); + when(guild.getModifier(GuildModifier.INFRASTRUCTURE)).thenReturn(amount); + return guild; + } + + private static Installation installation(InstallationKind kind, int province) { + Installation installation = mock(Installation.class); + when(installation.getKind()).thenReturn(kind); + when(installation.getProvince()).thenReturn(province); + when(installation.getLevel()).thenReturn(5); + return installation; + } + + private static Faction faction(Installation... installations) { + Faction faction = mock(Faction.class); + InstallationHandler handler = mock(InstallationHandler.class); + when(faction.getInstallationHandler()).thenReturn(handler); + when(handler.getAll()).thenReturn(List.of(installations)); + return faction; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/map/infra/InfrastructureSpreadTest.java b/src/test/java/net/tfminecraft/simplefactions/map/infra/InfrastructureSpreadTest.java new file mode 100644 index 00000000..dc924b7c --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/map/infra/InfrastructureSpreadTest.java @@ -0,0 +1,135 @@ +package net.tfminecraft.simplefactions.map.infra; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.map.infra.InfrastructureSpread.Arrival; +import net.tfminecraft.simplefactions.map.infra.InfrastructureSpread.Node; + +class InfrastructureSpreadTest { + @Test + void eachHopMultipliesByTheEnteredRawTerrain() { + Map result = spread(Map.of( + 1, land(0.4, "realm", 2), + 2, land(0.75, "realm", 1, 3), + 3, land(0.75, "realm", 2)), Map.of(1, 10.0)); + + assertEquals(7.5, result.get(2).amount(), 1e-9); + assertEquals(5.625, result.get(3).amount(), 1e-9); + assertEquals("realm", result.get(3).origin()); + } + + @Test + void aWorsePathDoesNotReplaceTheBetterArrival() { + Map result = spread(Map.of( + 1, land(0.75, "realm", 2, 3), + 2, land(0.75, "realm", 1, 4), + 3, land(0.4, "realm", 1, 4), + 4, land(0.75, "realm", 2, 3)), Map.of(1, 10.0)); + + assertEquals(5.625, result.get(4).amount(), 1e-9); + } + + @Test + void waterCannotBeEnteredOrUsedAsASource() { + Map result = spread(Map.of( + 1, land(0.75, "realm", 2), + 2, new Node(0.75, false, "realm", List.of(1, 3)), + 3, land(0.75, "realm", 2)), Map.of(1, 10.0, 2, 100.0)); + + assertFalse(result.containsKey(2)); + assertFalse(result.containsKey(3)); + } + + @Test + void anotherRealmBlocksSpread() { + Map result = spread(Map.of( + 1, land(0.75, "home", 2), + 2, land(0.75, "foreign", 1, 3), + 3, land(0.75, "home", 2)), Map.of(1, 10.0)); + + assertFalse(result.containsKey(2)); + assertFalse(result.containsKey(3)); + } + + @Test + void everyUnownedProvinceEnteredGetsTheWildernessPenalty() { + Map graph = Map.of( + 1, land(0.75, "home", 2), + 2, land(0.4, null, 1, 3), + 3, land(0.75, null, 2)); + Map result = InfrastructureSpread.spread(graph, Map.of(1, 10.0), 0.25, 0.1); + + assertEquals(10 * 0.40 * 0.25, result.get(2).amount(), 1e-9); + assertEquals(10 * 0.40 * 0.25 * 0.75 * 0.25, result.get(3).amount(), 1e-9); + assertFalse(spread(graph, Map.of(1, 10.0)).containsKey(3)); + } + + @Test + void unownedSourcesStayInUnownedLand() { + Map result = spread(Map.of( + 1, land(0.75, null, 2, 3), + 2, land(0.75, "realm", 1), + 3, land(0.75, null, 1)), Map.of(1, 10.0)); + + assertFalse(result.containsKey(2)); + assertEquals(1.875, result.get(3).amount(), 1e-9); + assertNull(result.get(3).origin()); + } + + @Test + void ownedSourcesCanReenterTheirRealmFromWilderness() { + Map result = spread(Map.of( + 1, land(0.75, "home", 2), + 2, land(0.4, null, 1, 3, 4), + 3, land(0.75, "home", 2), + 4, land(0.75, "foreign", 2)), Map.of(1, 10.0)); + + assertEquals(0.75, result.get(3).amount(), 1e-9); + assertEquals("home", result.get(3).origin()); + assertFalse(result.containsKey(4)); + } + + @Test + void arrivalsBelowTheFloorStopButAnArrivalAtTheFloorContinues() { + Map result = spread(Map.of( + 1, land(0.75, "realm", 2, 3), + 2, land(0.4, "realm", 1, 4), + 3, land(0.5, "realm", 1, 5), + 4, land(0.75, "realm", 2), + 5, land(1, "realm", 3)), Map.of(1, 1.0)); + + assertFalse(result.containsKey(2)); + assertFalse(result.containsKey(4)); + assertEquals(0.5, result.get(3).amount(), 1e-9); + assertEquals(0.5, result.get(5).amount(), 1e-9); + } + + @Test + void theWinningArrivalKeepsItsOriginAcrossWilderness() { + Map result = spread(Map.of( + 1, land(0.75, "first", 3), + 2, land(0.75, "second", 3), + 3, land(0.75, null, 1, 2, 4, 5), + 4, land(0.75, "first", 3), + 5, land(0.75, "second", 3)), Map.of(1, 10.0, 2, 20.0)); + + assertEquals("second", result.get(3).origin()); + assertFalse(result.containsKey(4)); + assertEquals(2.8125, result.get(5).amount(), 1e-9); + } + + private static Node land(double terrain, String realm, Integer... neighbours) { + return new Node(terrain, true, realm, List.of(neighbours)); + } + + private static Map spread(Map graph, Map sources) { + return InfrastructureSpread.spread(graph, sources, 0.25, 0.5); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/map/infra/ProvinceInfrastructureTest.java b/src/test/java/net/tfminecraft/simplefactions/map/infra/ProvinceInfrastructureTest.java new file mode 100644 index 00000000..a112a621 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/map/infra/ProvinceInfrastructureTest.java @@ -0,0 +1,249 @@ +package net.tfminecraft.simplefactions.map.infra; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.enums.GuildModifier; +import net.tfminecraft.simplefactions.enums.Terrain; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.GuildModifierOverride; +import net.tfminecraft.simplefactions.guild.hub.HubNetwork; +import net.tfminecraft.simplefactions.guild.income.EconomicPreview; +import net.tfminecraft.simplefactions.guild.income.TradeBreakdown; +import net.tfminecraft.simplefactions.installation.Installation; +import net.tfminecraft.simplefactions.installation.InstallationKind; +import net.tfminecraft.simplefactions.installation.handler.InstallationHandler; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.ProvinceManager; +import net.tfminecraft.simplefactions.managers.TitleManager; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.map.provinces.ProvinceDataEntry; +import net.tfminecraft.simplefactions.objects.Faction; + +class ProvinceInfrastructureTest { + private final Map previousCarry = new HashMap<>(Cache.tradeCarry); + private final boolean previousEnabled = Cache.provincesEnabled; + private final double previousFull = Cache.infrastructureFull; + private final double previousTarget = Cache.infrastructureTarget; + private final double previousWilderness = Cache.infrastructureWildernessSpread; + private final double previousFloor = Cache.infrastructureSpreadFloor; + private final double previousStation = Cache.infrastructureStation; + private final double previousMaxUpkeep = Cache.maxTradeUpkeep; + private MockedStatic factions; + private MockedStatic titles; + private MockedStatic hubs; + private Guild guild; + private Faction faction; + + @BeforeEach + void setUp() { + Cache.provincesEnabled = true; + Cache.infrastructureFull = 20; + Cache.infrastructureTarget = 0.75; + Cache.infrastructureWildernessSpread = 0.25; + Cache.infrastructureSpreadFloor = 0.5; + Cache.infrastructureStation = 10; + Cache.maxTradeUpkeep = 0.75; + for (Terrain terrain : Terrain.values()) Cache.tradeCarry.put(terrain, 0.5); + Cache.tradeCarry.put(Terrain.FARMLAND, 0.8); + Cache.tradeCarry.put(Terrain.PLAINS, 0.75); + Cache.tradeCarry.put(Terrain.BOG, 0.4); + Cache.tradeCarry.put(Terrain.MOUNTAIN, 0.3); + factions = mockStatic(FactionManager.class); + titles = mockStatic(TitleManager.class); + hubs = mockStatic(HubNetwork.class); + faction = mock(Faction.class); + when(faction.getId()).thenReturn("realm"); + guild = mock(Guild.class); + when(guild.getId()).thenReturn("guild"); + when(guild.getFaction()).thenReturn(faction); + when(guild.getTradeBreakdown()).thenReturn(new TradeBreakdown()); + when(guild.getModifier(GuildModifier.TRADE_CARRY)).thenReturn(1.0); + } + + @AfterEach + void restore() { + GuildModifierOverride.clear(); + hubs.close(); + titles.close(); + factions.close(); + Cache.tradeCarry.clear(); + Cache.tradeCarry.putAll(previousCarry); + Cache.provincesEnabled = previousEnabled; + Cache.infrastructureFull = previousFull; + Cache.infrastructureTarget = previousTarget; + Cache.infrastructureWildernessSpread = previousWilderness; + Cache.infrastructureSpreadFloor = previousFloor; + Cache.infrastructureStation = previousStation; + Cache.maxTradeUpkeep = previousMaxUpkeep; + } + + @Test + void anEmptySourceMapLeavesGuildCarryEqualToRawOnEveryTerrain() { + Map map = new HashMap<>(); + for (Terrain terrain : Terrain.values()) { + int id = terrain.ordinal() + 1; + map.put(id, new Province(id, terrain.name(), 0)); + } + ProvinceManager manager = manager(map); + manager.recalculate(); + + for (Province province : manager.getProvinces()) { + assertEquals(0, province.getInfrastructure()); + assertEquals(province.getTradeCarry(), province.getTradeCarry(guild)); + } + } + + @Test + void bothRecalculationPathsRebuildSourcesFromCurrentModifiers() { + Province first = new Province(1, "bog", 0); + Province second = new Province(2, "plains", 0); + first.addNeighbour(2); + second.addNeighbour(1); + titles.when(() -> TitleManager.getByProvince(1)).thenReturn(faction); + titles.when(() -> TitleManager.getByProvince(2)).thenReturn(faction); + capitalSource(10); + ProvinceManager manager = manager(Map.of(1, first, 2, second)); + + manager.recalculate(); + assertEquals(10, first.getInfrastructure()); + assertEquals(7.5, second.getInfrastructure(), 1e-9); + assertEquals(0.575, first.getTradeCarry(guild), 1e-9); + when(guild.getModifier(GuildModifier.INFRASTRUCTURE)).thenReturn(20.0); + manager.recalculateForSingleGuild(guild, false); + assertEquals(20, first.getInfrastructure()); + assertEquals(15, second.getInfrastructure(), 1e-9); + when(guild.getModifier(GuildModifier.INFRASTRUCTURE)).thenReturn(0.0); + manager.recalculate(); + assertEquals(0, first.getInfrastructure()); + assertEquals(first.getTradeCarry(), first.getTradeCarry(guild)); + } + + @Test + void snapshotsRebuildTheirOwnInfrastructureAndDoNotChangeTheLiveMap() { + Province capital = new Province(1, "bog", 0); + titles.when(() -> TitleManager.getByProvince(1)).thenReturn(faction); + capitalSource(10); + ProvinceManager live = manager(Map.of(1, capital)); + live.recalculate(); + EconomicPreview.Prepared prepared = EconomicPreview.prepare(live); + ProvinceManager snapshot = EconomicPreview.copyOf(prepared); + + assertNotSame(capital, snapshot.get(1)); + assertEquals(10, snapshot.get(1).getInfrastructure()); + GuildModifierOverride.use(guild, Map.of(GuildModifier.INFRASTRUCTURE, 20.0)); + snapshot.recalculate(); + GuildModifierOverride.clear(); + + assertEquals(20, snapshot.get(1).getInfrastructure()); + assertEquals(10, capital.getInfrastructure()); + assertEquals(10, EconomicPreview.copyOf(prepared).get(1).getInfrastructure()); + } + + @Test + void snapshotsReadInstallationsAtRecalculationTime() { + InstallationHandler handler = mock(InstallationHandler.class); + when(faction.getInstallationHandler()).thenReturn(handler); + factions.when(FactionManager::getCopy).thenReturn(List.of(faction)); + ProvinceManager live = manager(Map.of(1, new Province(1, "bog", 0))); + EconomicPreview.Prepared prepared = EconomicPreview.prepare(live); + Installation station = mock(Installation.class); + when(station.getKind()).thenReturn(InstallationKind.TRAIN_STATION); + when(station.getProvince()).thenReturn(1); + when(handler.getAll()).thenReturn(List.of(station)); + ProvinceManager snapshot = EconomicPreview.copyOf(prepared); + snapshot.recalculate(); + + assertEquals(10, snapshot.get(1).getInfrastructure()); + assertEquals(0, live.get(1).getInfrastructure()); + when(handler.getAll()).thenReturn(List.of()); + snapshot.recalculate(); + assertEquals(0, snapshot.get(1).getInfrastructure()); + } + + @Test + void singleGuildRecalculationRebuildsInstallationsEvenWithoutACapital() { + InstallationHandler handler = mock(InstallationHandler.class); + Installation station = mock(Installation.class); + when(station.getKind()).thenReturn(InstallationKind.TRAIN_STATION); + when(station.getProvince()).thenReturn(1); + when(handler.getAll()).thenReturn(List.of(station)); + when(faction.getInstallationHandler()).thenReturn(handler); + factions.when(FactionManager::getCopy).thenReturn(List.of(faction)); + ProvinceManager manager = manager(Map.of(1, new Province(1, "bog", 0))); + + manager.recalculateForSingleGuild(guild, false); + + assertEquals(10, manager.get(1).getInfrastructure()); + } + + @Test + void effectiveTerrainIsUsedByTradeProductionAndUpkeep() { + Province province = new Province(1, "bog", 0); + province.setInfrastructure(20); + ProvinceManager manager = manager(Map.of(1, province)); + province.calculateTrade(manager, guild, 10, 1); + + assertEquals(7.5, province.getRawGuildTrade(guild), 1e-9); + double factor = 7.5 / (7.5 + 2.5 / Math.sqrt(0.75)); + assertEquals(factor, province.getTradeFactor(guild), 1e-9); + province.calculateProduction(manager, guild, new ProvinceDataEntry(guild, 0, 10), 1); + assertEquals(10 * Math.sqrt(0.75) * factor, province.getGuildProduction(guild), 1e-9); + when(guild.getModifier(GuildModifier.TRADE_UPKEEP)).thenReturn(1.0); + province.setProsperity(100); + double expectedNet = Math.round(100 * (1 - factor) * 100) / 100.0; + assertEquals(expectedNet, manager.getIncome(guild, false), 1e-9); + assertEquals(0.4, province.getTradeCarry()); + assertEquals(0.4, Cache.getTradeCarry(Terrain.BOG)); + } + + @Test + void hubProductionSpreadUsesEffectiveTerrain() { + Province source = new Province(1, "plains", 0); + Province destination = new Province(2, "bog", 0); + source.addNeighbour(2); + destination.setInfrastructure(20); + destination.setData(guild.getId(), new ProvinceDataEntry(guild, 10, 0)); + ProvinceManager manager = manager(Map.of(1, source, 2, destination)); + source.seedProduction(manager, guild, 100); + + double factor = 10 / (10 + 2.5 / Math.sqrt(0.75)); + assertEquals(100 * Math.sqrt(0.75) * factor, destination.getGuildProduction(guild), 1e-9); + } + + @Test + void seaAndWaterCarryRemainRawEvenIfInfrastructureIsSet() { + for (Terrain terrain : List.of(Terrain.SEA, Terrain.WATER)) { + Province province = new Province(1, terrain.name(), 0); + province.setInfrastructure(20); + assertEquals(province.getTradeCarry(), province.getTradeCarry(guild)); + } + } + + private void capitalSource(double amount) { + when(guild.isBase()).thenReturn(true); + when(guild.hasCapital()).thenReturn(true); + when(guild.getCapital()).thenReturn(1); + when(guild.getModifier(GuildModifier.INFRASTRUCTURE)).thenReturn(amount); + factions.when(FactionManager::getAllGuilds).thenReturn(List.of(guild)); + } + + private static ProvinceManager manager(Map provinces) { + ProvinceManager manager = new ProvinceManager(); + manager.start(provinces); + return manager; + } +} From e6f9c1ee1b62cfef6717691cafee041167065c38 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:51:23 +0000 Subject: [PATCH 2/2] Fall back to the default when the infrastructure spread floor is not positive. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../simplefactions/loaders/ConfigLoader.java | 4 +++- .../ConfigLoaderInfrastructureTest.java | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java index 72ec396b..80c45e45 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java @@ -76,7 +76,9 @@ public void loadConfig(File configFile) { Cache.infrastructureFull = config.getDouble("infrastructure.full", 20); Cache.infrastructureTarget = config.getDouble("infrastructure.target", 0.75); Cache.infrastructureWildernessSpread = config.getDouble("infrastructure.wilderness-spread", 0.25); - Cache.infrastructureSpreadFloor = config.getDouble("infrastructure.spread-floor", 0.5); + double infrastructureSpreadFloor = config.getDouble("infrastructure.spread-floor", 0.5); + Cache.infrastructureSpreadFloor = Double.isFinite(infrastructureSpreadFloor) && infrastructureSpreadFloor > 0 + ? infrastructureSpreadFloor : 0.5; Cache.infrastructureStation = config.getDouble("infrastructure.station", 10); Cache.infrastructurePort = config.getDouble("infrastructure.port", 10); Cache.infrastructureAirport = config.getDouble("infrastructure.airport", 5); diff --git a/src/test/java/net/tfminecraft/simplefactions/loaders/ConfigLoaderInfrastructureTest.java b/src/test/java/net/tfminecraft/simplefactions/loaders/ConfigLoaderInfrastructureTest.java index 3a1ec253..14458d71 100644 --- a/src/test/java/net/tfminecraft/simplefactions/loaders/ConfigLoaderInfrastructureTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/loaders/ConfigLoaderInfrastructureTest.java @@ -71,6 +71,27 @@ void configuredKeysAreReadIntoCache() throws IOException { assertValues(30, 0.80, 0.10, 0.25, 12, 8, 4); } + @Test + void zeroSpreadFloorUsesDefault() throws IOException { + load("infrastructure:\n spread-floor: 0\n"); + + assertEquals(0.5, Cache.infrastructureSpreadFloor, 1e-9); + } + + @Test + void negativeSpreadFloorUsesDefault() throws IOException { + load("infrastructure:\n spread-floor: -0.25\n"); + + assertEquals(0.5, Cache.infrastructureSpreadFloor, 1e-9); + } + + @Test + void positiveSpreadFloorIsKept() throws IOException { + load("infrastructure:\n spread-floor: 0.25\n"); + + assertEquals(0.25, Cache.infrastructureSpreadFloor, 1e-9); + } + private void load(String yaml) throws IOException { Path file = tempDir.resolve("config.yml"); Files.writeString(file, yaml);