From 156d928bd588fdad9bb0d75f0b6f6a7cd408b7c4 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:56:28 +0000 Subject: [PATCH] Estimate hub, infrastructure, and installation income on a snapshot. Menus can rank destinations and split tax and fee without running the map again, and the daily pass keeps that list off the server thread. Co-authored-by: Cursor --- .../net/tfminecraft/simplefactions/Cache.java | 1 + .../guild/hub/HubEstimates.java | 647 ++++++++++++++++++ .../guild/hub/SupplyHubService.java | 2 + .../guild/income/EconomicPreview.java | 5 + .../managers/FactionManager.java | 2 + .../managers/ProvinceManager.java | 30 + src/main/resources/config.yml | 3 + .../guild/hub/HubEstimatesTest.java | 401 +++++++++++ 8 files changed, 1091 insertions(+) create mode 100644 src/main/java/net/tfminecraft/simplefactions/guild/hub/HubEstimates.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/guild/hub/HubEstimatesTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/Cache.java b/src/main/java/net/tfminecraft/simplefactions/Cache.java index fc802748..f986fa55 100644 --- a/src/main/java/net/tfminecraft/simplefactions/Cache.java +++ b/src/main/java/net/tfminecraft/simplefactions/Cache.java @@ -169,6 +169,7 @@ public static boolean requireProvinces(org.bukkit.command.CommandSender sender) public static double supplyHubMaxFee = 500.0; public static int supplyHubOfferDays = 7; public static int supplyHubAgreementDays = 14; + public static int supplyHubEstimateCandidates = 24; public static double infrastructureFull = 20; public static double infrastructureTarget = 0.75; diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubEstimates.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubEstimates.java new file mode 100644 index 00000000..78b660f9 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubEstimates.java @@ -0,0 +1,647 @@ +package net.tfminecraft.simplefactions.guild.hub; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; + +import org.bukkit.Bukkit; + +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.enums.Rules; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.hub.HubTransport.Link; +import net.tfminecraft.simplefactions.guild.hub.HubTransport.Mode; +import net.tfminecraft.simplefactions.installation.Installation; +import net.tfminecraft.simplefactions.installation.InstallationKind; +import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.ProvinceManager; +import net.tfminecraft.simplefactions.managers.RelationManager; +import net.tfminecraft.simplefactions.managers.TitleManager; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.guild.income.EconomicPreview; +import net.tfminecraft.simplefactions.objects.Faction; + +/** + * What-if income for a hub, a realm's infrastructure, or one new installation. + * Menus read the cache. Tax and fee are arithmetic on a result that was already run. + */ +public final class HubEstimates { + public enum Group { + READY, + WORTH_BUILDING + } + + /** + * One ranked destination. {@code installationId} is null when the installation + * is only imagined. Gains are net income before this hub's tax, fee, and upkeep. + */ + public record Destination( + Group group, + String hostFactionId, + String installationId, + String label, + int provinceId, + InstallationKind kind, + boolean ownRealm, + boolean assumedRailway, + double assumedTrackBlocks, + double operatorGain, + double hostGain, + Map hostGuildGains, + double taxableIncome) { + } + + /** Operator pays the tax and the fee. The host realm receives both. Own-realm terms are zero. */ + public record Terms(double operatorNet, double hostNet, double tax, double fee) { + } + + /** What building this installation is worth to the realm that would own it. */ + public record InstallationPreview(double infrastructureHere, double realmPerDay, double upkeep) { + public static final InstallationPreview NONE = new InstallationPreview(0, 0, 0); + } + + record Candidate( + Group group, + Faction host, + Installation installation, + boolean ownRealm, + double distance) { + } + + record Planned(List added, boolean assumedRailway, double assumedTrackBlocks) { + } + + record Result( + double operatorGain, + double hostGain, + Map hostGuildGains, + double taxableIncome) { + } + + static void clearForTests() { + proposals = Map.of(); + headlines = Map.of(); + RUNNING.set(false); + } + + private static volatile Map> proposals = Map.of(); + private static volatile Map headlines = Map.of(); + private static final AtomicBoolean RUNNING = new AtomicBoolean(); + + private HubEstimates() { + } + + public static List destinations(Guild guild) { + if (guild == null || guild.getId() == null) { + return List.of(); + } + return destinations(guild.getId()); + } + + public static List destinations(String guildId) { + if (guildId == null) { + return List.of(); + } + List exact = proposals.get(guildId); + if (exact != null) { + return exact; + } + for (Map.Entry> entry : proposals.entrySet()) { + if (guildId.equalsIgnoreCase(entry.getKey())) { + return entry.getValue(); + } + } + return List.of(); + } + + /** How much infrastructure is worth to this realm, from the last daily pass. Positive earns. */ + public static double infrastructureWorth(Faction realm) { + if (realm == null) { + return 0; + } + return infrastructureWorth(realm.getId()); + } + + public static double infrastructureWorth(String realmId) { + if (realmId == null) { + return 0; + } + Double exact = headlines.get(realmId); + if (exact != null) { + return exact; + } + for (Map.Entry entry : headlines.entrySet()) { + if (realmId.equalsIgnoreCase(entry.getKey())) { + return entry.getValue(); + } + } + return 0; + } + + public static Terms applyTerms(Destination destination, int ratePercent, long feeCents) { + if (destination == null) { + return new Terms(0, 0, 0, 0); + } + if (destination.ownRealm()) { + return new Terms(destination.operatorGain(), destination.hostGain(), 0, 0); + } + int rate = Math.max(0, Math.min(100, ratePercent)); + double fee = Math.max(0, feeCents) / 100.0; + double tax = destination.taxableIncome() * rate / 100.0; + return new Terms( + round(destination.operatorGain() - tax - fee), + round(destination.hostGain() + tax + fee), + round(tax), + round(fee)); + } + + /** + * On demand. Adds this kind's infrastructure in the province and reads the realm's net change. + * Writes live trade breakdowns, the same way a law preview does. + */ + public static InstallationPreview previewInstallation( + ProvinceManager live, Faction realm, int provinceId, InstallationKind kind) { + if (live == null || realm == null || kind == null || !live.contains(provinceId)) { + return InstallationPreview.NONE; + } + Province province = live.get(provinceId); + if (province.isSea()) { + return InstallationPreview.NONE; + } + double added = sourceAmount(kind); + Map before = EconomicPreview.projectNets(copy(live)); + ProvinceManager after = copy(live); + if (added > 0) { + after.setExtraInfrastructure(Map.of(provinceId, added)); + } + Map next = EconomicPreview.projectNets(after); + return new InstallationPreview(added, realmDelta(realm, before, next), levelOneUpkeep(kind)); + } + + /** After income and the agreement tick. One pass, then the live trade numbers are put back. */ + public static void scheduleDaily() { + SimpleFactions plugin = SimpleFactions.getInstance(); + if (plugin == null || plugin.getProvinceManager() == null || !Cache.provincesEnabled) { + return; + } + if (!RUNNING.compareAndSet(false, true)) { + return; + } + ProvinceManager live = plugin.getProvinceManager(); + List guilds = new ArrayList<>(FactionManager.getAllGuilds()); + List realms = FactionManager.getCopy(); + try { + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> runDaily(plugin, live, guilds, realms)); + } catch (RuntimeException | LinkageError ex) { + RUNNING.set(false); + } + } + + private static void runDaily( + SimpleFactions plugin, ProvinceManager live, List guilds, List realms) { + try { + long started = System.nanoTime(); + rebuild(live, guilds, realms); + long millis = (System.nanoTime() - started) / 1_000_000L; + plugin.getLogger().info("Hub estimates took " + millis + " ms for " + guilds.size() + " guilds"); + } catch (RuntimeException ex) { + plugin.getLogger().log(Level.WARNING, "Hub estimates failed", ex); + } finally { + try { + Bukkit.getScheduler().runTask(plugin, () -> { + try { + if (plugin.isEnabled() && plugin.getProvinceManager() == live) { + live.recalculate(); + } + } finally { + RUNNING.set(false); + } + }); + } catch (RuntimeException | LinkageError ex) { + RUNNING.set(false); + } + } + } + + static void rebuild(ProvinceManager live, List guilds, List realms) { + List safeGuilds = guilds == null ? List.of() : guilds; + List safeRealms = realms == null ? List.of() : realms; + Map> nextProposals = new HashMap<>(); + int cap = Math.max(1, Cache.supplyHubEstimateCandidates); + for (Guild guild : safeGuilds) { + if (guild == null || guild.getId() == null || !guild.hasCapital() || guild.getLedger() == null) { + continue; + } + if (!SupplyHubService.allowsSupplyHubs(guild)) { + continue; + } + nextProposals.put(guild.getId(), List.copyOf(build(live, guild, safeGuilds, safeRealms, cap))); + } + Map nextHeadlines = headlinesFor(live, safeRealms); + proposals = Map.copyOf(nextProposals); + headlines = Map.copyOf(nextHeadlines); + } + + static List shortlist( + Guild guild, List guilds, List factions, ProvinceManager provinces, int cap) { + int limit = Math.max(1, cap); + List partners = activeHubs(guild, guilds); + Set occupied = new HashSet<>(); + List ready = new ArrayList<>(); + if (factions != null) { + for (Faction host : factions) { + if (host == null || host.getId() == null || host.getInstallationHandler() == null + || host.getInstallationHandler().getAll() == null) { + continue; + } + boolean realm = sameRealm(guild.getFaction(), host); + for (Installation installation : host.getInstallationHandler().getAll()) { + if (installation == null || installation.getId() == null) { + continue; + } + occupied.add(installation.getProvince()); + if (!hubKind(installation.getKind())) { + continue; + } + int slots = hubSlots(installation); + if (slots <= 0) { + continue; + } + if (SupplyHubService.hasHub(guild.getSupplyHubs(), host.getId(), installation.getId())) { + continue; + } + if (SupplyHubService.countAt(host.getId(), installation.getId(), guilds) >= slots) { + continue; + } + if (!realm && !host.hasFactionRule(Rules.HUB_TAX)) { + continue; + } + ready.add(new Candidate( + Group.READY, host, installation, realm, + closeness(guild, installation.getCenterX(), installation.getCenterZ(), partners, provinces))); + } + } + } + List worth = new ArrayList<>(); + if (provinces != null) { + for (Province province : provinces.getProvinces()) { + if (province == null || !province.isValid() || province.isSea() || occupied.contains(province.getId())) { + continue; + } + Faction host = TitleManager.getByProvince(province.getId()); + if (host == null || host.getId() == null) { + continue; + } + boolean realm = sameRealm(guild.getFaction(), host); + if (!realm && !host.hasFactionRule(Rules.HUB_TAX)) { + continue; + } + Installation imagined = new Installation( + "proposed-" + province.getId(), + terrainLabel(province), + InstallationKind.TRAIN_STATION, + province.getId(), + province.getCenterX(), + province.getCenterZ(), + 0L); + worth.add(new Candidate( + Group.WORTH_BUILDING, host, imagined, realm, + closeness(guild, province.getCenterX(), province.getCenterZ(), partners, provinces))); + } + } + List chosen = new ArrayList<>(); + chosen.addAll(closest(ready, limit)); + chosen.addAll(closest(worth, limit)); + return chosen; + } + + /** New links from the guild's active hubs to this installation. Assumed rail is a straight line. */ + static Planned plan(List existing, Installation target, String targetFactionId, ProvinceManager provinces) { + List added = new ArrayList<>(); + boolean assumed = false; + double blocks = 0; + if (existing == null || target == null) { + return new Planned(added, false, 0); + } + String toFaction = targetFactionId == null ? "" : targetFactionId; + for (Installation from : existing) { + if (from == null || from.getProvince() == target.getProvince()) { + continue; + } + String fromFaction = SupplyHubService.owningFactionId(from); + Choice forward = choose(from, fromFaction, target, toFaction, provinces); + Choice back = choose(target, toFaction, from, fromFaction, provinces); + if (forward != null) { + added.add(forward.link()); + if (forward.assumed()) { + assumed = true; + blocks = Math.max(blocks, forward.link().distance()); + } + } + if (back != null) { + added.add(back.link()); + if (back.assumed()) { + assumed = true; + blocks = Math.max(blocks, back.link().distance()); + } + } + } + return new Planned(List.copyOf(added), assumed, blocks); + } + + static Result compare( + ProvinceManager live, Guild operator, Faction host, List links, Map extra) { + ProvinceManager baseline = copy(live); + Map before = EconomicPreview.projectNets(baseline); + double gross = baseline.getGrossTradeIncome(operator); + return diff(live, operator, host, before, gross, links, extra); + } + + private static List build( + ProvinceManager live, Guild guild, List guilds, List factions, int cap) { + List chosen = shortlist(guild, guilds, factions, live, cap); + if (chosen.isEmpty()) { + return List.of(); + } + ProvinceManager baseline = copy(live); + Map before = EconomicPreview.projectNets(baseline); + double gross = baseline.getGrossTradeIncome(guild); + List partners = activeHubs(guild, guilds); + List current = new ArrayList<>(HubNetwork.linksFor(guild)); + List built = new ArrayList<>(); + for (Candidate candidate : chosen) { + Planned planned = plan(partners, candidate.installation(), candidate.host().getId(), live); + List links = new ArrayList<>(current); + links.addAll(planned.added()); + Map extra = null; + if (candidate.group() == Group.WORTH_BUILDING) { + double amount = sourceAmount(candidate.installation().getKind()); + if (amount > 0) { + extra = Map.of(candidate.installation().getProvince(), amount); + } + } + Result result = diff(live, guild, candidate.host(), before, gross, links, extra); + String installationId = candidate.group() == Group.READY ? candidate.installation().getId() : null; + built.add(new Destination( + candidate.group(), + candidate.host().getId(), + installationId, + candidate.installation().getName(), + candidate.installation().getProvince(), + candidate.installation().getKind(), + candidate.ownRealm(), + planned.assumedRailway(), + planned.assumedTrackBlocks(), + result.operatorGain(), + result.hostGain(), + result.hostGuildGains(), + result.taxableIncome())); + } + built.sort(Comparator.comparingInt((Destination destination) -> destination.group() == Group.READY ? 0 : 1) + .thenComparing(Comparator.comparingDouble(Destination::operatorGain).reversed())); + return built; + } + + private static Result diff( + ProvinceManager live, Guild operator, Faction host, + Map before, double baselineGross, List links, Map extra) { + ProvinceManager after = copy(live); + if (operator.getId() != null && links != null) { + after.setHubLinksOverride(Map.of(operator.getId(), List.copyOf(links))); + } + if (extra != null && !extra.isEmpty()) { + after.setExtraInfrastructure(extra); + } + Map next = EconomicPreview.projectNets(after); + double operatorGain = round(next.getOrDefault(operator, 0.0) - before.getOrDefault(operator, 0.0)); + double hostGain = 0; + Map hostGuilds = new HashMap<>(); + Set seen = new HashSet<>(); + seen.addAll(before.keySet()); + seen.addAll(next.keySet()); + for (Guild other : seen) { + if (other == null || sameGuild(other, operator) || !sameRealm(host, other.getFaction())) { + continue; + } + double delta = round(next.getOrDefault(other, 0.0) - before.getOrDefault(other, 0.0)); + hostGain += delta; + if (delta != 0 && other.getId() != null) { + hostGuilds.put(other.getId(), delta); + } + } + double taxable = round(Math.max(0, after.getGrossTradeIncome(operator) - baselineGross)); + return new Result(operatorGain, round(hostGain), Map.copyOf(hostGuilds), taxable); + } + + private static Map headlinesFor(ProvinceManager live, List realms) { + if (realms.isEmpty()) { + return Map.of(); + } + Map before = EconomicPreview.projectNets(copy(live)); + ProvinceManager off = copy(live); + off.setInfrastructureSuppressed(true); + Map after = EconomicPreview.projectNets(off); + Map worth = new HashMap<>(); + for (Faction realm : realms) { + if (realm == null || realm.getId() == null) { + continue; + } + worth.put(realm.getId(), realmDelta(realm, after, before)); + } + return worth; + } + + private static double realmDelta(Faction realm, Map before, Map after) { + double sum = 0; + Set guilds = new HashSet<>(); + guilds.addAll(before.keySet()); + guilds.addAll(after.keySet()); + for (Guild guild : guilds) { + if (guild == null || !sameRealm(realm, guild.getFaction())) { + continue; + } + sum += after.getOrDefault(guild, 0.0) - before.getOrDefault(guild, 0.0); + } + return round(sum); + } + + private record Choice(Link link, boolean assumed) { + } + + private static Choice choose( + Installation from, String fromFactionId, Installation to, String toFactionId, ProvinceManager provinces) { + if (from == null || to == null || from.getProvince() == to.getProvince()) { + return null; + } + Link real = provinces == null ? null : HubNetwork.connect(from, to, provinces); + Link assumed = assumedRail(from, fromFactionId, to, toFactionId); + if (real != null && real.mode() == Mode.RAIL) { + return new Choice(real, false); + } + if (assumed != null && (real == null || assumed.tradeFactor() > real.tradeFactor())) { + return new Choice(assumed, true); + } + return real == null ? null : new Choice(real, false); + } + + private static Link assumedRail(Installation from, String fromFactionId, Installation to, String toFactionId) { + if (from.getProvince() == to.getProvince()) { + return null; + } + double dx = from.getCenterX() - to.getCenterX(); + double dz = from.getCenterZ() - to.getCenterZ(); + double distance = Math.sqrt(dx * dx + dz * dz); + if (!Double.isFinite(distance) || !HubTransport.inRange(HubTransport.rates(Mode.RAIL), distance)) { + return null; + } + return HubTransport.link( + from, fromFactionId == null ? "" : fromFactionId, + to, toFactionId == null ? "" : toFactionId, + Mode.RAIL, distance); + } + + private static List activeHubs(Guild guild, List guilds) { + List sites = new ArrayList<>(); + if (guild == null || guild.getSupplyHubs() == null) { + return sites; + } + for (SupplyHub hub : guild.getSupplyHubs()) { + Installation installation = SupplyHubService.findInstallation(hub.ownerFactionId(), hub.installationId()); + if (installation == null) { + continue; + } + int slots = hubSlots(installation); + boolean allowed = SupplyHubService.hubPermitted(guild, hub.ownerFactionId(), hub.installationId()); + SupplyHubService.HubStanding standing = SupplyHubService.standing( + guild, hub, true, allowed, slots, + SupplyHubService.atInstallation(hub.ownerFactionId(), hub.installationId(), guilds)); + if (standing.active()) { + sites.add(installation); + } + } + return sites; + } + + private static List closest(List sites, int cap) { + sites.sort(Comparator.comparingDouble(Candidate::distance)); + if (sites.size() <= cap) { + return sites; + } + return new ArrayList<>(sites.subList(0, cap)); + } + + private static double closeness( + Guild guild, int x, int z, List hubs, ProvinceManager provinces) { + double best = Double.POSITIVE_INFINITY; + if (hubs != null) { + for (Installation hub : hubs) { + if (hub == null) { + continue; + } + best = Math.min(best, distance(hub.getCenterX(), hub.getCenterZ(), x, z)); + } + } + if (best < Double.POSITIVE_INFINITY) { + return best; + } + if (guild != null && guild.hasCapital() && provinces != null) { + Province capital = provinces.get(guild.getCapital()); + if (capital != null && capital.isValid()) { + return distance(capital.getCenterX(), capital.getCenterZ(), x, z); + } + } + return 0; + } + + private static double distance(double x1, double z1, double x2, double z2) { + double dx = x1 - x2; + double dz = z1 - z2; + return Math.sqrt(dx * dx + dz * dz); + } + + private static boolean hubKind(InstallationKind kind) { + return kind == InstallationKind.PORT + || kind == InstallationKind.AIRPORT + || kind == InstallationKind.TRAIN_STATION; + } + + private static int hubSlots(Installation installation) { + try { + return InstallationConfigLoader.getHubSlots(installation.getKind(), installation.getLevel()); + } catch (IllegalStateException ex) { + return SupplyHubService.defaultHubSlots(installation.getKind(), installation.getLevel()); + } + } + + static double sourceAmount(InstallationKind kind) { + if (kind == null) { + return 0; + } + return switch (kind) { + case TRAIN_STATION -> Cache.infrastructureStation; + case PORT -> Cache.infrastructurePort; + case AIRPORT -> Cache.infrastructureAirport; + default -> 0; + }; + } + + private static double levelOneUpkeep(InstallationKind kind) { + if (kind == null) { + return 0; + } + try { + return InstallationConfigLoader.getDailyUpkeep(kind, 1); + } catch (IllegalStateException ex) { + return switch (kind) { + case TRAIN_STATION -> 5; + case PORT -> 15; + case AIRPORT -> 20; + case FORT -> 30; + }; + } + } + + private static String terrainLabel(Province province) { + String name = province.getTerrain().name().toLowerCase(Locale.ROOT); + return name.substring(0, 1).toUpperCase(Locale.ROOT) + name.substring(1); + } + + private static boolean sameRealm(Faction left, Faction right) { + if (left == null || right == null) { + return false; + } + if (left.getId() != null && left.getId().equalsIgnoreCase(right.getId())) { + return true; + } + return RelationManager.sameRealm(left, right); + } + + private static boolean sameGuild(Guild left, Guild right) { + if (left == right) { + return true; + } + return left != null && right != null && left.getId() != null && left.getId().equalsIgnoreCase(right.getId()); + } + + private static ProvinceManager copy(ProvinceManager live) { + ProvinceManager snapshot = live.createSnapshotShell(); + snapshot.copyAllDataFrom(live); + return snapshot; + } + + private static double round(double value) { + if (!Double.isFinite(value)) { + return 0; + } + return Math.round(value * 100.0) / 100.0; + } +} 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 3692be62..78910f41 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubService.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubService.java @@ -82,6 +82,8 @@ public static void loadConfig(FileConfiguration config) { Cache.supplyHubOfferDays = Math.max(1, offerDays); int agreementDays = config == null ? 14 : config.getInt("supply-hubs.agreement-days", 14); Cache.supplyHubAgreementDays = Math.max(1, agreementDays); + int candidates = config == null ? 24 : config.getInt("supply-hubs.estimate-candidates", 24); + Cache.supplyHubEstimateCandidates = Math.max(1, candidates); } public static int limit(Guild guild) { diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/EconomicPreview.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/EconomicPreview.java index d4dac71e..6d6d9d3e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/EconomicPreview.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/EconomicPreview.java @@ -93,6 +93,11 @@ public static Map tax(Faction faction, TaxTarget target, String i } } + /** Net income of every guild after recalculating this snapshot. Writes the live trade breakdowns. */ + public static Map projectNets(ProvinceManager snapshot) { + return project(snapshot, IncomePreviewContext.scratch(), false); + } + private static Map project( ProvinceManager snapshot, IncomePreviewContext context, boolean capitalsOnly) { IncomePreviewContext.open(context); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java index 06ea68cc..02d096e0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java @@ -512,6 +512,8 @@ public void time() { runDailyStep("hub agreements", () -> net.tfminecraft.simplefactions.guild.hub.HubAgreementService.tick( getAllGuilds(), System.currentTimeMillis())); + runDailyStep("hub estimates", () -> + net.tfminecraft.simplefactions.guild.hub.HubEstimates.scheduleDaily()); timer = 0; day++; } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java index c0e4b6c4..9db8065b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java @@ -38,6 +38,8 @@ public class ProvinceManager { private Map provinces = new HashMap<>(); private Map> hubLinksOverride; + private boolean infrastructureSuppressed; + private Map extraInfrastructure; private long stateVersion = 0; private long lastCalculatedVersion = -1; @@ -84,6 +86,19 @@ public void setHubLinksOverride(Map> links) { hubLinksOverride = links; } + /** Snapshot-only. The next recalculation writes 0 infrastructure on every province. */ + public void setInfrastructureSuppressed(boolean suppressed) { + infrastructureSuppressed = suppressed; + } + + /** + * Snapshot-only source added before the spread. Sea and water provinces are ignored. + * A null or empty map adds nothing. Copies do not inherit this. + */ + public void setExtraInfrastructure(Map extra) { + extraInfrastructure = extra == null || extra.isEmpty() ? null : Map.copyOf(extra); + } + private List hubLinksFor(Guild guild) { if (hubLinksOverride != null && hubLinksOverride.containsKey(guild.getId())) { return hubLinksOverride.get(guild.getId()); @@ -144,6 +159,12 @@ public void recalculateForSingleGuild(Guild g, boolean save) { } private void recalculateInfrastructure() { + if (infrastructureSuppressed) { + for (Province province : provinces.values()) { + province.setInfrastructure(0); + } + return; + } Map graph = new HashMap<>(); for (Province province : provinces.values()) { Faction realm = InfrastructureAccess.topRealm(province.getOwner()); @@ -154,6 +175,15 @@ private void recalculateInfrastructure() { Map sources = InfrastructureSources.collect( provinces, FactionManager.getAllGuilds(), FactionManager.getCopy(), net.tfminecraft.simplefactions.map.infra.TrackProvinceCache.live().provinces()); + if (extraInfrastructure != null) { + for (Map.Entry extra : extraInfrastructure.entrySet()) { + Province province = provinces.get(extra.getKey()); + if (province == null || province.isSea() || extra.getValue() == null || extra.getValue() <= 0) { + continue; + } + sources.merge(extra.getKey(), extra.getValue(), Double::sum); + } + } Map infrastructure = InfrastructureSpread.spread( graph, sources, Cache.infrastructureWildernessSpread, Cache.infrastructureSpreadFloor); for (Province province : provinces.values()) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index baa596d9..3c6b67fa 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -92,6 +92,9 @@ supply-hubs: offer-days: 7 # Days a hub agreement runs. agreement-days: 14 + # How many ready installations, and how many provinces worth building, are fully + # estimated per guild each day. The closest ones are kept. + estimate-candidates: 24 # 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 track joins any two kinds; ports # also connect across the sea, and airports through the air. diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubEstimatesTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubEstimatesTest.java new file mode 100644 index 00000000..872b0824 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubEstimatesTest.java @@ -0,0 +1,401 @@ +package net.tfminecraft.simplefactions.guild.hub; + +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 static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalDouble; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.enums.GuildModifier; +import net.tfminecraft.simplefactions.enums.Rules; +import net.tfminecraft.simplefactions.enums.Terrain; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.GuildModifierOverride; +import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Candidate; +import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Destination; +import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Group; +import net.tfminecraft.simplefactions.guild.hub.HubEstimates.InstallationPreview; +import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Planned; +import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Result; +import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Terms; +import net.tfminecraft.simplefactions.guild.hub.HubTransport.Link; +import net.tfminecraft.simplefactions.guild.hub.HubTransport.Mode; +import net.tfminecraft.simplefactions.guild.income.IncomePreviewContext; +import net.tfminecraft.simplefactions.guild.income.Ledger; +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.loaders.InstallationConfigLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.ProvinceManager; +import net.tfminecraft.simplefactions.managers.RelationManager; +import net.tfminecraft.simplefactions.managers.TitleManager; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.Faction; + +class HubEstimatesTest { + @AfterEach + void reset() { + IncomePreviewContext.clear(); + GuildModifierOverride.clear(); + HubNetwork.setLinksForTests(null); + HubNetwork.setRailRoutesForTests(null); + HubEstimates.clearForTests(); + } + + @Test + void suppressedInfrastructureClearsAndExtraSourceSkipsSea() { + boolean enabled = Cache.provincesEnabled; + Cache.provincesEnabled = true; + Province land = new Province(1, "bog", 0); + Province sea = new Province(2, "sea", 0); + ProvinceManager manager = new ProvinceManager(); + manager.start(Map.of(1, land, 2, sea)); + try (MockedStatic factions = mockStatic(FactionManager.class)) { + factions.when(FactionManager::getAllGuilds).thenReturn(List.of()); + factions.when(FactionManager::getCopy).thenReturn(List.of()); + manager.setExtraInfrastructure(Map.of(1, 20.0, 2, 20.0)); + manager.recalculate(); + assertEquals(20, manager.get(1).getInfrastructure(), 1e-9); + assertEquals(0, manager.get(2).getInfrastructure(), 1e-9); + + manager.setInfrastructureSuppressed(true); + manager.recalculate(); + assertEquals(0, manager.get(1).getInfrastructure(), 1e-9); + assertEquals(0, manager.get(2).getInfrastructure(), 1e-9); + } finally { + Cache.provincesEnabled = enabled; + } + } + + @Test + void missingRailwayUsesAStraightLineAndTermsAreArithmetic() { + Installation from = new Installation("a", "Home", InstallationKind.TRAIN_STATION, 1, 0, 0, 0L); + Installation to = new Installation("b", "Away", InstallationKind.TRAIN_STATION, 2, 3000, 0, 0L); + HubNetwork.setRailRoutesForTests((left, right) -> OptionalDouble.empty()); + Planned planned = HubEstimates.plan(List.of(from), to, "host", new ProvinceManager()); + + assertTrue(planned.assumedRailway()); + assertEquals(3000, planned.assumedTrackBlocks(), 1e-6); + assertEquals(2, planned.added().size()); + assertEquals(Mode.RAIL, planned.added().get(0).mode()); + assertEquals(3000, planned.added().get(0).distance(), 1e-6); + + Destination foreign = destination(false, 80, -20, 100); + Terms terms = HubEstimates.applyTerms(foreign, 10, 500); + assertEquals(65, terms.operatorNet(), 1e-9); + assertEquals(-5, terms.hostNet(), 1e-9); + assertEquals(10, terms.tax(), 1e-9); + assertEquals(5, terms.fee(), 1e-9); + + Terms clamped = HubEstimates.applyTerms(foreign, 150, -20); + assertEquals(0, clamped.fee(), 1e-9); + assertEquals(100, clamped.tax(), 1e-9); + + Terms own = HubEstimates.applyTerms(destination(true, 80, 4, 100), 10, 500); + assertEquals(80, own.operatorNet(), 1e-9); + assertEquals(4, own.hostNet(), 1e-9); + assertEquals(0, own.tax(), 1e-9); + assertEquals(0, own.fee(), 1e-9); + } + + @Test + void aHubLinkRaisesTheOperatorsNetAndThatRiseIsTaxable() { + Road road = road(24); + Link link = new Link(1, 22, Mode.RAIL, 0, 0.4, 0.8); + try (MockedStatic factions = mockStatic(FactionManager.class); + MockedStatic titles = mockStatic(TitleManager.class)) { + factions.when(FactionManager::getAllGuilds).thenReturn(List.of(road.guild)); + factions.when(FactionManager::getCopy).thenReturn(List.of()); + titles.when(() -> TitleManager.getByProvince(anyInt())).thenReturn(null); + Result result = HubEstimates.compare(road.provinces, road.guild, road.faction, List.of(link), null); + assertTrue(result.operatorGain() > 0, "operator gain " + result.operatorGain()); + assertTrue(result.taxableIncome() > 0, "taxable " + result.taxableIncome()); + assertEquals(0, result.hostGain(), 1e-9); + } finally { + road.restore(); + } + } + + @Test + void shortlistKeepsFreeHubsAndTheClosestProvinces() { + Faction home = faction("home", true); + Faction foreign = faction("foreign", false); + Faction taxed = faction("taxed", true); + Guild guild = guild(home, 1, List.of(new SupplyHub("home", "mine", 1L))); + Installation near = station("near", 1, 0, 0); + Installation mine = station("mine", 12, 50, 0); + Installation taken = station("taken", 3, 80, 0); + Installation far = station("far", 4, 10_000, 0); + Installation abroad = station("abroad", 5, 90, 0); + Installation fort = new Installation("fort", "Fort", InstallationKind.FORT, 2, 10, 0, 0L); + InstallationHandler homeSites = handler(near, mine, taken, fort, far); + InstallationHandler foreignSites = handler(abroad); + InstallationHandler taxedSites = handler(station("taxed", 11, 8_000, 0)); + when(home.getInstallationHandler()).thenReturn(homeSites); + when(foreign.getInstallationHandler()).thenReturn(foreignSites); + when(taxed.getInstallationHandler()).thenReturn(taxedSites); + + int slots = stationSlots(); + List guilds = new ArrayList<>(); + guilds.add(guild); + for (int i = 0; i < slots; i++) { + Guild occupant = mock(Guild.class); + when(occupant.getSupplyHubs()).thenReturn(List.of(new SupplyHub("home", "taken", i))); + guilds.add(occupant); + } + + ProvinceManager provinces = new ProvinceManager(); + Map map = new HashMap<>(); + map.put(1, new Province(1, Terrain.PLAINS.name(), 50, 0, 0)); + map.put(6, new Province(6, Terrain.PLAINS.name(), 50, 100, 0)); + map.put(7, new Province(7, Terrain.PLAINS.name(), 50, 5_000, 0)); + map.put(8, new Province(8, Terrain.SEA.name(), 50, 30, 0)); + map.put(9, new Province(9, Terrain.PLAINS.name(), 50, 40, 0)); + map.put(10, new Province(10, Terrain.PLAINS.name(), 50, 60, 0)); + map.put(11, new Province(11, Terrain.PLAINS.name(), 50, 8_000, 0)); + map.put(13, new Province(13, Terrain.PLAINS.name(), 50, 9_000, 0)); + provinces.start(map); + + try (MockedStatic titles = mockStatic(TitleManager.class); + MockedStatic relations = mockStatic(RelationManager.class)) { + titles.when(() -> TitleManager.getByProvince(anyInt())).thenReturn(null); + titles.when(() -> TitleManager.getByProvince(6)).thenReturn(home); + titles.when(() -> TitleManager.getByProvince(7)).thenReturn(home); + titles.when(() -> TitleManager.getByProvince(8)).thenReturn(home); + titles.when(() -> TitleManager.getByProvince(10)).thenReturn(foreign); + titles.when(() -> TitleManager.getByProvince(11)).thenReturn(taxed); + titles.when(() -> TitleManager.getByProvince(13)).thenReturn(taxed); + relations.when(() -> RelationManager.sameRealm(any(), any())).thenAnswer(invocation -> { + Faction left = invocation.getArgument(0); + Faction right = invocation.getArgument(1); + return left != null && right != null && left.getId().equalsIgnoreCase(right.getId()); + }); + + List listed = HubEstimates.shortlist( + guild, guilds, List.of(home, foreign, taxed), provinces, 10); + assertTrue(listed.stream().anyMatch(candidate -> "near".equals(candidate.installation().getId()))); + assertTrue(listed.stream().anyMatch(candidate -> "far".equals(candidate.installation().getId()))); + assertTrue(listed.stream().anyMatch(candidate -> candidate.installation().getProvince() == 6 + && candidate.group() == Group.WORTH_BUILDING)); + assertTrue(listed.stream().anyMatch(candidate -> "taxed".equals(candidate.installation().getId()) + && candidate.group() == Group.READY)); + assertTrue(listed.stream().anyMatch(candidate -> candidate.installation().getProvince() == 13 + && candidate.group() == Group.WORTH_BUILDING)); + assertFalse(listed.stream().anyMatch(candidate -> "mine".equals(candidate.installation().getId()) + && candidate.group() == Group.READY)); + assertFalse(listed.stream().anyMatch(candidate -> "taken".equals(candidate.installation().getId()))); + assertFalse(listed.stream().anyMatch(candidate -> "abroad".equals(candidate.installation().getId()))); + assertFalse(listed.stream().anyMatch(candidate -> "fort".equals(candidate.installation().getId()))); + assertFalse(listed.stream().anyMatch(candidate -> { + int province = candidate.installation().getProvince(); + return province == 8 || province == 9 || province == 10; + })); + + List capped = HubEstimates.shortlist( + guild, guilds, List.of(home, foreign, taxed), provinces, 1); + assertEquals(1, capped.stream().filter(candidate -> candidate.group() == Group.READY).count()); + assertEquals("near", capped.stream().filter(candidate -> candidate.group() == Group.READY).findFirst().orElseThrow().installation().getId()); + assertEquals(6, capped.stream().filter(candidate -> candidate.group() == Group.WORTH_BUILDING).findFirst().orElseThrow().installation().getProvince()); + } + } + + @Test + void aFewHundredProvincesFinishWithinTenSeconds() { + Road road = road(300); + Link link = new Link(1, 200, Mode.RAIL, 0, 0.4, 0.8); + try (MockedStatic factions = mockStatic(FactionManager.class); + MockedStatic titles = mockStatic(TitleManager.class)) { + factions.when(FactionManager::getAllGuilds).thenReturn(List.of(road.guild)); + factions.when(FactionManager::getCopy).thenReturn(List.of()); + titles.when(() -> TitleManager.getByProvince(anyInt())).thenReturn(null); + long started = System.nanoTime(); + HubEstimates.compare(road.provinces, road.guild, road.faction, List.of(link), null); + long millis = (System.nanoTime() - started) / 1_000_000L; + assertTrue(millis < 10_000, "synthetic estimate took " + millis + " ms"); + } finally { + road.restore(); + } + } + + @Test + void infrastructureWorthAndANewStationArePositive() { + // A capital alone ignores terrain. The neighbour is what infrastructure changes. + boolean enabled = Cache.provincesEnabled; + Map carry = new HashMap<>(Cache.tradeCarry); + Cache.provincesEnabled = true; + Cache.tradeCarry.clear(); + Cache.tradeCarry.put(Terrain.BOG, 0.4); + Province capital = new Province(1, Terrain.BOG.name(), 50, 0, 0); + Province neighbour = new Province(2, Terrain.BOG.name(), 50, 100, 0); + capital.addNeighbour(2); + neighbour.addNeighbour(1); + ProvinceManager provinces = new ProvinceManager(); + provinces.start(Map.of(1, capital, 2, neighbour)); + Faction home = faction("home", true); + Guild operator = guild(home, 1, List.of()); + InstallationHandler sites = handler(station("station", 1, 0, 0)); + when(home.getInstallationHandler()).thenReturn(sites); + double previousStation = Cache.infrastructureStation; + double previousFull = Cache.infrastructureFull; + double previousTarget = Cache.infrastructureTarget; + Cache.infrastructureStation = 10; + Cache.infrastructureFull = 20; + Cache.infrastructureTarget = 0.75; + try (MockedStatic factions = mockStatic(FactionManager.class); + MockedStatic titles = mockStatic(TitleManager.class)) { + factions.when(FactionManager::getAllGuilds).thenReturn(List.of(operator)); + factions.when(FactionManager::getCopy).thenReturn(List.of(home)); + factions.when(() -> FactionManager.getByString("home")).thenReturn(home); + titles.when(() -> TitleManager.getByProvince(anyInt())).thenReturn(home); + when(home.hasFactionRule(Rules.SUPPLY_HUBS)).thenReturn(true); + HubEstimates.rebuild(provinces, List.of(operator), List.of(home)); + assertTrue(HubEstimates.infrastructureWorth(home) > 0, + "worth " + HubEstimates.infrastructureWorth(home)); + + InstallationPreview preview = HubEstimates.previewInstallation( + provinces, home, 2, InstallationKind.TRAIN_STATION); + assertEquals(10, preview.infrastructureHere(), 1e-9); + assertTrue(preview.realmPerDay() > 0, "realm " + preview.realmPerDay()); + assertTrue(preview.upkeep() > 0); + assertEquals(InstallationPreview.NONE, HubEstimates.previewInstallation( + provinces, home, 2, null)); + } finally { + Cache.infrastructureStation = previousStation; + Cache.infrastructureFull = previousFull; + Cache.infrastructureTarget = previousTarget; + Cache.provincesEnabled = enabled; + Cache.tradeCarry.clear(); + Cache.tradeCarry.putAll(carry); + } + } + + @Test + void scheduleDoesNothingWithoutAPluginAndCandidatesDefaultToTwentyFour() { + assertNull(SimpleFactions.getInstance()); + HubEstimates.scheduleDaily(); + int previous = Cache.supplyHubEstimateCandidates; + double previousTax = Cache.supplyHubMaxTax; + double previousFee = Cache.supplyHubMaxFee; + int previousOffer = Cache.supplyHubOfferDays; + int previousAgreement = Cache.supplyHubAgreementDays; + try { + YamlConfiguration config = new YamlConfiguration(); + config.set("supply-hubs.estimate-candidates", 0); + SupplyHubService.loadConfig(config); + assertEquals(1, Cache.supplyHubEstimateCandidates); + SupplyHubService.loadConfig(null); + assertEquals(24, Cache.supplyHubEstimateCandidates); + } finally { + Cache.supplyHubEstimateCandidates = previous; + Cache.supplyHubMaxTax = previousTax; + Cache.supplyHubMaxFee = previousFee; + Cache.supplyHubOfferDays = previousOffer; + Cache.supplyHubAgreementDays = previousAgreement; + } + } + + private static Destination destination(boolean ownRealm, double operator, double host, double taxable) { + return new Destination( + Group.READY, "host", "station", "Station", 2, InstallationKind.TRAIN_STATION, + ownRealm, true, 3000, operator, host, Map.of(), taxable); + } + + private static int stationSlots() { + try { + return InstallationConfigLoader.getHubSlots(InstallationKind.TRAIN_STATION, 1); + } catch (IllegalStateException ex) { + return SupplyHubService.defaultHubSlots(InstallationKind.TRAIN_STATION, 1); + } + } + + private static Installation station(String id, int province, int x, int z) { + return new Installation(id, id, InstallationKind.TRAIN_STATION, province, x, z, 0L); + } + + private static InstallationHandler handler(Installation... installations) { + InstallationHandler handler = mock(InstallationHandler.class); + when(handler.getAll()).thenReturn(List.of(installations)); + return handler; + } + + private static Faction faction(String id, boolean hubTax) { + Faction faction = mock(Faction.class); + when(faction.getId()).thenReturn(id); + lenient().when(faction.hasFactionRule(Rules.HUB_TAX)).thenReturn(hubTax); + lenient().when(faction.hasFactionRule(Rules.SUPPLY_HUBS)).thenReturn(true); + lenient().when(faction.getModifiers(any(), any(), any())).thenReturn(List.of()); + return faction; + } + + private static Guild guild(Faction faction, int capital, List hubs) { + Guild guild = mock(Guild.class); + when(guild.getId()).thenReturn("guild"); + when(guild.getFaction()).thenReturn(faction); + when(guild.hasCapital()).thenReturn(true); + when(guild.getCapital()).thenReturn(capital); + when(guild.getSupplyHubs()).thenReturn(hubs); + when(guild.isBase()).thenReturn(false); + when(guild.isBankrupt()).thenReturn(false); + when(guild.getBank()).thenReturn(mock(Bank.class)); + when(guild.getTradeBreakdown()).thenReturn(new TradeBreakdown()); + when(guild.getLedger()).thenReturn(new Ledger(guild)); + lenient().when(guild.getModifier(any())).thenReturn(0.0); + when(guild.getModifier(GuildModifier.TRADE_POWER)).thenReturn(20.0); + when(guild.getModifier(GuildModifier.TRADE_CARRY)).thenReturn(1.0); + when(guild.getModifier(GuildModifier.PRODUCTION)).thenReturn(10.0); + return guild; + } + + private static Road road(int count) { + boolean enabled = Cache.provincesEnabled; + Map carry = new HashMap<>(Cache.tradeCarry); + Cache.provincesEnabled = true; + Cache.tradeCarry.clear(); + Cache.tradeCarry.put(Terrain.PLAINS, 0.85); + Cache.tradeCarry.put(Terrain.BOG, 0.4); + ProvinceManager provinces = new ProvinceManager(); + Map map = new HashMap<>(); + for (int id = 1; id <= count; id++) { + String terrain = count == 1 ? Terrain.BOG.name() : Terrain.PLAINS.name(); + map.put(id, new Province(id, terrain, 50, id * 100, 0)); + if (id > 1) { + map.get(id).addNeighbour(id - 1); + map.get(id - 1).addNeighbour(id); + } + } + provinces.start(map); + Faction faction = faction("home", true); + Guild guild = guild(faction, 1, List.of()); + return new Road(provinces, faction, guild, enabled, carry); + } + + private record Road( + ProvinceManager provinces, Faction faction, Guild guild, boolean enabled, Map carry) { + void restore() { + Cache.provincesEnabled = enabled; + Cache.tradeCarry.clear(); + Cache.tradeCarry.putAll(carry); + } + } +}