diff --git a/src/main/java/net/tfminecraft/simplefactions/Cache.java b/src/main/java/net/tfminecraft/simplefactions/Cache.java index 5abd26a7..dbba107a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/Cache.java +++ b/src/main/java/net/tfminecraft/simplefactions/Cache.java @@ -165,9 +165,6 @@ public static boolean requireProvinces(org.bukkit.command.CommandSender sender) public static int mercenaryMaxContractDays = 14; public static double mercenaryDefaultBreachRefund = 500.0; - public static int supplyHubBaseLimit = 2; - public static double supplyHubBaseUpkeep = 15.0; - public static double supplyHubUpkeepGrowth = 1.5; public static double supplyHubMaxTax = 50.0; public static Map baseEffects = new HashMap<>(); diff --git a/src/main/java/net/tfminecraft/simplefactions/database/Database.java b/src/main/java/net/tfminecraft/simplefactions/database/Database.java index 76663929..ff7d71aa 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/Database.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/Database.java @@ -425,6 +425,7 @@ public void saveFaction(Faction f) { gd.depositsToday = depositsToday.isEmpty() ? null : depositsToday; gd.company = g.getCompany() != null ? g.getCompany().serialize() : null; gd.supplyHubs = SupplyHubService.toData(g.getSupplyHubs()); + gd.supplyHubTutorialDismissals = g.getSupplyHubTutorialDismissals(); // --- Bank --- if (g.getBank() != null) { diff --git a/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java index 4743fd0e..36a264b4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java @@ -70,4 +70,7 @@ public class GuildData { @SerializedName("supply hubs") public List supplyHubs = new ArrayList<>(); + + @SerializedName("supply hub tutorial dismissals") + public List supplyHubTutorialDismissals = new ArrayList<>(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/enums/GuildModifier.java b/src/main/java/net/tfminecraft/simplefactions/enums/GuildModifier.java index d71bef02..13e2491a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/enums/GuildModifier.java +++ b/src/main/java/net/tfminecraft/simplefactions/enums/GuildModifier.java @@ -7,6 +7,7 @@ public enum GuildModifier { PRODUCTION("#f2c94cProduction", true), TRADE_CARRY("#86d1b0Trade Carry", true), HUB_LIMIT("#c99b70Supply Hub Limit", true), + HUB_UPKEEP("#d6645aSupply Hub Upkeep", false), HUB_TRADE("#b5835aHub Trade Transfer", true), HUB_PRODUCTION("#a9744fHub Production Transfer", true), DIPLOMATIC_CAPACITY("#56ccf2Diplomatic Capacity", true), @@ -34,4 +35,8 @@ public String getName() { public boolean isPositive() { return positive; } + + public boolean scalesWithInactivity() { + return positive && this != HUB_LIMIT; + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index c1cce2f4..af6bc349 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -107,6 +107,7 @@ public class Guild { private List dividendEligible = new ArrayList<>(); private MercenaryCompany company; private final List supplyHubs = new ArrayList<>(); + private final Set supplyHubTutorialDismissals = new HashSet<>(); public Guild(Faction f) { host = f; @@ -246,6 +247,9 @@ public Guild(GuildData data, Faction host) { this, data.company, MercenaryCompany.cloneMercenaryRegiment()); } this.supplyHubs.addAll(SupplyHubService.fromData(data.supplyHubs)); + if (data.supplyHubTutorialDismissals != null) { + supplyHubTutorialDismissals.addAll(data.supplyHubTutorialDismissals); + } if(data.pillageHits != null) { for (StabilityModifierData smd : data.pillageHits) { if (smd == null || smd.name == null) { @@ -427,6 +431,20 @@ public boolean consumeInvite(String p) { public List getSupplyHubs() { return supplyHubs; } + + public boolean hasDismissedSupplyHubTutorial(String playerId) { + return playerId != null && supplyHubTutorialDismissals.contains(playerId); + } + + public void dismissSupplyHubTutorial(String playerId) { + if (playerId != null) { + supplyHubTutorialDismissals.add(playerId); + } + } + + public List getSupplyHubTutorialDismissals() { + return new ArrayList<>(supplyHubTutorialDismissals); + } public String getName() { return isBase() ? host.getName() : name; } /** Backing name, even while this guild is a faction base (display name follows the host). */ @@ -459,8 +477,11 @@ public Branch getBranch(int i) { return branches.getOrDefault(i, null); } public Branch getBranch(String id) { + // A renamed branch is still found by the id it was saved under. + Branch definition = BranchLoader.getByString(id); + String current = definition == null ? id : definition.getId(); for(Branch b : branches.values()) { - if(b.getId().equalsIgnoreCase(id)) return b; + if(b.getId().equalsIgnoreCase(current)) return b; } return null; } @@ -693,7 +714,7 @@ public double getModifier(GuildModifier m) { amount += u.getAmount(m); } // Levels stay. While the guild is inactive, the benefit of those levels fades. - if (m != null && m.isPositive()) { + if (m != null && m.scalesWithInactivity()) { amount *= net.tfminecraft.simplefactions.inactivity.InactivityService.outputFactor(this); } return amount; 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 562134cd..c1ac1986 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubNetwork.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubNetwork.java @@ -18,6 +18,7 @@ import net.tfminecraft.simplefactions.guild.hub.HubTransport.Rates; import net.tfminecraft.simplefactions.guild.hub.SupplyHubService.HubStanding; 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; @@ -40,7 +41,9 @@ public interface RailRoutes { } private static volatile Map> links = Map.of(); - private static final Map railDistances = new ConcurrentHashMap<>(); + private record CachedRoute(OptionalDouble distance, long expiresAt) { } + private static final Map railDistances = new ConcurrentHashMap<>(); + private static final long ROUTE_MISS_TTL_MILLIS = 30_000L; private static RailRoutes railRoutes = HubNetwork::trackDistance; private HubNetwork() { @@ -122,35 +125,58 @@ public static Link connect(Installation from, Installation to, ProvinceManager p if (from == null || to == null || from == to || from.getProvince() == to.getProvince()) { return null; } - Mode mode = HubTransport.modeBetween(from.getKind(), to.getKind()); - if (mode == null) { + if (!hubKind(from) || !hubKind(to)) { return null; } + String fromFactionId = SupplyHubService.owningFactionId(from); + String toFactionId = SupplyHubService.owningFactionId(to); + if (fromFactionId == null) { + fromFactionId = ""; + } + if (toFactionId == null) { + toFactionId = ""; + } + Link best = modeLink(from, fromFactionId, to, toFactionId, Mode.RAIL, provinces); + Mode ownMode = HubTransport.modeBetween(from.getKind(), to.getKind()); + if (ownMode != null && ownMode != Mode.RAIL) { + Link own = modeLink(from, fromFactionId, to, toFactionId, ownMode, provinces); + if (own != null && (best == null || own.tradeFactor() > best.tradeFactor())) { + best = own; + } + } + return best; + } + + private static boolean hubKind(Installation installation) { + return installation.getKind() == InstallationKind.PORT + || installation.getKind() == InstallationKind.AIRPORT + || installation.getKind() == InstallationKind.TRAIN_STATION; + } + + private static Link modeLink( + Installation from, String fromFactionId, Installation to, String toFactionId, + Mode mode, ProvinceManager provinces) { Rates rates = HubTransport.rates(mode); double distance; - switch (mode) { - case RAIL: - OptionalDouble track = railDistance(from, to); - if (track.isEmpty()) { - return null; - } - distance = track.getAsDouble(); - break; - case SEA: - if (SeaConnectivity.sharedSeaProvinces( - provinces, List.of(from.getProvince()), List.of(to.getProvince())).isEmpty()) { - return null; - } - distance = straightLine(from, to); - break; - default: - distance = straightLine(from, to); - break; + if (mode == Mode.RAIL) { + OptionalDouble track = railDistance(from, to); + if (track.isEmpty()) { + return null; + } + distance = track.getAsDouble(); + } else if (mode == Mode.SEA) { + if (SeaConnectivity.sharedSeaProvinces( + provinces, List.of(from.getProvince()), List.of(to.getProvince())).isEmpty()) { + return null; + } + distance = straightLine(from, to); + } else { + distance = straightLine(from, to); } if (!Double.isFinite(distance) || distance < 0 || !HubTransport.inRange(rates, distance)) { return null; } - return HubTransport.link(from.getProvince(), to.getProvince(), mode, distance); + return HubTransport.link(from, fromFactionId, to, toFactionId, mode, distance); } public static void setLinksForTests(Map> replacement) { @@ -201,19 +227,17 @@ private static List activeSites(Guild guild, List guilds) { } private static OptionalDouble railDistance(Installation from, Installation to) { - String a = from.getProvince() + ":" + from.getId(); - String b = to.getProvince() + ":" + to.getId(); + String a = SupplyHubService.owningFactionId(from) + ":" + from.getProvince() + ":" + from.getId(); + String b = SupplyHubService.owningFactionId(to) + ":" + to.getProvince() + ":" + to.getId(); String key = a.compareTo(b) <= 0 ? a + "|" + b : b + "|" + a; - OptionalDouble known = railDistances.get(key); - if (known != null) { - return known; + long now = System.currentTimeMillis(); + CachedRoute known = railDistances.get(key); + if (known != null && (known.distance().isPresent() || known.expiresAt() > now)) { + return known.distance(); } - // Only a found route is remembered: stations with no track yet are asked about again, - // so a line connects as soon as it is finished rather than at the next daily check. OptionalDouble measured = railRoutes.distance(from, to); - if (measured.isPresent()) { - railDistances.put(key, measured); - } + railDistances.put(key, new CachedRoute( + measured, measured.isPresent() ? Long.MAX_VALUE : now + ROUTE_MISS_TTL_MILLIS)); return measured; } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxService.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxService.java index 820d5aa6..8d5ef87b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxService.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxService.java @@ -55,7 +55,7 @@ public static HubTaxBreakdown assess(ProvinceManager provinces, Guild guild, Lis continue; } List remaining = links.stream() - .filter(link -> !touches(link, installation)) + .filter(link -> !touches(link, hub)) .toList(); without.put(hub, incomeWith(provinces, guild, remaining)); hosts.put(hub, host); @@ -99,10 +99,15 @@ public static Map taxableIncome(double base, double full, Map links) { diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTransport.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTransport.java index dea5a89d..0f353700 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTransport.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTransport.java @@ -6,6 +6,7 @@ import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.FileConfiguration; +import net.tfminecraft.simplefactions.installation.Installation; import net.tfminecraft.simplefactions.installation.InstallationKind; /** @@ -59,11 +60,21 @@ public record Rates(double trade, double production, double keptPer1000, double public record Link( int fromProvince, int toProvince, + String fromFactionId, + String fromInstallationId, + String toFactionId, + String toInstallationId, Mode mode, double distance, double tradeFactor, double productionFactor) { + public Link(int fromProvince, int toProvince, Mode mode, double distance, + double tradeFactor, double productionFactor) { + this(fromProvince, toProvince, null, null, null, null, mode, + distance, tradeFactor, productionFactor); + } + /** The boosted share is capped before the cached distance loss is applied. */ public double boostedTradeFactor(double bonus) { return boostedFactor(tradeFactor, mode, distance, bonus, true); @@ -142,17 +153,28 @@ public static double delivered(double share, double keptPer1000, double distance return share * Math.pow(keptPer1000, Math.max(0, distance) / 1000.0); } - public static Link link(int fromProvince, int toProvince, Mode mode, double distance) { + public static Link link(Installation from, String fromFactionId, Installation to, String toFactionId, Mode mode, double distance) { Rates modeRates = rates(mode); return new Link( - fromProvince, - toProvince, + from.getProvince(), + to.getProvince(), + fromFactionId, + from.getId(), + toFactionId, + to.getId(), mode, distance, delivered(modeRates.trade(), modeRates.keptPer1000(), distance), delivered(modeRates.production(), modeRates.keptPer1000(), distance)); } + public static Link link(int fromProvince, int toProvince, Mode mode, double distance) { + Rates modeRates = rates(mode); + return new Link(fromProvince, toProvince, mode, distance, + delivered(modeRates.trade(), modeRates.keptPer1000(), distance), + delivered(modeRates.production(), modeRates.keptPer1000(), distance)); + } + private static double boostedFactor(double factor, Mode mode, double distance, double bonus, boolean trade) { Rates modeRates = rates(mode); double baseShare = trade ? modeRates.trade() : modeRates.production(); diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubCommands.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubCommands.java index fba455bc..ec73ba8b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubCommands.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubCommands.java @@ -13,6 +13,7 @@ import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.guild.GuildModifierOverride; import net.tfminecraft.simplefactions.enums.GuildModifier; +import net.tfminecraft.simplefactions.database.Database; import net.tfminecraft.simplefactions.guild.hub.SupplyHubService.BuildFailure; import net.tfminecraft.simplefactions.guild.hub.SupplyHubService.HubStanding; import net.tfminecraft.simplefactions.guild.hub.SupplyHubService.PlacedCandidate; @@ -39,6 +40,9 @@ public static boolean guild(Player player, String[] args) { return true; } String action = args[1]; + if (action.equalsIgnoreCase("gotit") && args.length == 2) { + return gotIt(player); + } if (action.equalsIgnoreCase("build")) { return build(player); } @@ -207,6 +211,17 @@ private static boolean build(Player player) { player.sendMessage("§cYou are not standing inside an installation"); return true; } + boolean capitalHub = hasCapitalHub(guild); + SupplyHubTutorial.Decision tutorial = SupplyHubTutorial.decision( + guild.hasCapital(), capitalHub, installation.getProvince() == guild.getCapital(), + guild.hasDismissedSupplyHubTutorial(player.getUniqueId().toString())); + if (tutorial == SupplyHubTutorial.Decision.SHOW) { + Installation capitalInstallation = capitalPlacement(guild); + Faction capitalOwner = capitalInstallation == null ? capitalOwner(guild) : null; + SupplyHubTutorial.show(player, SupplyHubTutorial.placementLine( + capitalInstallation, capitalOwner == null ? null : capitalOwner.getName())); + return true; + } String guildFactionId = guild.getFaction() == null ? null : guild.getFaction().getId(); Faction owner = FactionManager.getByString(chosen.ownerFactionId()); boolean permit = owner != null && owner.hasHubPermit(guild.getId()); @@ -229,9 +244,88 @@ private static boolean build(Player player) { chosen.ownerFactionId(), installation.getId(), System.currentTimeMillis())); player.sendMessage("§aBuilt a supply hub at §f" + installation.getName()); recalculateTrade(); + SupplyHub newHub = guild.getSupplyHubs().get(guild.getSupplyHubs().size() - 1); + List connections = connectionLines(guild, newHub, installation); + for (String line : connections) { + player.sendMessage(line); + } + if (connections.isEmpty() && guild.getSupplyHubs().size() == 1) { + player.sendMessage("§7A hub does nothing alone. Build a second one where you want trade to arrive."); + } else if (connections.isEmpty()) { + player.sendMessage("§7This hub is not linked to any of your other hubs. Train track links any two hubs; ports also link by sea and airports by air."); + } + if (tutorial == SupplyHubTutorial.Decision.NOTE) { + player.sendMessage("§7Your guild still has no hub in its capital province, so this hub has little to pass on."); + } + return true; + } + + private static boolean gotIt(Player player) { + Guild guild = FactionManager.getGuildByMember(player.getName()); + if (guild == null) { + player.sendMessage("§cYou are not in a guild"); + return true; + } + guild.dismissSupplyHubTutorial(player.getUniqueId().toString()); + new Database().saveFaction(guild.getFaction()); + player.sendMessage("§aGot it. §7Run §e/guild hub build §7again to build here anyway."); return true; } + private static boolean hasCapitalHub(Guild guild) { + if (guild == null || !guild.hasCapital()) return false; + for (SupplyHub hub : guild.getSupplyHubs()) { + Installation installation = SupplyHubService.findInstallation( + hub.ownerFactionId(), hub.installationId()); + if (installation != null && installation.getProvince() == guild.getCapital()) { + return true; + } + } + return false; + } + + private static Installation capitalPlacement(Guild guild) { + if (guild == null || !guild.hasCapital()) { + return null; + } + if (FactionManager.factions == null) { + return null; + } + for (Faction faction : FactionManager.factions) { + if (faction == null || faction.getInstallationHandler() == null) { + continue; + } + boolean permitted = faction.hasHubPermit(guild.getId()); + if (!SupplyHubService.ownerAllows( + guild.getFaction() == null ? null : guild.getFaction().getId(), faction.getId(), permitted)) { + continue; + } + for (Installation installation : faction.getInstallationHandler().getAll()) { + if (installation == null || installation.getProvince() != guild.getCapital()) { + continue; + } + int slots = InstallationConfigLoader.getHubSlots(installation.getKind(), installation.getLevel()); + if (slots <= SupplyHubService.countLoaded(faction.getId(), installation.getId())) continue; + if (SupplyHubService.hasHub(guild.getSupplyHubs(), faction.getId(), installation.getId())) continue; + if (Math.max( + tradeInProvince(installation.getProvince(), guild.getId()), + reachableTrade(guild, installation)) < 0.5) continue; + return installation; + } + } + return null; + } + + private static Faction capitalOwner(Guild guild) { + if (guild == null || !guild.hasCapital()) return null; + SimpleFactions plugin = SimpleFactions.getInstance(); + if (plugin == null || plugin.getProvinceManager() == null) { + return null; + } + Province province = plugin.getProvinceManager().get(guild.getCapital()); + return province == null ? null : province.getOwner(); + } + private static boolean list(Player player) { Guild guild = FactionManager.getGuildByMember(player.getName()); if (guild == null) { @@ -252,15 +346,19 @@ private static boolean list(Player player) { Faction owner = FactionManager.getByString(hub.ownerFactionId()); String ownerName = owner == null ? hub.ownerFactionId() : owner.getName(); double cost = SupplyHubService.upkeepOf( - hub, guild.getSupplyHubs(), SupplyHubService.baseUpkeep(), SupplyHubService.upkeepGrowth()); + hub, guild.getSupplyHubs(), SupplyHubService.upkeepPerHub(guild)); player.sendMessage("§f" + name + " §7(" + kind + ") §7owned by §f" + ownerName); player.sendMessage("§7Upkeep: §e" + Formatter.formatMoney(cost) + "d/day §7" + statusOf(guild, hub, installation)); HubTaxBreakdown.Assessment assessment = guild.getHubTaxBreakdown().forHub(hub); player.sendMessage("§7Taxable income: §e" + Formatter.formatMoney(assessment.taxableIncome()) + "d/day §7Hub Tax: §e" + Formatter.formatMoney(assessment.tax()) + "d/day"); - for (String line : connectionLines(guild, installation)) { + List connections = connectionLines(guild, hub, installation); + for (String line : connections) { player.sendMessage(line); } + if (connections.isEmpty() && standingOf(guild, hub, installation).active()) { + player.sendMessage("§7Not linked to another hub"); + } } return true; } @@ -309,6 +407,10 @@ private static void listPermits(Player player, Faction faction) { } private static String statusOf(Guild guild, SupplyHub hub, Installation installation) { + return SupplyHubService.statusText(standingOf(guild, hub, installation)); + } + + private static HubStanding standingOf(Guild guild, SupplyHub hub, Installation installation) { String guildFactionId = guild.getFaction() == null ? null : guild.getFaction().getId(); Faction owner = FactionManager.getByString(hub.ownerFactionId()); boolean permit = owner != null && owner.hasHubPermit(guild.getId()); @@ -324,7 +426,7 @@ private static String statusOf(Guild guild, SupplyHub hub, Installation installa SupplyHubService.ownerAllows(guildFactionId, hub.ownerFactionId(), permit), slots, atInstallation); - return SupplyHubService.statusText(standing); + return standing; } private static String label(SupplyHub hub) { @@ -396,7 +498,7 @@ public static void recalculateTrade() { plugin.getProvinceManager().recalculate(); } - private static List connectionLines(Guild guild, Installation installation) { + static List connectionLines(Guild guild, SupplyHub hub, Installation installation) { List lines = new ArrayList<>(); if (installation == null) { return lines; @@ -405,10 +507,10 @@ private static List connectionLines(Guild guild, Installation installati double productionBonus = GuildModifierOverride.resolve(guild, GuildModifier.HUB_PRODUCTION); for (HubTransport.Link link : HubNetwork.linksFor(guild)) { if (link.fromProvince() != installation.getProvince() - || link.mode().getKind() != installation.getKind()) { + || !sameEndpoint(link.fromFactionId(), link.fromInstallationId(), hub)) { continue; } - lines.add("§7Sends to §f" + hubNameIn(guild, link) + " §7by " + link.mode().getKey() + lines.add("§7Sends to §f" + hubNameIn(link) + " §7by " + link.mode().getKey() + " (" + Math.round(link.distance()) + " blocks): §e" + Math.round(link.boostedTradeFactor(tradeBonus) * 100) + "% §7trade, §e" @@ -418,16 +520,15 @@ private static List connectionLines(Guild guild, Installation installati return lines; } - private static String hubNameIn(Guild guild, HubTransport.Link link) { - for (SupplyHub hub : guild.getSupplyHubs()) { - Installation other = SupplyHubService.findInstallation(hub.ownerFactionId(), hub.installationId()); - if (other != null - && other.getProvince() == link.toProvince() - && other.getKind() == link.mode().getKind()) { - return other.getName(); - } - } - return "province " + link.toProvince(); + private static String hubNameIn(HubTransport.Link link) { + Installation other = SupplyHubService.findInstallation(link.toFactionId(), link.toInstallationId()); + return other == null ? "province " + link.toProvince() : other.getName(); + } + + private static boolean sameEndpoint(String factionId, String installationId, SupplyHub hub) { + return factionId != null && installationId != null && hub != null + && factionId.equalsIgnoreCase(hub.ownerFactionId()) + && installationId.equalsIgnoreCase(hub.installationId()); } private static double tradeInProvince(int provinceId, String guildId) { diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubService.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubService.java index c8f089ab..d430e752 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubService.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubService.java @@ -5,7 +5,6 @@ import java.util.List; import java.util.function.BiPredicate; -import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.FileConfiguration; import net.tfminecraft.simplefactions.Cache; @@ -28,9 +27,6 @@ * Whether a hub is active is computed here and never saved. */ public final class SupplyHubService { - public static final int DEFAULT_LIMIT = 2; - public static final double DEFAULT_UPKEEP = 15.0; - public static final double DEFAULT_GROWTH = 1.5; private static final Comparator OLDEST_FIRST = Comparator .comparingLong(SupplyHub::createdAt) @@ -77,48 +73,19 @@ public record HubCandidate( } public static void loadConfig(FileConfiguration config) { - int limit = DEFAULT_LIMIT; - double upkeep = DEFAULT_UPKEEP; - double growth = DEFAULT_GROWTH; - if (config != null) { - ConfigurationSection section = config.getConfigurationSection("supply-hubs"); - if (section != null) { - if (section.contains("base-limit")) { - limit = section.getInt("base-limit"); - } - if (section.contains("base-upkeep")) { - upkeep = section.getDouble("base-upkeep"); - } - if (section.contains("upkeep-growth")) { - growth = section.getDouble("upkeep-growth"); - } - } - } double maxTax = config == null ? 50.0 : config.getDouble("supply-hubs.max-tax", 50.0); Cache.supplyHubMaxTax = Double.isFinite(maxTax) ? Math.max(0, Math.min(100, maxTax)) : 50.0; - Cache.supplyHubBaseLimit = limit; - Cache.supplyHubBaseUpkeep = upkeep; - Cache.supplyHubUpkeepGrowth = growth; - } - - public static int baseLimit() { - return Cache.supplyHubBaseLimit; - } - - public static double baseUpkeep() { - return Cache.supplyHubBaseUpkeep; - } - - public static double upkeepGrowth() { - return Cache.supplyHubUpkeepGrowth; } public static int limit(Guild guild) { if (guild == null) { - return baseLimit(); + return 0; } - return Math.max(0, baseLimit() + (int) Math.floor( - GuildModifierOverride.resolve(guild, GuildModifier.HUB_LIMIT))); + return Math.max(0, (int) Math.floor(GuildModifierOverride.resolve(guild, GuildModifier.HUB_LIMIT))); + } + + public static double upkeepPerHub(Guild guild) { + return guild == null ? 0 : Math.max(0, GuildModifierOverride.resolve(guild, GuildModifier.HUB_UPKEEP)); } /** Hubs above the guild limit are dormant newest first, and remain in the saved list. */ @@ -325,49 +292,38 @@ public static String dormantReasonText(DormantReason reason) { } } - public static double upkeepAt(int indexOldestZero, double base, double growth) { - if (indexOldestZero < 0) { - return 0; - } - return base * Math.pow(growth, indexOldestZero); - } - - public static double totalUpkeep(int count, double base, double growth) { - double total = 0; - for (int i = 0; i < count; i++) { - total += upkeepAt(i, base, growth); - } - return total; + public static double totalUpkeep(int count, double perHub) { + return Math.max(0, count) * Math.max(0, perHub); } public static double dailyCost(Guild guild) { if (guild == null || guild.getSupplyHubs() == null || guild.getSupplyHubs().isEmpty()) { return 0; } - return totalUpkeep(guild.getSupplyHubs().size(), baseUpkeep(), upkeepGrowth()); + return totalUpkeep(guild.getSupplyHubs().size(), upkeepPerHub(guild)); } - public static double upkeepOf(SupplyHub hub, List guildHubs, double base, double growth) { + public static double upkeepOf(SupplyHub hub, List guildHubs, double perHub) { List ordered = oldestFirst(guildHubs); int index = indexOfHub(ordered, hub); if (index < 0) { return 0; } - return upkeepAt(index, base, growth); + return Math.max(0, perHub); } /** * Removes hubs the guild cannot pay for, newest first. Hubs that remain are the ones * the daily charge should take. Does not move money. */ - public static List shedUnpaid(List hubs, double wealth, double base, double growth) { + public static List shedUnpaid(List hubs, double wealth, double perHub) { List removed = new ArrayList<>(); if (hubs == null || hubs.isEmpty()) { return removed; } List ordered = oldestFirst(hubs); int guard = 0; - while (!ordered.isEmpty() && totalUpkeep(ordered.size(), base, growth) > wealth && guard++ < 10000) { + while (!ordered.isEmpty() && totalUpkeep(ordered.size(), perHub) > wealth && guard++ < 10000) { SupplyHub newest = ordered.remove(ordered.size() - 1); if (!hubs.remove(newest)) { break; @@ -386,7 +342,7 @@ public static List shedUnpaid(Guild guild) { if (bank != null && bank.getWealth() != null) { wealth = bank.getWealth(); } - return shedUnpaid(guild.getSupplyHubs(), wealth, baseUpkeep(), upkeepGrowth()); + return shedUnpaid(guild.getSupplyHubs(), wealth, upkeepPerHub(guild)); } public static boolean hasHub(List guildHubs, String ownerFactionId, String installationId) { diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubTutorial.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubTutorial.java new file mode 100644 index 00000000..e00d1f09 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubTutorial.java @@ -0,0 +1,69 @@ +package net.tfminecraft.simplefactions.guild.hub; + +import java.util.List; + +import org.bukkit.entity.Player; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.tfminecraft.simplefactions.installation.Installation; +import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; + +public final class SupplyHubTutorial { + public enum Decision { SHOW, NOTE, NONE } + + private static final String GOT_IT_LABEL = "[Got It]"; + private static final int SEPARATOR_WIDTH = 40; + + private SupplyHubTutorial() { + } + + public static Decision decision( + boolean hasCapital, boolean hasCapitalHub, boolean targetIsCapital, boolean dismissed) { + if (!hasCapital || hasCapitalHub || targetIsCapital) { + return Decision.NONE; + } + return dismissed ? Decision.NOTE : Decision.SHOW; + } + + public static void show(Player player, String capitalLine) { + player.sendMessage(StringFormatter.formatHex("#777777" + "-".repeat(SEPARATOR_WIDTH))); + player.sendMessage(StringFormatter.formatHex("#b5835aSupply Hubs")); + List lines = List.of( + "Supply hubs work in pairs. A hub sends a share of your guild's trade power" + + " and production to your other hubs it is linked to.", + "Trade power starts at your guild capital, so build your first hub there and" + + " your second where you want trade to arrive.", + "Hubs link by train track between any two of them. Ports also link across the" + + " sea, and airports through the air.", + capitalLine); + for (String line : lines) { + player.sendMessage("§7" + line); + } + String dashes = "-".repeat((SEPARATOR_WIDTH - GOT_IT_LABEL.length()) / 2); + Component hover = Component.text("Click to dismiss", NamedTextColor.GREEN) + .decorate(TextDecoration.BOLD) + .append(Component.text("\nYou won't see this tutorial again.", NamedTextColor.GRAY) + .decorate(TextDecoration.BOLD, TextDecoration.ITALIC)); + Component gotIt = Component.text(GOT_IT_LABEL, NamedTextColor.GREEN) + .decorate(TextDecoration.BOLD, TextDecoration.UNDERLINED) + .clickEvent(ClickEvent.runCommand("/guild hub gotit")) + .hoverEvent(HoverEvent.showText(hover)); + player.sendMessage(Component.empty() + .append(Component.text(dashes, NamedTextColor.GRAY)) + .append(gotIt) + .append(Component.text(dashes, NamedTextColor.GRAY))); + } + + public static String placementLine(Installation installation, String ownerName) { + if (installation != null) { + return "Your capital has a free hub slot at §f" + installation.getName() + "§7."; + } + String faction = ownerName == null || ownerName.isBlank() ? "the faction that owns it" : ownerName; + return "Your capital province has no port, airport or train station with a free hub slot. Ask " + + faction + " to build or upgrade one."; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/BranchLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/BranchLoader.java index a6562ee3..ba6768a1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/BranchLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/BranchLoader.java @@ -28,6 +28,13 @@ public static Branch getByString(String id) { for(Branch r : map.values()) { if(r.getId().equalsIgnoreCase(id)) return r; } + if (id != null && id.equalsIgnoreCase("freight_yards")) { + for (Branch branch : map.values()) { + if (branch.getId().equalsIgnoreCase("counting_houses")) { + return branch; + } + } + } return null; } public static Branch getByGroup(Guild guild, int group) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java index 4bdf13f3..1022037e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java @@ -452,9 +452,16 @@ public ItemStack createLedgerItem(Player p, Guild g) { if (value >= 0) continue; hasExpenses = true; + String display = cf.getDisplay(); + if (cf == Cashflow.SUPPLY_HUBS && !g.getSupplyHubs().isEmpty()) { + display += "#d4c9ae (" + g.getSupplyHubs().size() + " × " + + net.tfminecraft.simplefactions.utils.Formatter.formatDouble( + net.tfminecraft.simplefactions.guild.hub.SupplyHubService.upkeepPerHub(g)) + + "d per hub)"; + } lore.add(StringFormatter.formatHex( "#cfc7a2• " - + cf.getDisplay() + + display + "#d6cf69: #cf493a" + String.format("%.2f", value) + "d" diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubView.java index 42aa20fc..3fc4ff52 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubView.java @@ -67,7 +67,7 @@ private void populateGuildView(Player player, Guild guild, Inventory inventory) installation == null ? hub.installationId() : installation.getName(), installation == null ? "Unknown" : installation.getKind().getDisplayName(), owner == null ? hub.ownerFactionId() : owner.getName(), - SupplyHubService.upkeepOf(hub, hubs, SupplyHubService.baseUpkeep(), SupplyHubService.upkeepGrowth()), + SupplyHubService.upkeepOf(hub, hubs, SupplyHubService.upkeepPerHub(guild)), standing, connections, province == null ? 0 : SupplyHubService.exportedTrade(province, guild.getId()), @@ -88,7 +88,9 @@ private void populateGuildView(Player player, Guild guild, Inventory inventory) } int limit = SupplyHubService.limit(guild); inventory.setItem(49, SupplyHubCreator.item(Material.PAPER, "§eSupply Hub Information", List.of( + "§7Hubs work in pairs.", "§7Hubs: §e" + hubs.size() + "/" + limit, + "§7Upkeep per hub: §e" + Formatter.formatDouble(SupplyHubService.upkeepPerHub(guild)) + "d/day", "§7Daily upkeep: §e" + Formatter.formatDouble(SupplyHubService.dailyCost(guild)) + "d/day", "§7Build with §e/guild hub build §7while standing in a port, airport or train station"))); inventory.setItem(53, inv.createBackButton(SFGUI.SUPPLY_HUB_VIEW)); @@ -291,7 +293,8 @@ private static List connectionLore(Guild guild, SupplyHub hub, Installat double productionBonus = GuildModifierOverride.resolve(guild, GuildModifier.HUB_PRODUCTION); for (Link link : HubNetwork.linksFor(guild)) { if (link.fromProvince() != installation.getProvince() - || link.mode().getKind() != installation.getKind()) { + || !hub.ownerFactionId().equalsIgnoreCase(link.fromFactionId()) + || !hub.installationId().equalsIgnoreCase(link.fromInstallationId())) { continue; } Installation destination = null; @@ -299,7 +302,8 @@ private static List connectionLore(Guild guild, SupplyHub hub, Installat Installation candidate = SupplyHubService.findInstallation( other.ownerFactionId(), other.installationId()); if (candidate != null && candidate.getProvince() == link.toProvince() - && candidate.getKind() == link.mode().getKind()) { + && other.ownerFactionId().equalsIgnoreCase(link.toFactionId()) + && other.installationId().equalsIgnoreCase(link.toInstallationId())) { destination = candidate; break; } @@ -311,6 +315,9 @@ private static List connectionLore(Guild guild, SupplyHub hub, Installat + Math.round(link.boostedProductionFactor(productionBonus) * 100) + "% §7production"); } } + if (result.isEmpty() && standing(guild, hub).active()) { + result.add("§7Not linked to another hub"); + } return result; } } diff --git a/src/main/java/net/tfminecraft/simplefactions/map/export/Markers.java b/src/main/java/net/tfminecraft/simplefactions/map/export/Markers.java index 735f182e..070fd431 100644 --- a/src/main/java/net/tfminecraft/simplefactions/map/export/Markers.java +++ b/src/main/java/net/tfminecraft/simplefactions/map/export/Markers.java @@ -164,8 +164,8 @@ static JsonArray hubLinkRows(Guild guild, List links) { if (link == null || link.fromProvince() >= link.toProvince()) { continue; } - Installation from = activeHubAt(guild, link.fromProvince(), link.mode().getKind()); - Installation to = activeHubAt(guild, link.toProvince(), link.mode().getKind()); + Installation from = activeHubAt(guild, link.fromProvince(), link.fromFactionId(), link.fromInstallationId()); + Installation to = activeHubAt(guild, link.toProvince(), link.toFactionId(), link.toInstallationId()); if (from == null || to == null) { continue; } @@ -216,10 +216,12 @@ static JsonObject hubEndpointRow(HubEndpoint endpoint) { return row; } - private static Installation activeHubAt(Guild guild, int provinceId, InstallationKind kind) { + private static Installation activeHubAt(Guild guild, int provinceId, String factionId, String installationId) { for (SupplyHub hub : guild.getSupplyHubs()) { Installation installation = SupplyHubService.findInstallation(hub.ownerFactionId(), hub.installationId()); - if (installation == null || installation.getProvince() != provinceId || installation.getKind() != kind) { + if (installation == null || installation.getProvince() != provinceId + || !hub.ownerFactionId().equalsIgnoreCase(factionId) + || !hub.installationId().equalsIgnoreCase(installationId)) { continue; } Faction owner = FactionManager.getByString(hub.ownerFactionId()); diff --git a/src/main/resources/Guilds/branches.yml b/src/main/resources/Guilds/branches.yml index ef484bde..795a4350 100644 --- a/src/main/resources/Guilds/branches.yml +++ b/src/main/resources/Guilds/branches.yml @@ -64,37 +64,39 @@ storehouses: modifiers: - trade_carry 0.25 - trade_upkeep 0 0.02 + - hub_upkeep 0 0.5 - prestige 0 5 supply_lines: name: "#b5835aSupply Lines" icon: "rail.0" group: 3 description: - - "#b5835aSupply Lines #d4c9aestrengthen the" - - "#d4c9aeguild's network of supply hubs," - - "#d4c9aemaking more hubs available and" - - "#d4c9aepassing more trade power between them." + - "#b5835aSupply Lines #d4c9aeallow more" + - "#d4c9aehubs and pass on more trade power" + - "#d4c9aeand production between them." + - "#d4c9aeEach hub costs more to run." allowed-types: - guild - realm modifiers: - - hub_limit 0 0.5 - - hub_trade 0 0.03 + - hub_limit 2 0.5 + - hub_trade 0 0.05 + - hub_production 0 0.08 + - hub_upkeep 15 1 - trade_upkeep 0 0.01 - prestige 0 5 -freight_yards: - name: "#a9744fFreight Yards" +counting_houses: + name: "#a9744fCounting Houses" icon: "chest_minecart.0" group: 4 description: - - "#a9744fFreight Yards #d4c9aeimprove the" - - "#d4c9aehandover of production between" - - "#d4c9aehubs, helping workshops prosper" - - "#d4c9aefar beyond the guild's capital." + - "#a9744fCounting Houses #d4c9aekeep the" + - "#d4c9aeguild's books in order, reducing" + - "#d4c9aetrade upkeep by #a9744f0.01 #d4c9aeper" + - "#d4c9aelevel, to a minimum of zero." allowed-types: - guild - realm modifiers: - - hub_production 0 0.08 - - trade_upkeep 0 0.01 + - trade_upkeep 0 -0.01 - prestige 0 5 diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 1ba2741a..e22b0d0a 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -84,17 +84,11 @@ mercenary-default-breach-refund: 500.0 # smaller refund would make no-showing more profitable per head than fighting. supply-hubs: - # Hubs a guild may have before any upgrades. - base-limit: 2 - # Daily upkeep of a guild's first hub, in denars. - base-upkeep: 15 - # Each further hub costs this many times the one before. - upkeep-growth: 1.5 # Maximum hub tax rate a host faction can set. max-tax: 50 # A hub takes the guild's trade power and production in its province and delivers a share - # to each of the guild's hubs it is connected to. Train stations connect by track, ports - # across the sea, airports through the air; different kinds do not connect. + # to each of the guild's hubs it is connected to. Train track joins any two kinds; ports + # also connect across the sea, and airports through the air. # trade / production: share delivered before distance loss (capped at 0.95) # kept-per-1000-blocks: share that survives each 1000 blocks # max-range: longest connection in blocks, 0 for no limit diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubNetworkTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubNetworkTest.java index 74239de2..c0d9050d 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubNetworkTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubNetworkTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.bukkit.configuration.file.YamlConfiguration; import net.tfminecraft.simplefactions.enums.Terrain; import net.tfminecraft.simplefactions.guild.hub.HubTransport.Link; @@ -107,7 +108,7 @@ void trackIsMeasuredOncePerPairUntilForgotten() { } @Test - void missingTrackIsAskedAboutAgain_soANewLineConnectsAtOnce() { + void missingTrackIsCachedUntilForgotten_thenANewLineConnects() { boolean[] laid = {false}; HubNetwork.setRailRoutesForTests((from, to) -> laid[0] ? OptionalDouble.of(300) : OptionalDouble.empty()); Installation a = installation("a", InstallationKind.TRAIN_STATION, 1, 0, 0); @@ -116,6 +117,8 @@ void missingTrackIsAskedAboutAgain_soANewLineConnectsAtOnce() { assertNull(HubNetwork.connect(a, b, provinces)); laid[0] = true; + assertNull(HubNetwork.connect(a, b, provinces)); + HubNetwork.forgetRoutes(); assertNotNull(HubNetwork.connect(a, b, provinces)); } @@ -152,7 +155,7 @@ void airportsConnectWithinRangeOnly() { } @Test - void differentKindsAndSameProvinceDoNotConnect() { + void mixedKindsUseRailAndSameProvinceDoesNotConnect() { HubNetwork.setRailRoutesForTests((from, to) -> OptionalDouble.of(10)); Installation station = installation("a", InstallationKind.TRAIN_STATION, 1, 0, 0); Installation port = installation("b", InstallationKind.PORT, 5, 10, 10); @@ -160,7 +163,7 @@ void differentKindsAndSameProvinceDoNotConnect() { Installation fort = installation("d", InstallationKind.FORT, 5, 10, 10); Installation otherFort = installation("e", InstallationKind.FORT, 6, 10, 10); - assertNull(HubNetwork.connect(station, port, provinces)); + assertEquals(Mode.RAIL, HubNetwork.connect(station, port, provinces).mode()); assertNull(HubNetwork.connect(station, sameProvince, provinces)); assertNull(HubNetwork.connect(fort, otherFort, provinces)); assertNull(HubNetwork.connect(station, station, provinces)); @@ -175,11 +178,46 @@ void linksRunBothWaysBetweenEveryConnectedPair() { List links = HubNetwork.linksBetween(List.of(a, b, airport), provinces); - assertEquals(2, links.size()); + assertEquals(6, links.size()); assertEquals(1, links.get(0).fromProvince()); assertEquals(5, links.get(0).toProvince()); - assertEquals(5, links.get(1).fromProvince()); - assertEquals(1, links.get(1).toProvince()); + assertEquals(1, links.get(1).fromProvince()); + assertEquals(6, links.get(1).toProvince()); + assertEquals(5, links.get(2).fromProvince()); + assertEquals(1, links.get(2).toProvince()); + } + + + @Test + void aPairUsesTheModeWithTheHigherTradeShareAndRailWinsATie() { + Installation a = installation("a", InstallationKind.PORT, 1, 0, 0); + Installation b = installation("b", InstallationKind.PORT, 5, 600, 800); + HubNetwork.setRailRoutesForTests((from, to) -> OptionalDouble.of(1000)); + YamlConfiguration config = new YamlConfiguration(); + config.set("supply-hubs.transport.rail.trade", 0.50); + config.set("supply-hubs.transport.rail.kept-per-1000-blocks", 1.0); + config.set("supply-hubs.transport.sea.trade", 0.60); + config.set("supply-hubs.transport.sea.kept-per-1000-blocks", 1.0); + HubTransport.loadConfig(config); + assertEquals(Mode.SEA, HubNetwork.connect(a, b, provinces).mode()); + + config.set("supply-hubs.transport.rail.trade", 0.60); + HubTransport.loadConfig(config); + assertEquals(Mode.RAIL, HubNetwork.connect(a, b, provinces).mode()); + } + + @Test + void aMissingTrackIsAskedAgainAfterThirtySecondsOnly() { + int[] calls = {0}; + HubNetwork.setRailRoutesForTests((from, to) -> { + calls[0]++; + return OptionalDouble.empty(); + }); + Installation a = installation("a", InstallationKind.TRAIN_STATION, 1, 0, 0); + Installation b = installation("b", InstallationKind.AIRPORT, 5, 10, 10); + HubNetwork.connect(a, b, provinces); + HubNetwork.connect(a, b, provinces); + assertEquals(1, calls[0]); } private static Installation installation(String id, InstallationKind kind, int province, int x, int z) { diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubTaxServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubTaxServiceTest.java index b63f049d..1d50b49d 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubTaxServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubTaxServiceTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.ArrayList; @@ -68,13 +69,64 @@ void noGainOrNegativeMarginalsCannotBeTaxed() { @Test void removingAHubOnlyRemovesItsOwnModeAtEitherEnd() { - Installation station = new Installation("a", "A", InstallationKind.TRAIN_STATION, 1, 0, 0, 0L); - assertTrue(HubTaxService.touches(new Link(1, 2, Mode.RAIL, 0, 0.7, 0.25), station)); - assertTrue(HubTaxService.touches(new Link(2, 1, Mode.RAIL, 0, 0.7, 0.25), station)); + SupplyHub station = new SupplyHub("owner", "a", 0); + SupplyHub port = new SupplyHub("owner", "port", 0); + assertTrue(HubTaxService.touches(new Link(1, 2, "owner", "a", "owner", "b", Mode.RAIL, 0, 0.7, 0.25), station)); + assertTrue(HubTaxService.touches(new Link(2, 1, "owner", "b", "owner", "a", Mode.RAIL, 0, 0.7, 0.25), station)); org.junit.jupiter.api.Assertions.assertFalse( - HubTaxService.touches(new Link(1, 2, Mode.AIR, 0, 0.2, 0), station)); + HubTaxService.touches(new Link(1, 2, "owner", "c", "owner", "b", Mode.AIR, 0, 0.2, 0), station)); org.junit.jupiter.api.Assertions.assertFalse( - HubTaxService.touches(new Link(2, 3, Mode.RAIL, 0, 0.7, 0.25), station)); + HubTaxService.touches(new Link(2, 3, "owner", "b", "owner", "c", Mode.RAIL, 0, 0.7, 0.25), station)); + Link stationRoute = new Link(1, 2, "owner", "a", "remote", "b", Mode.RAIL, 0, 0.7, 0.25); + Link portRoute = new Link(1, 3, "owner", "port", "remote", "c", Mode.RAIL, 0, 0.7, 0.25); + assertTrue(HubTaxService.touches(stationRoute, station)); + assertTrue(HubTaxService.touches(portRoute, port)); + org.junit.jupiter.api.Assertions.assertFalse(HubTaxService.touches(portRoute, station)); + org.junit.jupiter.api.Assertions.assertFalse(HubTaxService.touches(stationRoute, port)); + } + + @Test + void connectionLinesAttributeSameProvinceHubsByInstallation() { + Guild guild = mock(Guild.class); + when(guild.getId()).thenReturn("guild"); + SupplyHub portHub = new SupplyHub("home", "port", 1); + SupplyHub stationHub = new SupplyHub("home", "station", 2); + SupplyHub portDestinationHub = new SupplyHub("remote", "port-destination", 3); + SupplyHub stationDestinationHub = new SupplyHub("remote", "station-destination", 4); + when(guild.getSupplyHubs()).thenReturn(List.of( + portHub, stationHub, portDestinationHub, stationDestinationHub)); + Installation port = new Installation("port", "Port", InstallationKind.PORT, 1, 0, 0, 0L); + Installation station = new Installation("station", "Station", InstallationKind.TRAIN_STATION, 1, 0, 0, 0L); + Installation portDestination = new Installation( + "port-destination", "Port destination", InstallationKind.PORT, 2, 0, 0, 0L); + Installation stationDestination = new Installation( + "station-destination", "Station destination", InstallationKind.TRAIN_STATION, 3, 0, 0, 0L); + InstallationHandler homeSites = mock(InstallationHandler.class); + InstallationHandler remoteSites = mock(InstallationHandler.class); + Faction home = mock(Faction.class); + Faction remote = mock(Faction.class); + when(home.getInstallationHandler()).thenReturn(homeSites); + when(remote.getInstallationHandler()).thenReturn(remoteSites); + when(homeSites.getById("port")).thenReturn(port); + when(homeSites.getById("station")).thenReturn(station); + when(remoteSites.getById("port-destination")).thenReturn(portDestination); + when(remoteSites.getById("station-destination")).thenReturn(stationDestination); + List links = List.of( + new Link(1, 2, "home", "port", "remote", "port-destination", Mode.RAIL, 100, 0.7, 0.25), + new Link(1, 3, "home", "station", "remote", "station-destination", Mode.RAIL, 200, 0.7, 0.25)); + HubNetwork.setLinksForTests(Map.of("guild", links)); + try (MockedStatic factions = mockStatic(FactionManager.class)) { + factions.when(() -> FactionManager.getByString("home")).thenReturn(home); + factions.when(() -> FactionManager.getByString("remote")).thenReturn(remote); + List portLines = SupplyHubCommands.connectionLines(guild, portHub, port); + List stationLines = SupplyHubCommands.connectionLines(guild, stationHub, station); + assertEquals(1, portLines.size()); + assertTrue(portLines.get(0).contains("Port destination")); + assertEquals(1, stationLines.size()); + assertTrue(stationLines.get(0).contains("Station destination")); + } finally { + HubNetwork.setLinksForTests(null); + } } @Test @@ -96,6 +148,7 @@ void snapshotsAssessForeignHubsWithoutChangingLiveDataOrTheNetwork() { when(guild.hasCapital()).thenReturn(true); when(guild.getCapital()).thenReturn(1); when(guild.getModifier(GuildModifier.TRADE_POWER)).thenReturn(20.0); + when(guild.getModifier(GuildModifier.HUB_LIMIT)).thenReturn(3.0); when(guild.getModifier(GuildModifier.TRADE_CARRY)).thenReturn(1.0); when(guild.getModifier(GuildModifier.PRODUCTION)).thenReturn(10.0); TradeBreakdown trade = new TradeBreakdown(); @@ -123,8 +176,8 @@ void snapshotsAssessForeignHubsWithoutChangingLiveDataOrTheNetwork() { map.get(id + 1).addNeighbour(id); } provinces.start(map); - List links = List.of(new Link(1, 22, Mode.RAIL, 0, 0.7, 0.25), - new Link(22, 1, Mode.RAIL, 0, 0.7, 0.25)); + List links = List.of(new Link(1, 22, "home", "a", "host", "b", Mode.RAIL, 0, 0.7, 0.25), + new Link(22, 1, "host", "b", "home", "a", Mode.RAIL, 0, 0.7, 0.25)); HubNetwork.setLinksForTests(Map.of("guild", links)); try (MockedStatic factions = mockStatic(FactionManager.class); MockedStatic titles = mockStatic(TitleManager.class); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubServiceTest.java index 9bf216d0..834ce31e 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubServiceTest.java @@ -52,16 +52,10 @@ class SupplyHubServiceTest { private final List previousFactions = new ArrayList<>(FactionManager.factions); - private final int previousLimit = Cache.supplyHubBaseLimit; - private final double previousUpkeep = Cache.supplyHubBaseUpkeep; - private final double previousGrowth = Cache.supplyHubUpkeepGrowth; @AfterEach void restore() { FactionManager.factions = new ArrayList<>(previousFactions); - Cache.supplyHubBaseLimit = previousLimit; - Cache.supplyHubBaseUpkeep = previousUpkeep; - Cache.supplyHubUpkeepGrowth = previousGrowth; } @Test @@ -91,12 +85,14 @@ void forbiddenEconomyMakesExistingHubsDormantButKeepsUpkeepAndSlots() { Guild guild = guildWith(hub); Faction faction = mock(Faction.class); when(guild.getFaction()).thenReturn(faction); + when(guild.getModifier(GuildModifier.HUB_LIMIT)).thenReturn(2.0); HubStanding standing = SupplyHubService.standing(guild, hub, true, true, 1, List.of(hub)); assertFalse(standing.active()); assertEquals(DormantReason.ECONOMY_DISALLOWS, standing.reason()); assertEquals("§cDormant §7(your faction's economy does not allow supply hubs)", SupplyHubService.statusText(standing)); - assertEquals(SupplyHubService.baseUpkeep(), SupplyHubService.dailyCost(guild)); + when(guild.getModifier(GuildModifier.HUB_UPKEEP)).thenReturn(15.0); + assertEquals(15.0, SupplyHubService.dailyCost(guild)); assertEquals(1, SupplyHubService.countAt("decentralized", "station", List.of(guild))); // The host's economy does not forbid a foreign guild that may build hubs. @@ -163,12 +159,10 @@ void hubSlots_missingKeyDefaults() { } @Test - void upkeep_growsFromTheOldestHub() { - assertEquals(15.0, SupplyHubService.upkeepAt(0, 15, 1.5), 1e-9); - assertEquals(22.5, SupplyHubService.upkeepAt(1, 15, 1.5), 1e-9); - assertEquals(33.75, SupplyHubService.upkeepAt(2, 15, 1.5), 1e-9); - assertEquals(50.625, SupplyHubService.upkeepAt(3, 15, 1.5), 1e-9); - assertEquals(71.25, SupplyHubService.totalUpkeep(3, 15, 1.5), 1e-9); + void upkeepIsFlatForEveryHub() { + assertEquals(0.0, SupplyHubService.totalUpkeep(0, 15), 1e-9); + assertEquals(45.0, SupplyHubService.totalUpkeep(3, 15), 1e-9); + assertEquals(15.0, SupplyHubService.totalUpkeep(3, 5), 1e-9); } @Test @@ -177,16 +171,16 @@ void shed_removesNewestFirstUntilTheRestCanBePaid() { hubs.add(hub("rome", "a", 1)); hubs.add(hub("rome", "b", 2)); hubs.add(hub("rome", "c", 3)); - List removed = SupplyHubService.shedUnpaid(hubs, 40, 15, 1.5); + List removed = SupplyHubService.shedUnpaid(hubs, 40, 15); assertEquals(List.of("c"), ids(removed)); assertEquals(List.of("a", "b"), ids(hubs)); - assertEquals(37.5, SupplyHubService.totalUpkeep(hubs.size(), 15, 1.5), 1e-9); + assertEquals(30.0, SupplyHubService.totalUpkeep(hubs.size(), 15), 1e-9); - removed = SupplyHubService.shedUnpaid(hubs, 15, 15, 1.5); + removed = SupplyHubService.shedUnpaid(hubs, 15, 15); assertEquals(List.of("b"), ids(removed)); assertEquals(List.of("a"), ids(hubs)); - removed = SupplyHubService.shedUnpaid(hubs, 14.99, 15, 1.5); + removed = SupplyHubService.shedUnpaid(hubs, 14.99, 15); assertEquals(List.of("a"), ids(removed)); assertTrue(hubs.isEmpty()); } @@ -196,7 +190,7 @@ void shed_keepsHubsTheGuildCanPayFor() { List hubs = new ArrayList<>(); hubs.add(hub("rome", "a", 1)); hubs.add(hub("rome", "b", 2)); - assertTrue(SupplyHubService.shedUnpaid(hubs, 37.5, 15, 1.5).isEmpty()); + assertTrue(SupplyHubService.shedUnpaid(hubs, 30, 15).isEmpty()); assertEquals(2, hubs.size()); } @@ -232,12 +226,11 @@ void standing_eachDormantReason_andActive() { @Test void guildLimitIncludesWholeBranchBonus_andExcessHubsAreNewestFirst() { - Cache.supplyHubBaseLimit = 2; Guild guild = mock(Guild.class); List hubs = new ArrayList<>(List.of( hub("rome", "old", 1), hub("rome", "middle", 2), hub("rome", "new", 3))); when(guild.getSupplyHubs()).thenReturn(hubs); - when(guild.getModifier(GuildModifier.HUB_LIMIT)).thenReturn(1.5); + when(guild.getModifier(GuildModifier.HUB_LIMIT)).thenReturn(3.5); assertEquals(3, SupplyHubService.limit(guild)); assertFalse(SupplyHubService.beyondGuildLimit(guild, hubs.get(0))); @@ -245,19 +238,26 @@ void guildLimitIncludesWholeBranchBonus_andExcessHubsAreNewestFirst() { assertFalse(SupplyHubService.beyondGuildLimit(guild, hubs.get(2))); when(guild.getModifier(GuildModifier.HUB_LIMIT)).thenReturn(0.5); - assertEquals(2, SupplyHubService.limit(guild)); - assertTrue(SupplyHubService.beyondGuildLimit(guild, hubs.get(2))); + assertEquals(0, SupplyHubService.limit(guild)); + assertTrue(SupplyHubService.beyondGuildLimit(guild, hubs.get(0))); assertTrue(SupplyHubService.statusText(new HubStanding( false, DormantReason.BEYOND_GUILD_LIMIT)).contains("guild's supply hub limit")); } + @Test + void hubLimitIsNotScaledWithInactivity() { + assertTrue(GuildModifier.HUB_LIMIT.isPositive()); + assertFalse(GuildModifier.HUB_LIMIT.scalesWithInactivity()); + assertFalse(GuildModifier.HUB_UPKEEP.isPositive()); + } + @Test void dormantHub_stillCostsUpkeepAndOccupiesASlot() { SupplyHub first = hub("rome", "harbour", 1); SupplyHub dormant = hub("rome", "harbour", 2); List guildHubs = List.of(first, dormant); - assertEquals(22.5, SupplyHubService.upkeepOf(dormant, guildHubs, 15, 1.5), 1e-9); - assertEquals(37.5, SupplyHubService.totalUpkeep(guildHubs.size(), 15, 1.5), 1e-9); + assertEquals(15.0, SupplyHubService.upkeepOf(dormant, guildHubs, 15), 1e-9); + assertEquals(30.0, SupplyHubService.totalUpkeep(guildHubs.size(), 15), 1e-9); assertEquals(2, SupplyHubService.countAt("rome", "harbour", List.of(guildWith(first, dormant)))); } @@ -357,24 +357,13 @@ void permits_toggleAndRoundTripWithTheFaction() { } @Test - void missingConfigKeys_useDefaults() { - YamlConfiguration empty = new YamlConfiguration(); - SupplyHubService.loadConfig(empty); - assertEquals(2, Cache.supplyHubBaseLimit); - assertEquals(15.0, Cache.supplyHubBaseUpkeep, 1e-9); - assertEquals(1.5, Cache.supplyHubUpkeepGrowth, 1e-9); - - YamlConfiguration partial = new YamlConfiguration(); - partial.set("supply-hubs.base-limit", 4); - SupplyHubService.loadConfig(partial); - assertEquals(4, Cache.supplyHubBaseLimit); - assertEquals(15.0, Cache.supplyHubBaseUpkeep, 1e-9); - assertEquals(1.5, Cache.supplyHubUpkeepGrowth, 1e-9); - - YamlConfiguration explicitZero = new YamlConfiguration(); - explicitZero.set("supply-hubs.base-upkeep", 0); - SupplyHubService.loadConfig(explicitZero); - assertEquals(0.0, Cache.supplyHubBaseUpkeep, 1e-9); + void oldUpkeepAndLimitConfigKeysAreIgnored() { + YamlConfiguration old = new YamlConfiguration(); + old.set("supply-hubs.base-limit", 4); + old.set("supply-hubs.base-upkeep", 0); + old.set("supply-hubs.upkeep-growth", 3.0); + SupplyHubService.loadConfig(old); + assertEquals(50.0, Cache.supplyHubMaxTax, 1e-9); } @Test @@ -409,8 +398,6 @@ void notify_tellsTheLeaderWhenOnline() { @Test void ledger_supplyHubsIsItsOwnSinkLine() { - Cache.supplyHubBaseUpkeep = 15; - Cache.supplyHubUpkeepGrowth = 1.5; Guild guild = mock(Guild.class); Faction faction = mock(Faction.class); List hubs = new ArrayList<>(); @@ -418,31 +405,31 @@ void ledger_supplyHubsIsItsOwnSinkLine() { hubs.add(hub("rome", "b", 2)); hubs.add(hub("rome", "c", 3)); when(guild.getSupplyHubs()).thenReturn(hubs); + when(guild.getModifier(GuildModifier.HUB_UPKEEP)).thenReturn(15.0); Bank bank = mock(Bank.class); when(bank.getWealth()).thenReturn(40.0); Ledger ledger = ledger(faction, guild, bank); assertTrue(Cashflow.SUPPLY_HUBS.getDisplay().contains("Supply hubs")); - assertEquals(-71.25, ledger.getIncome(Cashflow.SUPPLY_HUBS), 1e-9); + assertEquals(-45.0, ledger.getIncome(Cashflow.SUPPLY_HUBS), 1e-9); DailyGuildTransfers buffer = new DailyGuildTransfers(); ledger.populateDailyTransfers(buffer); assertEquals(List.of("a", "b"), ids(hubs)); - assertEquals(-37.5, buffer.getExternalDeltas().get(guild), 1e-9); + assertEquals(-30.0, buffer.getExternalDeltas().get(guild), 1e-9); assertTrue(buffer.getTransfers().isEmpty()); - assertEquals(-37.5, ledger.getIncome(Cashflow.SUPPLY_HUBS), 1e-9); - assertEquals(-37.5, ledger.getNetIncome(), 1e-9); + assertEquals(-30.0, ledger.getIncome(Cashflow.SUPPLY_HUBS), 1e-9); + assertEquals(-30.0, ledger.getNetIncome(), 1e-9); } @Test void ledger_removesEveryHubWhenNoneCanBePaid() { - Cache.supplyHubBaseUpkeep = 15; - Cache.supplyHubUpkeepGrowth = 1.5; Guild guild = mock(Guild.class); Faction faction = mock(Faction.class); List hubs = new ArrayList<>(); hubs.add(hub("rome", "a", 1)); when(guild.getSupplyHubs()).thenReturn(hubs); + when(guild.getModifier(GuildModifier.HUB_UPKEEP)).thenReturn(15.0); Bank bank = mock(Bank.class); when(bank.getWealth()).thenReturn(10.0); Ledger ledger = ledger(faction, guild, bank); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubTutorialTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubTutorialTest.java new file mode 100644 index 00000000..1cbd485e --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubTutorialTest.java @@ -0,0 +1,18 @@ +package net.tfminecraft.simplefactions.guild.hub; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.guild.hub.SupplyHubTutorial.Decision; + +class SupplyHubTutorialTest { + @Test + void tutorialDecisionShowsNotesOrDoesNothing() { + assertEquals(Decision.SHOW, SupplyHubTutorial.decision(true, false, false, false)); + assertEquals(Decision.NOTE, SupplyHubTutorial.decision(true, false, false, true)); + assertEquals(Decision.NONE, SupplyHubTutorial.decision(false, false, false, false)); + assertEquals(Decision.NONE, SupplyHubTutorial.decision(true, true, false, false)); + assertEquals(Decision.NONE, SupplyHubTutorial.decision(true, false, true, false)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/loaders/SupplyHubBranchesTest.java b/src/test/java/net/tfminecraft/simplefactions/loaders/SupplyHubBranchesTest.java index 4059e630..6bb41622 100644 --- a/src/test/java/net/tfminecraft/simplefactions/loaders/SupplyHubBranchesTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/loaders/SupplyHubBranchesTest.java @@ -18,6 +18,7 @@ import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.guild.GuildType; import net.tfminecraft.simplefactions.guild.branch.Branch; +import net.tfminecraft.simplefactions.database.GuildBranchData; class SupplyHubBranchesTest { private final Map savedBranches = new HashMap<>(BranchLoader.map); @@ -61,9 +62,35 @@ void bundledBranchesLoadForGuildsAndRealms() { assertNotNull(freightGuild); assertNotNull(freightRealm); assertEquals("supply_lines", supplyGuild.getId()); - assertEquals("freight_yards", freightRealm.getId()); + assertEquals("counting_houses", freightRealm.getId()); + assertEquals(2.0, supplyGuild.getModifier(GuildModifier.HUB_LIMIT).getBase()); assertEquals(0.5, supplyGuild.getModifier(GuildModifier.HUB_LIMIT).getPerLevel()); - assertEquals(0.08, freightRealm.getModifier(GuildModifier.HUB_PRODUCTION).getPerLevel()); + assertEquals(0.05, supplyGuild.getModifier(GuildModifier.HUB_TRADE).getPerLevel()); + assertEquals(0.08, supplyGuild.getModifier(GuildModifier.HUB_PRODUCTION).getPerLevel()); + assertEquals(-0.01, freightRealm.getModifier(GuildModifier.TRADE_UPKEEP).getPerLevel()); + assertEquals(15.0, supplyGuild.getModifier(GuildModifier.HUB_UPKEEP).getBase()); + assertEquals(1.0, supplyGuild.getModifier(GuildModifier.HUB_UPKEEP).getPerLevel()); + assertEquals(0.5, BranchLoader.getByString("storehouses").getModifier(GuildModifier.HUB_UPKEEP).getPerLevel()); assertTrue(!supplyGuild.getDescription().isEmpty()); } + + @Test + void freightYardsResolvesToCountingHousesOnlyWhenNoExactBranchExists() { + Branch counting = new Branch("counting_houses", new YamlConfiguration().createSection("counting_houses")); + BranchLoader.map.clear(); + BranchLoader.map.put(counting.getId(), counting); + assertEquals(counting, BranchLoader.getByString("freight_yards")); + Branch migrated = new Branch(BranchLoader.getByString("freight_yards"), 3); + assertEquals("counting_houses", migrated.getId()); + assertEquals(3, migrated.getLevel()); + GuildBranchData saved = new GuildBranchData(); + saved.id = migrated.getId(); + saved.level = migrated.getLevel(); + assertEquals("counting_houses", saved.id); + assertEquals(3, saved.level); + + Branch legacy = new Branch("freight_yards", new YamlConfiguration().createSection("freight_yards")); + BranchLoader.map.put(legacy.getId(), legacy); + assertEquals(legacy, BranchLoader.getByString("freight_yards")); + } } diff --git a/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxHandlerHubTaxTest.java b/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxHandlerHubTaxTest.java index 253fb09a..9a36737d 100644 --- a/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxHandlerHubTaxTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxHandlerHubTaxTest.java @@ -25,18 +25,12 @@ class TaxHandlerHubTaxTest { private double previousCap; - private int previousLimit; - private double previousUpkeep; - private double previousGrowth; private Faction faction; private TaxHandler handler; @BeforeEach void setUp() { previousCap = Cache.supplyHubMaxTax; - previousLimit = Cache.supplyHubBaseLimit; - previousUpkeep = Cache.supplyHubBaseUpkeep; - previousGrowth = Cache.supplyHubUpkeepGrowth; SupplyHubService.loadConfig(null); faction = mock(Faction.class); Government government = mock(Government.class); @@ -51,9 +45,6 @@ void setUp() { void tearDown() { IncomePreviewContext.clear(); Cache.supplyHubMaxTax = previousCap; - Cache.supplyHubBaseLimit = previousLimit; - Cache.supplyHubBaseUpkeep = previousUpkeep; - Cache.supplyHubUpkeepGrowth = previousGrowth; } @Test