From b8cee2584e9d1e4b843a55fafc9d655392e83948 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:37:22 +0000 Subject: [PATCH 1/3] Let factions tax the income guilds make through hubs they host. A faction now has a Hub Tax rate, set the way the tariff rate is and capped by supply-hubs.max-tax (50%). It applies to hubs at the faction's installations that belong to guilds outside its realm. Once a day, before income is settled, each guild's trade income is worked out with and without each of its hubs. What a hub adds is its taxable income; if the hubs' amounts add up to more than the guild's total gain from hubs, they are scaled down to it. The guild pays the host's rate on that through the ledger, beside tariffs. /guild hub list shows each hub's taxable income and tax. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../net/tfminecraft/simplefactions/Cache.java | 1 + .../simplefactions/database/Database.java | 2 + .../simplefactions/database/FactionData.java | 3 + .../government/proposal/TaxTarget.java | 1 + .../simplefactions/guild/Guild.java | 10 + .../guild/hub/HubTaxBreakdown.java | 74 +++++++ .../guild/hub/HubTaxService.java | 115 +++++++++++ .../guild/hub/SupplyHubCommands.java | 3 + .../guild/hub/SupplyHubService.java | 2 + .../simplefactions/guild/income/Cashflow.java | 2 + .../guild/income/IncomePreviewContext.java | 10 +- .../simplefactions/guild/income/Ledger.java | 56 +++++ .../guild/income/LedgerHistory.java | 2 + .../managers/FactionManager.java | 2 + .../managers/ProvinceManager.java | 31 ++- .../managers/inventory/GovernmentView.java | 4 +- .../managers/inventory/GuildCreator.java | 39 +++- .../managers/inventory/GuildView.java | 2 + .../managers/inventory/TaxView.java | 4 +- .../objects/handler/TaxHandler.java | 46 ++++- .../objects/handler/TaxSnapshot.java | 3 + src/main/resources/config.yml | 2 + .../guild/hub/HubTaxServiceTest.java | 193 ++++++++++++++++++ .../guild/income/LedgerHubTaxTest.java | 162 +++++++++++++++ .../objects/handler/TaxHandlerHubTaxTest.java | 104 ++++++++++ 25 files changed, 861 insertions(+), 12 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxBreakdown.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxService.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/guild/hub/HubTaxServiceTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHubTaxTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxHandlerHubTaxTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/Cache.java b/src/main/java/net/tfminecraft/simplefactions/Cache.java index cb9d4d49..5abd26a7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/Cache.java +++ b/src/main/java/net/tfminecraft/simplefactions/Cache.java @@ -168,6 +168,7 @@ public static boolean requireProvinces(org.bukkit.command.CommandSender sender) 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 879276c6..76663929 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/Database.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/Database.java @@ -166,6 +166,7 @@ public void loadFactions() { data.laws, data.governmentData ); + f.getTaxHandler().setHubTax(data.hubTax == null ? 0.0 : data.hubTax); f.getVehicleFeeHandler().load(data.vehicleFees, data.vehicleTypeFees); if (data.lawChangedAt != null) { for (Map.Entry entry : data.lawChangedAt.entrySet()) { @@ -329,6 +330,7 @@ public void saveFaction(Faction f) { data.vassalTax = f.getTaxHandler().getVassalTax(); data.dividendTax = f.getTaxHandler().getDividendTax(); data.tariffs = f.getTaxHandler().getTariffs(); + data.hubTax = f.getTaxHandler().getHubTax(); data.specificTaxes = serializeSpecificTaxes(f.getTaxHandler()); data.vehicleFees = new HashMap<>(f.getVehicleFeeHandler().serializeRates()); data.vehicleTypeFees = new HashMap<>(); diff --git a/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java b/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java index c67e6a7b..827ec68d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java @@ -34,6 +34,9 @@ public class FactionData { public Double tariffs; + @SerializedName("hub tax") + public Double hubTax; + @SerializedName("specific taxes") public HashMap> specificTaxes = new HashMap<>(); diff --git a/src/main/java/net/tfminecraft/simplefactions/government/proposal/TaxTarget.java b/src/main/java/net/tfminecraft/simplefactions/government/proposal/TaxTarget.java index 501588f1..0d8e35a4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/proposal/TaxTarget.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/proposal/TaxTarget.java @@ -6,6 +6,7 @@ public enum TaxTarget { VASSALS("Vassals"), DIVIDENDS("Dividends"), TARIFFS("Tariffs"), + HUB_TAX("Hub Tax"), TARIFF_ID("Faction Specific Tariffs"), GUILD_ID("Guild Specific"), VASSAL_ID("Vassal Specific"); diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index 3cd7644b..c1cce2f4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -25,6 +25,7 @@ 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.guild.hub.HubTaxBreakdown; import net.tfminecraft.simplefactions.guild.loans.LoanHandler; import net.tfminecraft.simplefactions.guild.upgrade.Upgrade; import net.tfminecraft.simplefactions.guild.upgrade.UpgradeExpansion; @@ -91,6 +92,7 @@ public class Guild { private List wealthModifiers = new ArrayList<>(); private TradeBreakdown breakdown = new TradeBreakdown(); + private volatile HubTaxBreakdown hubTaxBreakdown = HubTaxBreakdown.empty(); private Stance stance; @@ -703,6 +705,14 @@ public TradeBreakdown getTradeBreakdown() { } public void setTradeBreakdown(TradeBreakdown breakdown) { this.breakdown = breakdown; } + public HubTaxBreakdown getHubTaxBreakdown() { + return hubTaxBreakdown; + } + + public void setHubTaxBreakdown(HubTaxBreakdown hubTaxBreakdown) { + this.hubTaxBreakdown = hubTaxBreakdown; + } + public double getRepressFavourCost() { return Formatter.formatDouble(getStabilityEffect()*0.5); } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxBreakdown.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxBreakdown.java new file mode 100644 index 00000000..c9ac4065 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxBreakdown.java @@ -0,0 +1,74 @@ +package net.tfminecraft.simplefactions.guild.hub; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.income.IncomePreviewContext; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.utils.Formatter; + +/** Immutable daily assessment. Ordinary trade recalculations do not replace it. */ +public final class HubTaxBreakdown { + public record Assessment(double taxableIncome, double tax) { + } + + private static final HubTaxBreakdown EMPTY = new HubTaxBreakdown(Map.of(), Map.of(), Map.of(), Set.of()); + + private final Set exemptHosts; + private final Map hubs; + private final Map taxableByFaction; + private final Map taxesByFaction; + + public HubTaxBreakdown( + Map hubs, + Map taxableByFaction, + Map taxesByFaction, + Set exemptHosts) { + this.exemptHosts = Set.copyOf(exemptHosts); + this.hubs = Map.copyOf(hubs); + this.taxableByFaction = Map.copyOf(taxableByFaction); + this.taxesByFaction = Map.copyOf(taxesByFaction); + } + + public static HubTaxBreakdown empty() { + return EMPTY; + } + + public Assessment forHub(SupplyHub hub) { + return hubs.getOrDefault(hub, new Assessment(0, 0)); + } + + public double getTaxableIncome(Faction host) { + return taxableByFaction.getOrDefault(host, 0.0); + } + + public Map getTaxesByFaction() { + IncomePreviewContext context = IncomePreviewContext.current(); + if (context == null) { + return taxesByFaction; + } + Map preview = new HashMap<>(taxesByFaction); + for (Map.Entry entry : taxableByFaction.entrySet()) { + Faction host = entry.getKey(); + if (!exemptHosts.contains(host) && context.affectsHubTax(host)) { + preview.put(host, Formatter.formatDouble( + entry.getValue() * host.getTaxRate(TaxTarget.HUB_TAX, null, true) / 100.0)); + } + } + return preview; + } + + public double getTax(Faction host) { + return getTaxesByFaction().getOrDefault(host, 0.0); + } + + public double getTotalTax() { + double total = 0; + for (double tax : getTaxesByFaction().values()) { + total += tax; + } + return Formatter.formatDouble(total); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxService.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxService.java new file mode 100644 index 00000000..ab686a42 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxService.java @@ -0,0 +1,115 @@ +package net.tfminecraft.simplefactions.guild.hub; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.hub.HubTaxBreakdown.Assessment; +import net.tfminecraft.simplefactions.guild.hub.HubTransport.Link; +import net.tfminecraft.simplefactions.installation.Installation; +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.objects.Faction; +import net.tfminecraft.simplefactions.utils.Formatter; + +/** Computes hub tax once per day on copies of the province data. Server thread only. */ +public final class HubTaxService { + private HubTaxService() { + } + + public static void refresh(ProvinceManager provinces) { + List guilds = SupplyHubService.allGuilds(); + for (Guild guild : guilds) { + guild.setHubTaxBreakdown(assess(provinces, guild, guilds)); + } + } + + public static HubTaxBreakdown assess(ProvinceManager provinces, Guild guild, List guilds) { + List links = HubNetwork.linksFor(guild); + if (links.isEmpty() || !guild.hasCapital()) { + return HubTaxBreakdown.empty(); + } + double base = incomeWith(provinces, guild, List.of()); + double full = incomeWith(provinces, guild, links); + Map without = new HashMap<>(); + Map hosts = new HashMap<>(); + for (SupplyHub hub : guild.getSupplyHubs()) { + Installation installation = SupplyHubService.findInstallation( + hub.ownerFactionId(), hub.installationId()); + Faction host = FactionManager.getByString(hub.ownerFactionId()); + if (installation == null || host == null) { + continue; + } + boolean allowed = SupplyHubService.ownerAllows( + guild.getFaction().getId(), host.getId(), host.hasHubPermit(guild.getId())); + if (!SupplyHubService.standing( + hub, true, allowed, + InstallationConfigLoader.getHubSlots(installation.getKind(), installation.getLevel()), + SupplyHubService.atInstallation(host.getId(), installation.getId(), guilds)).active()) { + continue; + } + List remaining = links.stream() + .filter(link -> !touches(link, installation)) + .toList(); + without.put(hub, incomeWith(provinces, guild, remaining)); + hosts.put(hub, host); + } + Map taxable = taxableIncome(base, full, without); + Map assessments = new HashMap<>(); + Set exemptHosts = new HashSet<>(); + Map taxableByFaction = new HashMap<>(); + Map taxesByFaction = new HashMap<>(); + for (Map.Entry entry : taxable.entrySet()) { + Faction host = hosts.get(entry.getKey()); + double income = entry.getValue(); + double tax = 0; + taxableByFaction.merge(host, income, Double::sum); + if (RelationManager.sameRealm(host, guild.getFaction())) { + exemptHosts.add(host); + } else { + tax = income * host.getTaxRate(TaxTarget.HUB_TAX, null, true) / 100.0; + taxesByFaction.merge(host, tax, Double::sum); + } + assessments.put(entry.getKey(), new Assessment(income, tax)); + } + taxesByFaction.replaceAll((host, tax) -> Formatter.formatDouble(tax)); + return new HubTaxBreakdown(assessments, taxableByFaction, taxesByFaction, exemptHosts); + } + + /** Positive marginal gains, scaled only when their sum exceeds the total hub gain. */ + public static Map taxableIncome(double base, double full, Map without) { + double gain = Math.max(0, full - base); + Map marginal = new HashMap<>(); + double total = 0; + for (Map.Entry entry : without.entrySet()) { + double amount = Math.max(0, full - entry.getValue()); + marginal.put(entry.getKey(), amount); + total += amount; + } + if (total > gain) { + double scale = gain / total; + marginal.replaceAll((hub, amount) -> amount * scale); + } + return marginal; + } + + static boolean touches(Link link, Installation installation) { + return link.mode().getKind() == installation.getKind() + && (link.fromProvince() == installation.getProvince() + || link.toProvince() == installation.getProvince()); + } + + private static double incomeWith(ProvinceManager source, Guild guild, List links) { + ProvinceManager snapshot = source.createSnapshotShell(); + snapshot.copyAllDataFrom(source); + snapshot.setHubLinksOverride(Map.of(guild.getId(), links)); + snapshot.recalculateForSingleGuild(guild, false); + return snapshot.getGrossTradeIncome(guild); + } +} 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 f8fc1720..02baf58f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubCommands.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubCommands.java @@ -251,6 +251,9 @@ private static boolean list(Player player) { hub, guild.getSupplyHubs(), SupplyHubService.baseUpkeep(), SupplyHubService.upkeepGrowth()); 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)) { player.sendMessage(line); } 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 cec1cbb3..ef08e87e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubService.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubService.java @@ -90,6 +90,8 @@ public static void loadConfig(FileConfiguration config) { } } } + 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; diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/Cashflow.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/Cashflow.java index 3361197b..4795e447 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Cashflow.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Cashflow.java @@ -12,6 +12,8 @@ public enum Cashflow { CITIZENS("#c7bf85From tax on #94b572Citizens", false, true), TARIFF_PAYMENTS("#5cc46aTariffs", false, false), TARIFFS("#5cc46aTariffs", false, true), + HUB_TAX_PAYMENTS("#5cc46aHub Tax", false, false), + HUB_TAX("#5cc46aHub Tax", false, true), LOAN_PAYMENTS("#e6bb57Loans", false, false), LOANS("#e6bb57Loans", false, false), INTEREST_PAYMENTS("#c25140Interest Payments", false, false), diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewContext.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewContext.java index f3b0f332..eb643878 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewContext.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewContext.java @@ -222,6 +222,10 @@ public boolean affects(Faction owner) { return owner != null && faction == owner && (law != null || taxRate != null); } + public boolean affectsHubTax(Faction owner) { + return owner != null && faction == owner && (law != null || taxTarget == TaxTarget.HUB_TAX); + } + private boolean replaces(TaxHandler handler, TaxTarget query, String id) { if (taxTarget == null || query == null) { return false; @@ -253,7 +257,9 @@ private double clampForLaw(TaxTarget target, double rate) { } if (effect.hasBrackets()) { for (Map.Entry entry : effect.getBrackets().entrySet()) { - if (BracketToTaxTarget.convert(entry.getKey()) == family && entry.getValue() != null) { + if ((BracketToTaxTarget.convert(entry.getKey()) == family + || (family == TaxTarget.HUB_TAX && entry.getKey() == Brackets.TARIFFS)) + && entry.getValue() != null) { rate = clamp(rate, entry.getValue()); } } @@ -299,7 +305,7 @@ private static Rules ruleFor(TaxTarget target) { case GUILDS, GUILD_ID -> Rules.GUILD_TAX; case VASSALS, VASSAL_ID -> Rules.VASSAL_TAX; case DIVIDENDS -> Rules.DIVIDEND_TAX; - case TARIFFS, TARIFF_ID -> Rules.TARIFFS; + case TARIFFS, TARIFF_ID, HUB_TAX -> Rules.TARIFFS; default -> null; }; } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java index edaad296..439908bd 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java @@ -15,6 +15,7 @@ import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.guild.hub.SupplyHub; +import net.tfminecraft.simplefactions.guild.hub.HubTaxBreakdown; import net.tfminecraft.simplefactions.guild.hub.SupplyHubCommands; import net.tfminecraft.simplefactions.guild.hub.SupplyHubService; import net.tfminecraft.simplefactions.guild.income.entry.PlayerEntry; @@ -227,6 +228,17 @@ public double getIncome(Cashflow cashflow) { if(!guild.isBase()) return 0; amount = getTotalTariffsEarned(); break; + case HUB_TAX: + if (!guild.isBase()) { + return 0; + } + amount = getTotalHubTaxEarned(); + break; + case HUB_TAX_PAYMENTS: { + HubTaxBreakdown hubTax = guild.getHubTaxBreakdown(); + amount = hubTax == null ? 0 : -hubTax.getTotalTax(); + break; + } case TARIFF_PAYMENTS: { TradeBreakdown tariffs = guild.getTradeBreakdown(); amount = tariffs == null ? 0 : -tariffs.getTariffs(); @@ -392,6 +404,20 @@ public double getTotalTariffsEarned() { return total; } + public double getTotalHubTaxEarned() { + double total = 0; + for (Guild payer : FactionManager.getAllGuilds()) { + if (payer.getLedger() == null || payer.getLedger().skipsMoneyMovement()) { + continue; + } + HubTaxBreakdown hubTax = payer.getHubTaxBreakdown(); + if (hubTax != null) { + total += hubTax.getTax(guild.getFaction()); + } + } + return total; + } + public List> getCitizenTaxEntriesDescending() { return citizenTaxes.entrySet().stream() .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) @@ -482,6 +508,7 @@ public double getNetIncome() { case TRADE: case CITIZENS: case TARIFFS: + case HUB_TAX: case GAMBLING: case VEHICLE_FEES: case GUILDS: @@ -509,6 +536,7 @@ public double getNetIncome() { case OVERLORD_TAX: case TRIBUTE_PAYMENTS: case TARIFF_PAYMENTS: + case HUB_TAX_PAYMENTS: case DIVIDEND_PAYMENT: case DIVIDEND_PAYOUT: case WAR_REPARATIONS_PAYMENT: @@ -559,6 +587,7 @@ public double getDividendBase() { case TRADE: case CITIZENS: case TARIFFS: + case HUB_TAX: case GAMBLING: case VEHICLE_FEES: case GUILDS: @@ -583,6 +612,7 @@ public double getDividendBase() { case OVERLORD_TAX: case TRIBUTE_PAYMENTS: case TARIFF_PAYMENTS: + case HUB_TAX_PAYMENTS: case WAR_REPARATIONS_PAYMENT: case LOAN_PAYMENTS: case INTEREST_PAYMENTS: @@ -833,6 +863,18 @@ public static Map>> collect payer.getName(), Math.abs(ledger.getIncome(Cashflow.GUILD_PAYMENTS))); } + HubTaxBreakdown hubTax = payer.getHubTaxBreakdown(); + if (hubTax != null) { + for (Map.Entry entry : hubTax.getTaxesByFaction().entrySet()) { + if (entry.getValue() <= 0) { + continue; + } + recordHistory(out, entry.getKey().getOrCreateMainGuild(), LedgerHistory.Source.HUB_TAX, + f.getName(), entry.getValue()); + recordHistory(out, payer, LedgerHistory.Source.HUB_TAX_PAYMENTS, + entry.getKey().getName(), entry.getValue()); + } + } TradeBreakdown trade = payer.getTradeBreakdown(); if (trade != null && trade.getTariffsByFactionMap() != null) { for (Map.Entry entry : trade.getTariffsByFactionMap().entrySet()) { @@ -1062,6 +1104,19 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { return; } + case HUB_TAX_PAYMENTS: { + HubTaxBreakdown hubTax = guild.getHubTaxBreakdown(); + if (hubTax == null) { + return; + } + for (Map.Entry entry : hubTax.getTaxesByFaction().entrySet()) { + if (entry.getValue() > 0) { + buffer.add(guild, entry.getKey().getOrCreateMainGuild(), entry.getValue()); + } + } + break; + } + //Taxes and Tariffs case TARIFF_PAYMENTS: { TradeBreakdown tariffs = guild.getTradeBreakdown(); @@ -1165,6 +1220,7 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { case DIVIDENDS: case TRIBUTES: case TARIFFS: + case HUB_TAX: case WAR_REPARATIONS: case MERCENARY_CONTRACT: case REFUNDS: diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/LedgerHistory.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/LedgerHistory.java index 6aab969d..316d7f6c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/LedgerHistory.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/LedgerHistory.java @@ -19,6 +19,8 @@ public enum Source { VASSALS, TRIBUTES, TARIFFS, + HUB_TAX, + HUB_TAX_PAYMENTS, DEPOSITS } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java index dffa4266..857d7d19 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java @@ -504,6 +504,8 @@ public void time() { runDailyStep("supply hub links", () -> { net.tfminecraft.simplefactions.guild.hub.HubNetwork.forgetRoutes(); SimpleFactions.getInstance().getProvinceManager().recalculate(); + net.tfminecraft.simplefactions.guild.hub.HubTaxService.refresh( + SimpleFactions.getInstance().getProvinceManager()); }); runDailyStep("income", this::settleIncome); timer = 0; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java index 20ec1966..60fc7011 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java @@ -33,6 +33,7 @@ public class ProvinceManager { private Map provinces = new HashMap<>(); + private Map> hubLinksOverride; private long stateVersion = 0; private long lastCalculatedVersion = -1; @@ -74,6 +75,18 @@ public ProvinceManager createSnapshotShell() { return snap; } + /** Snapshot-only link selection. An unset map keeps the normal cached network. */ + public void setHubLinksOverride(Map> links) { + hubLinksOverride = links; + } + + private List hubLinksFor(Guild guild) { + if (hubLinksOverride != null && hubLinksOverride.containsKey(guild.getId())) { + return hubLinksOverride.get(guild.getId()); + } + return HubNetwork.linksFor(guild); + } + public void clearGuildData(String guildId) { if (guildId == null) { return; @@ -157,7 +170,7 @@ private void recalculateGuild(Guild guild) { * travel along a chain of hubs; every link loses some, so this settles. */ private void carryTradeThroughHubs(Guild guild) { - List links = HubNetwork.linksFor(guild); + List links = hubLinksFor(guild); if (links.isEmpty()) return; // Prosperity weighs production by a province's distance from where it came. Trade arriving // through a hub must not shorten that for production that still walked from the capital, @@ -192,7 +205,7 @@ private void deliverTradeThroughHubs(Guild guild, List links, double bonus } private void carryProductionThroughHubs(Guild guild) { - List links = HubNetwork.linksFor(guild); + List links = hubLinksFor(guild); double bonus = GuildModifierOverride.resolve(guild, GuildModifier.HUB_PRODUCTION); for (int pass = 0; pass <= links.size(); pass++) { boolean moved = false; @@ -254,6 +267,20 @@ public double getIncome(Guild guild, boolean save) { return Math.round(income * 100.0) / 100.0; } + /** The gross trade line, before upkeep and tariffs, without writing a guild breakdown. */ + public double getGrossTradeIncome(Guild guild) { + if (!Cache.provincesEnabled) { + return 0; + } + double income = 0; + for (Province province : provinces.values()) { + if (province.getTerrain().generatesIncome()) { + income += province.getIncome(guild); + } + } + return PillageTradeHit.applyToIncome(guild, income); + } + public double getIncome(Guild guild) { return getIncome(guild, true); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java index 8dfa9eef..25d3ba5b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java @@ -191,7 +191,7 @@ public void warPeaceSelectView(Player player, Faction f, Action action, boolean @SuppressWarnings("deprecation") public void taxProposalView(Player player, Faction f, Inventory i) { boolean open = i == null; - if(i == null) i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.TAX_PROPOSAL_VIEW), 9, "§7Select Tax Type"); + if(i == null) i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.TAX_PROPOSAL_VIEW), 18, "§7Select Tax Type"); i.clear(); int x = 0; for(TaxTarget target : TaxTarget.values()) { @@ -199,7 +199,7 @@ public void taxProposalView(Player player, Faction f, Inventory i) { i.setItem(x, creator.createTaxTypeItem(player, f, target, true)); x++; } - i.setItem(8, inv.createBackButton(SFGUI.TAX_PROPOSAL_VIEW)); + i.setItem(17, inv.createBackButton(SFGUI.TAX_PROPOSAL_VIEW)); if(open) player.openInventory(i); } 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 7ee13e1b..8e72c202 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java @@ -178,6 +178,7 @@ public ItemStack createMenuItem(Player p, Guild guild, MenuItemType t) { } lore.add(StringFormatter.formatHex("#d4c9aeUpkeep from trade: #cb5b4f"+guild.getTradeBreakdown().getUpkeep())); lore.add(StringFormatter.formatHex("#d4c9aeTariffs Paid: #b23c2f"+guild.getTradeBreakdown().getTariffs())); + lore.add(StringFormatter.formatHex("#d4c9aeHub Tax Paid: #b23c2f"+guild.getHubTaxBreakdown().getTotalTax())); lore.add(StringFormatter.formatHex("#d4c9aeTotal Trade Power: #a4bc5c"+guild.getTradeBreakdown().getTradePower())); String pillageLine = PillageTradeHit.breakdownLine(guild); if (pillageLine != null) { @@ -333,7 +334,8 @@ private List upgradeLore(Guild guild, Branch branch, Double delta) { lore.add(StringFormatter.formatHex("#ed1313No capital!")); } else { lore.add(""); - lore.add(StringFormatter.formatHex("#d4c9aeCurrent Net Trade Income: #7fbd73"+guild.getTradeBreakdown().getNetTradeIncome())); + lore.add(StringFormatter.formatHex("#d4c9aeCurrent Net Trade Income: #7fbd73"+net.tfminecraft.simplefactions.utils.Formatter.formatDouble( + guild.getTradeBreakdown().getNetTradeIncome() - guild.getHubTaxBreakdown().getTotalTax()))); lore.add(incomeChangeLine(delta)); } lore.add(""); @@ -379,7 +381,8 @@ private List downgradeLore(Guild guild, Branch branch, Double delta) { lore.add(""); lore.add(StringFormatter.formatHex("#ed1313No capital!")); } else { - lore.add(StringFormatter.formatHex("#d4c9aeCurrent Net Trade Income: #7fbd73"+guild.getTradeBreakdown().getNetTradeIncome())); + lore.add(StringFormatter.formatHex("#d4c9aeCurrent Net Trade Income: #7fbd73"+net.tfminecraft.simplefactions.utils.Formatter.formatDouble( + guild.getTradeBreakdown().getNetTradeIncome() - guild.getHubTaxBreakdown().getTotalTax()))); lore.add(incomeChangeLine(delta)); } lore.add(""); @@ -630,6 +633,38 @@ public ItemStack createLedgerTariffsItem(Guild g) { return i; } + // Keep the existing legacy text representation, formatting, and exact-string comparisons. + @SuppressWarnings("deprecation") + public ItemStack createLedgerHubTaxItem(Guild g, boolean payments) { + ItemStack i = new ItemStack(Material.EMERALD); + ItemMeta m = i.getItemMeta(); + m.setDisplayName(StringFormatter.formatHex("#5cc46aHub Tax" + (payments ? " Paid" : ""))); + Map today = new HashMap<>(); + if (payments) { + if (!g.isBankrupt() && g.getHubTaxBreakdown() != null) { + g.getHubTaxBreakdown().getTaxesByFaction().forEach((host, tax) -> { + if (tax > 0) { + today.merge(host.getName(), tax, Double::sum); + } + }); + } + } else { + for (Guild payer : FactionManager.getAllGuilds()) { + if (payer.isBankrupt() || payer.getBank() == null || payer.getHubTaxBreakdown() == null) { + continue; + } + double tax = payer.getHubTaxBreakdown().getTax(g.getFaction()); + if (tax > 0) { + today.merge(payer.getFaction().getName(), tax, Double::sum); + } + } + } + m.setLore(ledgerSourceLore(g, payments ? LedgerHistory.Source.HUB_TAX_PAYMENTS : LedgerHistory.Source.HUB_TAX, + payments ? "Top hub tax hosts" : "Top hub tax payers", today, "No hub tax today.")); + i.setItemMeta(m); + return i; + } + // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public ItemStack createLedgerDepositsItem(Guild g) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java index 2313ded8..51d9c97d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java @@ -131,7 +131,9 @@ public void ledgerView(Player player, Guild guild, Inventory i) { i.setItem(13, creator.createLedgerTributesItem(guild)); i.setItem(14, creator.createLedgerTariffsItem(guild)); i.setItem(15, creator.createLedgerDepositsItem(guild)); + i.setItem(16, creator.createLedgerHubTaxItem(guild, false)); } + i.setItem(17, creator.createLedgerHubTaxItem(guild, true)); i.setItem(26, inv.createBackButton(SFGUI.LEDGER_VIEW)); if(open) player.openInventory(i); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TaxView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TaxView.java index f10cb4d2..bef8afcf 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TaxView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TaxView.java @@ -32,7 +32,7 @@ public TaxView(InventoryManager inv) { public void taxView(Player player, Faction f, Inventory i) { if(f == null) return; boolean open = i == null; - if(open) i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.TAX_VIEW), 9, "§7Tax View"); + if(open) i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.TAX_VIEW), 18, "§7Tax View"); i.clear(); int slot = 0; for(TaxTarget target : TaxTarget.values()) { @@ -40,7 +40,7 @@ public void taxView(Player player, Faction f, Inventory i) { i.setItem(slot, creator.createTaxTypeItem(player, f, target, false)); slot++; } - i.setItem(8, inv.createBackButton(SFGUI.TAX_VIEW)); + i.setItem(17, inv.createBackButton(SFGUI.TAX_VIEW)); if(open) player.openInventory(i); } diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/handler/TaxHandler.java b/src/main/java/net/tfminecraft/simplefactions/objects/handler/TaxHandler.java index 8c364ca8..9c874b77 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/handler/TaxHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/handler/TaxHandler.java @@ -3,6 +3,7 @@ import java.util.HashMap; import java.util.Map; +import net.tfminecraft.simplefactions.Cache; import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.guild.income.EconomicPreview; import net.tfminecraft.simplefactions.guild.income.IncomePreviewContext; @@ -24,6 +25,7 @@ public class TaxHandler { private double vassalTax; private double dividendTax; private double tariffs; + private double hubTax; private HashMap> specificTaxes = new HashMap<>(); @@ -40,6 +42,24 @@ public void setTariffs(double tariffs) { this.tariffs = tariffs; } + public void setHubTax(double rate) { + hubTax = clampHubTax(rate); + } + + public double getHubTax() { + return clampHubTax(hubTax); + } + + private double clampHubTax(double rate) { + if (!Double.isFinite(rate)) { + return 0; + } + Bracket bracket = taxBrackets.get(TaxTarget.TARIFFS); + double max = Math.min(Cache.supplyHubMaxTax, bracket == null ? 100.0 : bracket.getMax()); + double min = Math.min(max, bracket == null ? 0.0 : bracket.getMin()); + return Math.max(min, Math.min(max, rate)); + } + public void setCitizenTax(double citizenTax) { this.citizenTax = citizenTax; } @@ -95,6 +115,10 @@ public void setTaxRate(TaxTarget target, String id, double rate) { dividendTax = rate; break; + case HUB_TAX: + setHubTax(rate); + break; + case TARIFFS: tariffs = rate; break; @@ -120,6 +144,7 @@ public double getTaxRate(TaxTarget target, String id, boolean effective) { double rate = switch (target) { case CITIZENS -> citizenTax; case DIVIDENDS -> dividendTax; + case HUB_TAX -> getHubTax(); case TARIFFS -> (id != null && hasSpecificTax(target, id)) ? getSpecificTax(target, id) : tariffs; @@ -144,6 +169,9 @@ public double getTaxRate(TaxTarget target, String id, boolean effective) { if (context != null) { rate = context.adjustTax(f, this, target, id, rate); } + if (target == TaxTarget.HUB_TAX) { + rate = clampHubTax(rate); + } return effective ? Formatter.formatDouble(rate * f.getGovernment().getTaxEfficiency()) : rate; } @@ -189,6 +217,7 @@ public void applyBracket(TaxTarget target, Bracket bracket) { case TARIFFS: tariffs = applyBracket(tariffs, bracket); + setHubTax(hubTax); applySpecificBracket(target, bracket); break; @@ -203,14 +232,20 @@ public Bracket getBracket(TaxTarget target) { public double getMin(TaxTarget target) { if(!canCollectTax(target)) return 0.0; - Bracket bracket = taxBrackets.get(target); + Bracket bracket = taxBrackets.get(target == TaxTarget.HUB_TAX ? TaxTarget.TARIFFS : target); if (bracket == null) return 0.0; + if (target == TaxTarget.HUB_TAX) { + return Math.min(bracket.getMin(), getMax(target)); + } return bracket.getMin(); } public double getMax(TaxTarget target) { if(!canCollectTax(target)) return 0.0; - Bracket bracket = taxBrackets.get(target); + Bracket bracket = taxBrackets.get(target == TaxTarget.HUB_TAX ? TaxTarget.TARIFFS : target); + if (target == TaxTarget.HUB_TAX) { + return Math.min(Cache.supplyHubMaxTax, bracket == null ? 100.0 : bracket.getMax()); + } if (bracket == null) return 100.0; return bracket.getMax(); } @@ -229,6 +264,7 @@ public boolean canCollectTax(TaxTarget target) { return f.hasFactionRule(Rules.DIVIDEND_TAX); case TARIFFS: case TARIFF_ID: + case HUB_TAX: return f.hasFactionRule(Rules.TARIFFS); default: return false; @@ -236,6 +272,9 @@ public boolean canCollectTax(TaxTarget target) { } public void setSpecificTax(TaxTarget target, String id, double rate) { + if (target == TaxTarget.HUB_TAX) { + return; + } double defaultRate = getDefaultRate(target); if (Double.compare(rate, defaultRate) == 0) { @@ -295,6 +334,7 @@ public void saveState() { vassalTax, dividendTax, tariffs, + hubTax, copiedSpecificTaxes ); } @@ -307,6 +347,7 @@ public void restoreState() { this.vassalTax = savedSnapshot.vassalTax; this.dividendTax = savedSnapshot.dividendTax; this.tariffs = savedSnapshot.tariffs; + setHubTax(savedSnapshot.hubTax); this.specificTaxes.clear(); @@ -326,6 +367,7 @@ private double getDefaultRate(TaxTarget target) { case GUILDS, GUILD_ID -> guildTax; case VASSALS, VASSAL_ID -> vassalTax; case DIVIDENDS -> dividendTax; + case HUB_TAX -> getHubTax(); case TARIFFS, TARIFF_ID -> tariffs; default -> 0.0; }; diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/handler/TaxSnapshot.java b/src/main/java/net/tfminecraft/simplefactions/objects/handler/TaxSnapshot.java index 51957b26..77d6276e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/handler/TaxSnapshot.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/handler/TaxSnapshot.java @@ -10,6 +10,7 @@ public class TaxSnapshot { final double vassalTax; final double dividendTax; final double tariffs; + final double hubTax; final HashMap> specificTaxes; @@ -19,6 +20,7 @@ public class TaxSnapshot { double vassalTax, double dividendTax, double tariffs, + double hubTax, HashMap> specificTaxes ) { this.citizenTax = citizenTax; @@ -26,6 +28,7 @@ public class TaxSnapshot { this.vassalTax = vassalTax; this.dividendTax = dividendTax; this.tariffs = tariffs; + this.hubTax = hubTax; this.specificTaxes = specificTaxes; } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index d89cc870..1ba2741a 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -90,6 +90,8 @@ supply-hubs: 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. diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubTaxServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubTaxServiceTest.java new file mode 100644 index 00000000..cd88fbad --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubTaxServiceTest.java @@ -0,0 +1,193 @@ +package net.tfminecraft.simplefactions.guild.hub; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +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 org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.enums.GuildModifier; +import net.tfminecraft.simplefactions.enums.Terrain; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +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.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.map.provinces.ProvinceDataEntry; +import net.tfminecraft.simplefactions.objects.Faction; + +class HubTaxServiceTest { + @Test + void pairSharesTheGainBetweenBothEnds() { + assertEquals(Map.of("a", 50.0, "b", 50.0), + HubTaxService.taxableIncome(100, 200, Map.of("a", 100.0, "b", 100.0))); + } + + @Test + void chainScalesMarginalsProportionallyToTheGain() { + Map taxable = HubTaxService.taxableIncome( + 100, 220, Map.of("a", 100.0, "b", 100.0, "c", 160.0)); + assertEquals(48, taxable.get("a"), 1e-9); + assertEquals(48, taxable.get("b"), 1e-9); + assertEquals(24, taxable.get("c"), 1e-9); + assertEquals(120, taxable.values().stream().mapToDouble(Double::doubleValue).sum(), 1e-9); + } + + @Test + void aHubThatAddsNothingGetsNoTaxableIncome() { + assertEquals(Map.of("a", 30.0, "b", 0.0), + HubTaxService.taxableIncome(100, 150, Map.of("a", 120.0, "b", 150.0))); + } + + @Test + void noGainOrNegativeMarginalsCannotBeTaxed() { + assertEquals(Map.of("a", 0.0), HubTaxService.taxableIncome(200, 150, Map.of("a", 100.0))); + assertEquals(Map.of("a", 0.0), HubTaxService.taxableIncome(100, 150, Map.of("a", 160.0))); + } + + @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)); + org.junit.jupiter.api.Assertions.assertFalse( + HubTaxService.touches(new Link(1, 2, 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)); + } + + @Test + void snapshotsAssessForeignHubsWithoutChangingLiveDataOrTheNetwork() { + boolean enabled = Cache.provincesEnabled; + Map carry = new HashMap<>(Cache.tradeCarry); + Cache.provincesEnabled = true; + Cache.tradeCarry.put(Terrain.PLAINS, 0.85); + Guild guild = mock(Guild.class); + Faction home = mock(Faction.class); + Faction host = mock(Faction.class); + when(home.getId()).thenReturn("home"); + when(host.getId()).thenReturn("host"); + when(host.hasHubPermit("guild")).thenReturn(true); + when(host.getTaxRate(TaxTarget.HUB_TAX, null, true)).thenReturn(50.0); + when(guild.getId()).thenReturn("guild"); + when(guild.getFaction()).thenReturn(home); + when(guild.hasCapital()).thenReturn(true); + when(guild.getCapital()).thenReturn(1); + 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); + TradeBreakdown trade = new TradeBreakdown(); + trade.setIncome(123); + when(guild.getTradeBreakdown()).thenReturn(trade); + SupplyHub first = new SupplyHub("home", "a", 1); + SupplyHub last = new SupplyHub("host", "b", 2); + SupplyHub dormant = new SupplyHub("host", "dormant", 3); + when(guild.getSupplyHubs()).thenReturn(new ArrayList<>(List.of(first, last, dormant))); + Installation a = new Installation("a", "A", InstallationKind.TRAIN_STATION, 1, 0, 0, 0L); + Installation b = new Installation("b", "B", InstallationKind.TRAIN_STATION, 22, 100, 0, 0L); + InstallationHandler homeSites = mock(InstallationHandler.class); + InstallationHandler hostSites = mock(InstallationHandler.class); + when(home.getInstallationHandler()).thenReturn(homeSites); + when(host.getInstallationHandler()).thenReturn(hostSites); + when(homeSites.getById("a")).thenReturn(a); + when(hostSites.getById("b")).thenReturn(b); + ProvinceManager provinces = new ProvinceManager(); + Map map = new HashMap<>(); + for (int id = 1; id <= 24; id++) { + map.put(id, new Province(id, Terrain.PLAINS.name(), 50)); + } + for (int id = 1; id < 24; id++) { + map.get(id).addNeighbour(id + 1); + 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)); + HubNetwork.setLinksForTests(Map.of("guild", links)); + try (MockedStatic factions = mockStatic(FactionManager.class); + MockedStatic titles = mockStatic(TitleManager.class); + MockedStatic config = mockStatic(InstallationConfigLoader.class)) { + factions.when(() -> FactionManager.getByString("home")).thenReturn(home); + factions.when(() -> FactionManager.getByString("host")).thenReturn(host); + factions.when(() -> FactionManager.getGuildByString("guild")).thenReturn(guild); + factions.when(FactionManager::getAllGuilds).thenReturn(List.of(guild)); + titles.when(() -> TitleManager.getByProvince(anyInt())).thenReturn(null); + config.when(() -> InstallationConfigLoader.getHubSlots(InstallationKind.TRAIN_STATION, 1)) + .thenReturn(1); + provinces.recalculateForSingleGuild(guild, false); + Map liveEntries = new HashMap<>(); + Map liveProsperity = new HashMap<>(); + Map liveValues = new HashMap<>(); + for (Province p : provinces.getProvinces()) { + ProvinceDataEntry entry = p.getAllData().get("guild"); + liveEntries.put(p.getId(), entry); + if (entry != null) { + liveValues.put(p.getId(), entry.copy()); + } + liveProsperity.put(p.getId(), p.getProsperity()); + } + ProvinceManager noLinks = provinces.createSnapshotShell(); + noLinks.copyAllDataFrom(provinces); + noLinks.setHubLinksOverride(Map.of("guild", List.of())); + noLinks.recalculateForSingleGuild(guild, false); + double gain = provinces.getGrossTradeIncome(guild) - noLinks.getGrossTradeIncome(guild); + + HubTaxBreakdown assessment = HubTaxService.assess(provinces, guild, List.of(guild)); + + assertTrue(gain > 0); + assertEquals(gain / 2, assessment.forHub(first).taxableIncome(), 1e-9); + assertEquals(gain / 2, assessment.forHub(last).taxableIncome(), 1e-9); + assertEquals(0, assessment.forHub(first).tax()); + assertEquals(gain / 4, assessment.forHub(last).tax(), 1e-9); + assertEquals(gain / 2, assessment.getTaxableIncome(host), 1e-9); + assertEquals(0, assessment.forHub(dormant).taxableIncome()); + for (Province p : provinces.getProvinces()) { + assertSame(liveEntries.get(p.getId()), p.getAllData().get("guild")); + assertEquals(liveProsperity.get(p.getId()), p.getProsperity()); + ProvinceDataEntry before = liveValues.get(p.getId()); + if (before != null) { + ProvinceDataEntry after = p.getAllData().get("guild"); + assertEquals(before.getTrade(), after.getTrade()); + assertEquals(before.getProduction(), after.getProduction()); + assertEquals(before.getDistance(), after.getDistance()); + } + } + assertEquals(123, guild.getTradeBreakdown().getIncome()); + assertEquals(links, HubNetwork.linksFor(guild)); + try (MockedStatic relations = mockStatic(RelationManager.class)) { + relations.when(() -> RelationManager.sameRealm(host, home)).thenReturn(true); + HubTaxBreakdown exempt = HubTaxService.assess(provinces, guild, List.of(guild)); + assertEquals(0, exempt.getTotalTax()); + assertEquals(gain / 2, exempt.getTaxableIncome(host), 1e-9); + assertTrue(exempt.forHub(last).taxableIncome() > 0); + } + HubNetwork.setLinksForTests(null); + assertEquals(0, HubTaxService.assess(provinces, guild, List.of(guild)).getTotalTax()); + } finally { + HubNetwork.setLinksForTests(null); + Cache.provincesEnabled = enabled; + Cache.tradeCarry.clear(); + Cache.tradeCarry.putAll(carry); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHubTaxTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHubTaxTest.java new file mode 100644 index 00000000..48edcc12 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHubTaxTest.java @@ -0,0 +1,162 @@ +package net.tfminecraft.simplefactions.guild.income; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.simplefactions.database.GuildData; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.government.Government; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.hub.HubTaxBreakdown; +import net.tfminecraft.simplefactions.guild.hub.SupplyHub; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.RelationManager; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.handler.TaxHandler; +import net.tfminecraft.simplefactions.utils.DailyGuildTransfers; + +class LedgerHubTaxTest { + private MockedStatic factions; + private MockedStatic relations; + private Guild payer; + private Guild receiver; + private Faction host; + private TaxHandler taxes; + private HubTaxBreakdown assessment; + + @BeforeEach + void setUp() { + factions = mockStatic(FactionManager.class); + relations = mockStatic(RelationManager.class); + Faction home = mock(Faction.class); + when(home.getId()).thenReturn("home"); + when(home.getName()).thenReturn("Home"); + host = mock(Faction.class); + when(host.getId()).thenReturn("host"); + when(host.getName()).thenReturn("Host"); + Government government = mock(Government.class); + when(host.getGovernment()).thenReturn(government); + when(government.getTaxEfficiency()).thenReturn(1.0); + taxes = new TaxHandler(host, 0, 0, 0, 0, 0); + taxes.setHubTax(20); + when(host.getTaxHandler()).thenReturn(taxes); + when(host.getTaxRate(any(TaxTarget.class), nullable(String.class), anyBoolean())) + .thenAnswer(call -> taxes.getTaxRate(call.getArgument(0), call.getArgument(1), call.getArgument(2))); + payer = guild(home, false); + receiver = guild(host, true); + when(payer.getId()).thenReturn("payer"); + when(payer.getName()).thenReturn("Payer"); + when(host.getOrCreateMainGuild()).thenReturn(receiver); + SupplyHub hub = new SupplyHub("host", "station", 1); + assessment = new HubTaxBreakdown( + Map.of(hub, new HubTaxBreakdown.Assessment(50, 10)), Map.of(host, 50.0), Map.of(host, 10.0), java.util.Set.of()); + when(payer.getHubTaxBreakdown()).thenReturn(assessment); + TradeBreakdown trade = new TradeBreakdown(); + trade.setIncome(200); + when(payer.getTradeBreakdown()).thenReturn(trade); + factions.when(FactionManager::getAllGuilds).thenReturn(List.of(payer, receiver)); + } + + @AfterEach + void tearDown() { + IncomePreviewContext.clear(); + factions.close(); + relations.close(); + } + + @Test + void bothLedgerLinesNetAndDividendBaseMatchTheSingleTransfer() { + assertEquals(-10, payer.getLedger().getIncome(Cashflow.HUB_TAX_PAYMENTS)); + assertEquals(10, receiver.getLedger().getIncome(Cashflow.HUB_TAX)); + assertEquals(0, payer.getLedger().getIncome(Cashflow.HUB_TAX)); + assertEquals(190, payer.getLedger().getNetIncome()); + assertEquals(190, payer.getLedger().getDividendBase()); + assertEquals(10, receiver.getLedger().getNetIncome()); + assertEquals(200, payer.getLedger().getGrossTaxableIncome()); + assertEquals(0, payer.getLedger().getInflationDelta() - 200); + DailyGuildTransfers transfers = new DailyGuildTransfers(); + payer.getLedger().populateDailyTransfers(transfers); + receiver.getLedger().populateDailyTransfers(transfers); + assertEquals(Map.of(receiver, 10.0), transfers.getTransfers().get(payer)); + assertEquals(Map.of(payer, 200.0), transfers.getExternalDeltas()); + assertEquals(1, transfers.getTransfers().size()); + } + + @Test + void bothSidesHistoryIsCollectedAndSaved() { + var day = Ledger.collectHistoryDay(List.of(payer, receiver)); + assertEquals(Map.of("Home", 10.0), day.get(receiver).get(LedgerHistory.Source.HUB_TAX)); + assertEquals(Map.of("Host", 10.0), day.get(payer).get(LedgerHistory.Source.HUB_TAX_PAYMENTS)); + for (Guild guild : List.of(payer, receiver)) { + guild.getLedger().getHistory().closeDay(day.get(guild)); + GuildData data = new GuildData(); + data.ledgerLastDay = guild.getLedger().getHistory().getLastDayCopy(); + data.ledgerLifetime = guild.getLedger().getHistory().getLifetimeCopy(); + GuildData restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(data), GuildData.class); + LedgerHistory loaded = new LedgerHistory(); + loaded.load(restored.ledgerLastDay, restored.ledgerLifetime, null); + LedgerHistory.Source source = guild == payer ? LedgerHistory.Source.HUB_TAX_PAYMENTS : LedgerHistory.Source.HUB_TAX; + assertEquals(10, LedgerHistory.total(loaded.getLastDay(source))); + assertEquals(10, LedgerHistory.total(loaded.getLifetime(source))); + } + } + + @Test + void bankruptPayersDoNotPayOrCreateIncomeForHosts() { + when(payer.isBankrupt()).thenReturn(true); + assertEquals(0, payer.getLedger().getIncome(Cashflow.HUB_TAX_PAYMENTS)); + assertEquals(0, receiver.getLedger().getIncome(Cashflow.HUB_TAX)); + assertTrue(Ledger.collectHistoryDay(List.of(payer, receiver)).isEmpty()); + DailyGuildTransfers transfers = new DailyGuildTransfers(); + payer.getLedger().populateDailyTransfers(transfers); + assertTrue(transfers.getTransfers().isEmpty()); + } + + @Test + void missingBankPayersAreFrozenAndBaseGuildsAlsoPay() { + when(payer.getBank()).thenReturn(null); + assertEquals(0, receiver.getLedger().getIncome(Cashflow.HUB_TAX)); + when(payer.getBank()).thenReturn(mock(Bank.class)); + when(payer.isBase()).thenReturn(true); + assertEquals(-10, payer.getLedger().getIncome(Cashflow.HUB_TAX_PAYMENTS)); + } + + @Test + void ratePreviewUsesStoredBasesAndLeavesTheAssessmentAndRateAlone() { + Map impact = EconomicPreview.tax(host, TaxTarget.HUB_TAX, null, 40); + assertEquals(-10, impact.get(payer)); + assertEquals(10, impact.get(receiver)); + assertEquals(20, taxes.getHubTax()); + assertEquals(10, assessment.getTotalTax()); + assertEquals(50, assessment.getTaxableIncome(host)); + } + + private static Guild guild(Faction faction, boolean base) { + Guild guild = mock(Guild.class); + Bank bank = mock(Bank.class); + when(bank.getWealth()).thenReturn(1000.0); + when(guild.getBank()).thenReturn(bank); + when(guild.getFaction()).thenReturn(faction); + when(guild.isBase()).thenReturn(base); + when(guild.getTradeBreakdown()).thenReturn(new TradeBreakdown()); + Ledger ledger = new Ledger(guild); + when(guild.getLedger()).thenReturn(ledger); + return guild; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxHandlerHubTaxTest.java b/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxHandlerHubTaxTest.java new file mode 100644 index 00000000..932c5c51 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxHandlerHubTaxTest.java @@ -0,0 +1,104 @@ +package net.tfminecraft.simplefactions.objects.handler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.database.FactionData; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.enums.Rules; +import net.tfminecraft.simplefactions.government.Government; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.hub.SupplyHubService; +import net.tfminecraft.simplefactions.objects.Bracket; +import net.tfminecraft.simplefactions.objects.Faction; + +class TaxHandlerHubTaxTest { + private double previousCap; + private int previousLimit; + private double previousUpkeep; + private double previousGrowth; + private TaxHandler handler; + + @BeforeEach + void setUp() { + previousCap = Cache.supplyHubMaxTax; + previousLimit = Cache.supplyHubBaseLimit; + previousUpkeep = Cache.supplyHubBaseUpkeep; + previousGrowth = Cache.supplyHubUpkeepGrowth; + SupplyHubService.loadConfig(null); + Faction faction = mock(Faction.class); + Government government = mock(Government.class); + when(faction.hasFactionRule(Rules.TARIFFS)).thenReturn(true); + when(faction.getGovernment()).thenReturn(government); + when(government.getTaxEfficiency()).thenReturn(0.8); + handler = new TaxHandler(faction, 0, 0, 0, 0, 15); + } + + @AfterEach + void tearDown() { + Cache.supplyHubMaxTax = previousCap; + Cache.supplyHubBaseLimit = previousLimit; + Cache.supplyHubBaseUpkeep = previousUpkeep; + Cache.supplyHubUpkeepGrowth = previousGrowth; + } + + @Test + void defaultsToZeroAndCapsAtFiftyWithTariffEfficiency() { + assertEquals(0, handler.getHubTax()); + assertEquals(50, handler.getMax(TaxTarget.HUB_TAX)); + handler.setTaxRate(TaxTarget.HUB_TAX, null, 90); + assertEquals(50, handler.getTaxRate(TaxTarget.HUB_TAX, "any", false)); + assertEquals(40, handler.getTaxRate(TaxTarget.HUB_TAX, null, true)); + handler.setHubTax(-1); + assertEquals(0, handler.getHubTax()); + } + + @Test + void configuredCapAndTariffBracketsBothApply() { + YamlConfiguration config = new YamlConfiguration(); + config.set("supply-hubs.max-tax", 25); + SupplyHubService.loadConfig(config); + handler.setHubTax(80); + assertEquals(25, handler.getHubTax()); + assertEquals(25, handler.getMax(TaxTarget.HUB_TAX)); + handler.applyBracket(TaxTarget.TARIFFS, new Bracket(5, 20)); + assertEquals(20, handler.getHubTax()); + assertEquals(5, handler.getMin(TaxTarget.HUB_TAX)); + assertEquals(20, handler.getMax(TaxTarget.HUB_TAX)); + handler.applyBracket(TaxTarget.TARIFFS, new Bracket(0, 0)); + assertEquals(0, handler.getHubTax()); + } + + @Test + void hubTaxCannotAcquireSpecificRatesAndRestoresItsSavedState() { + handler.setHubTax(12); + handler.setSpecificTax(TaxTarget.HUB_TAX, "foreign", 30); + assertFalse(handler.hasSpecificTax(TaxTarget.HUB_TAX, "foreign")); + assertEquals(12, handler.getTaxRate(TaxTarget.HUB_TAX, "foreign", false)); + handler.saveState(); + handler.setHubTax(30); + handler.restoreState(); + assertEquals(12, handler.getHubTax()); + } + + @Test + void factionRateRoundTripsAndOldSavesDefaultToZero() { + handler.setHubTax(23.5); + FactionData data = new FactionData(); + data.hubTax = handler.getHubTax(); + FactionData restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(data), FactionData.class); + handler.setHubTax(restored.hubTax == null ? 0 : restored.hubTax); + assertEquals(23.5, handler.getHubTax()); + FactionData legacy = JsonUtil.GSON.fromJson("{\"id\":\"old\"}", FactionData.class); + handler.setHubTax(legacy.hubTax == null ? 0 : legacy.hubTax); + assertEquals(0, handler.getHubTax()); + } +} From 6e11a775a34e143a598325d17a568b1ee654d1bc Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:13:03 +0000 Subject: [PATCH 2/3] Keep hub tax away from bankrupt hosts and preview it under a proposed law. A bankrupt host's realm guild takes no money, so it no longer receives hub tax: the payer owes it nothing and neither side records it. One check now decides this for payments, earnings, history and transfers. When a law is being previewed, the hub tax rate is no longer pulled back into the faction's current tariff bracket after the proposed law's bracket was applied, so the preview shows the rate the law would give. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../guild/income/IncomePreviewContext.java | 4 ++ .../simplefactions/guild/income/Ledger.java | 57 ++++++++++--------- .../objects/handler/TaxHandler.java | 10 +++- .../guild/income/LedgerHubTaxTest.java | 16 ++++++ .../objects/handler/TaxHandlerHubTaxTest.java | 30 +++++++++- 5 files changed, 88 insertions(+), 29 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewContext.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewContext.java index eb643878..9dcbac2e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewContext.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewContext.java @@ -226,6 +226,10 @@ public boolean affectsHubTax(Faction owner) { return owner != null && faction == owner && (law != null || taxTarget == TaxTarget.HUB_TAX); } + public boolean previewsLaw(Faction owner) { + return owner != null && faction == owner && law != null; + } + private boolean replaces(TaxHandler handler, TaxTarget query, String id) { if (taxTarget == null || query == null) { return false; diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java index 439908bd..ec6a7ac5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java @@ -235,8 +235,9 @@ public double getIncome(Cashflow cashflow) { amount = getTotalHubTaxEarned(); break; case HUB_TAX_PAYMENTS: { - HubTaxBreakdown hubTax = guild.getHubTaxBreakdown(); - amount = hubTax == null ? 0 : -hubTax.getTotalTax(); + for (double tax : getPayableHubTaxes().values()) { + amount -= tax; + } break; } case TARIFF_PAYMENTS: { @@ -405,19 +406,35 @@ public double getTotalTariffsEarned() { } public double getTotalHubTaxEarned() { + if (skipsMoneyMovement()) { + return 0; + } double total = 0; for (Guild payer : FactionManager.getAllGuilds()) { - if (payer.getLedger() == null || payer.getLedger().skipsMoneyMovement()) { + if (payer.getLedger() == null) { continue; } - HubTaxBreakdown hubTax = payer.getHubTaxBreakdown(); - if (hubTax != null) { - total += hubTax.getTax(guild.getFaction()); - } + total += payer.getLedger().getPayableHubTaxes().getOrDefault(guild.getFaction(), 0.0); } return total; } + private Map getPayableHubTaxes() { + HubTaxBreakdown hubTax = guild.getHubTaxBreakdown(); + if (hubTax == null || guild.getFaction() == null || skipsMoneyMovement()) { + return Map.of(); + } + Map payable = new HashMap<>(); + for (Map.Entry entry : hubTax.getTaxesByFaction().entrySet()) { + Guild receiver = entry.getKey().getOrCreateMainGuild(); + if (entry.getValue() > 0 && receiver != null && receiver.getLedger() != null + && !receiver.getLedger().skipsMoneyMovement()) { + payable.put(entry.getKey(), entry.getValue()); + } + } + return payable; + } + public List> getCitizenTaxEntriesDescending() { return citizenTaxes.entrySet().stream() .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) @@ -863,17 +880,11 @@ public static Map>> collect payer.getName(), Math.abs(ledger.getIncome(Cashflow.GUILD_PAYMENTS))); } - HubTaxBreakdown hubTax = payer.getHubTaxBreakdown(); - if (hubTax != null) { - for (Map.Entry entry : hubTax.getTaxesByFaction().entrySet()) { - if (entry.getValue() <= 0) { - continue; - } - recordHistory(out, entry.getKey().getOrCreateMainGuild(), LedgerHistory.Source.HUB_TAX, - f.getName(), entry.getValue()); - recordHistory(out, payer, LedgerHistory.Source.HUB_TAX_PAYMENTS, - entry.getKey().getName(), entry.getValue()); - } + for (Map.Entry entry : ledger.getPayableHubTaxes().entrySet()) { + recordHistory(out, entry.getKey().getOrCreateMainGuild(), LedgerHistory.Source.HUB_TAX, + f.getName(), entry.getValue()); + recordHistory(out, payer, LedgerHistory.Source.HUB_TAX_PAYMENTS, + entry.getKey().getName(), entry.getValue()); } TradeBreakdown trade = payer.getTradeBreakdown(); if (trade != null && trade.getTariffsByFactionMap() != null) { @@ -1105,14 +1116,8 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { } case HUB_TAX_PAYMENTS: { - HubTaxBreakdown hubTax = guild.getHubTaxBreakdown(); - if (hubTax == null) { - return; - } - for (Map.Entry entry : hubTax.getTaxesByFaction().entrySet()) { - if (entry.getValue() > 0) { - buffer.add(guild, entry.getKey().getOrCreateMainGuild(), entry.getValue()); - } + for (Map.Entry entry : getPayableHubTaxes().entrySet()) { + buffer.add(guild, entry.getKey().getOrCreateMainGuild(), entry.getValue()); } break; } diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/handler/TaxHandler.java b/src/main/java/net/tfminecraft/simplefactions/objects/handler/TaxHandler.java index 9c874b77..5f68aeec 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/handler/TaxHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/handler/TaxHandler.java @@ -51,10 +51,13 @@ public double getHubTax() { } private double clampHubTax(double rate) { + return clampHubTax(rate, taxBrackets.get(TaxTarget.TARIFFS)); + } + + private double clampHubTax(double rate, Bracket bracket) { if (!Double.isFinite(rate)) { return 0; } - Bracket bracket = taxBrackets.get(TaxTarget.TARIFFS); double max = Math.min(Cache.supplyHubMaxTax, bracket == null ? 100.0 : bracket.getMax()); double min = Math.min(max, bracket == null ? 0.0 : bracket.getMin()); return Math.max(min, Math.min(max, rate)); @@ -170,7 +173,10 @@ public double getTaxRate(TaxTarget target, String id, boolean effective) { rate = context.adjustTax(f, this, target, id, rate); } if (target == TaxTarget.HUB_TAX) { - rate = clampHubTax(rate); + // adjustTax already applied the proposed law's bracket, as it does for tariffs. + Bracket bracket = context != null && context.previewsLaw(f) + ? null : taxBrackets.get(TaxTarget.TARIFFS); + rate = clampHubTax(rate, bracket); } return effective ? Formatter.formatDouble(rate * f.getGovernment().getTaxEfficiency()) : rate; } diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHubTaxTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHubTaxTest.java index 48edcc12..3d14a7a6 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHubTaxTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHubTaxTest.java @@ -128,6 +128,22 @@ void bankruptPayersDoNotPayOrCreateIncomeForHosts() { assertTrue(transfers.getTransfers().isEmpty()); } + @Test + void bankruptHostsReceiveNothingAndPayersKeepTheirIncome() { + when(receiver.isBankrupt()).thenReturn(true); + assertEquals(0, receiver.getLedger().getTotalHubTaxEarned()); + assertEquals(0, receiver.getLedger().getIncome(Cashflow.HUB_TAX)); + assertEquals(0, payer.getLedger().getIncome(Cashflow.HUB_TAX_PAYMENTS)); + assertEquals(200, payer.getLedger().getNetIncome()); + assertEquals(200, payer.getLedger().getDividendBase()); + assertTrue(Ledger.collectHistoryDay(List.of(payer, receiver)).isEmpty()); + DailyGuildTransfers transfers = new DailyGuildTransfers(); + payer.getLedger().populateDailyTransfers(transfers); + receiver.getLedger().populateDailyTransfers(transfers); + assertTrue(transfers.getTransfers().isEmpty()); + assertEquals(Map.of(payer, 200.0), transfers.getExternalDeltas()); + } + @Test void missingBankPayersAreFrozenAndBaseGuildsAlsoPay() { when(payer.getBank()).thenReturn(null); 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 932c5c51..a022a7be 100644 --- a/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxHandlerHubTaxTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxHandlerHubTaxTest.java @@ -17,6 +17,9 @@ import net.tfminecraft.simplefactions.government.Government; import net.tfminecraft.simplefactions.government.proposal.TaxTarget; import net.tfminecraft.simplefactions.guild.hub.SupplyHubService; +import net.tfminecraft.simplefactions.guild.income.IncomePreviewContext; +import net.tfminecraft.simplefactions.laws.Law; +import net.tfminecraft.simplefactions.laws.LawGroup; import net.tfminecraft.simplefactions.objects.Bracket; import net.tfminecraft.simplefactions.objects.Faction; @@ -25,6 +28,7 @@ class TaxHandlerHubTaxTest { private int previousLimit; private double previousUpkeep; private double previousGrowth; + private Faction faction; private TaxHandler handler; @BeforeEach @@ -34,7 +38,7 @@ void setUp() { previousUpkeep = Cache.supplyHubBaseUpkeep; previousGrowth = Cache.supplyHubUpkeepGrowth; SupplyHubService.loadConfig(null); - Faction faction = mock(Faction.class); + faction = mock(Faction.class); Government government = mock(Government.class); when(faction.hasFactionRule(Rules.TARIFFS)).thenReturn(true); when(faction.getGovernment()).thenReturn(government); @@ -44,6 +48,7 @@ void setUp() { @AfterEach void tearDown() { + IncomePreviewContext.clear(); Cache.supplyHubMaxTax = previousCap; Cache.supplyHubBaseLimit = previousLimit; Cache.supplyHubBaseUpkeep = previousUpkeep; @@ -77,6 +82,29 @@ void configuredCapAndTariffBracketsBothApply() { assertEquals(0, handler.getHubTax()); } + @Test + void lawPreviewUsesTheProposedTariffBracketAndConfiguredCap() { + handler.applyBracket(TaxTarget.TARIFFS, new Bracket(0, 10)); + handler.setHubTax(5); + YamlConfiguration config = new YamlConfiguration(); + config.set("effects.FACTION.brackets.TARIFFS", "20-80"); + Law proposed = new Law("taxes", "proposed", config); + LawGroup group = mock(LawGroup.class); + + IncomePreviewContext.open(IncomePreviewContext.law(faction, group, proposed)); + assertEquals(20, handler.getTaxRate(TaxTarget.TARIFFS, null, false)); + assertEquals(20, handler.getTaxRate(TaxTarget.HUB_TAX, null, false)); + assertEquals(16, handler.getTaxRate(TaxTarget.HUB_TAX, null, true)); + Cache.supplyHubMaxTax = 15; + assertEquals(15, handler.getTaxRate(TaxTarget.HUB_TAX, null, false)); + IncomePreviewContext.clear(); + + assertEquals(5, handler.getHubTax()); + assertEquals(5, handler.getTaxRate(TaxTarget.HUB_TAX, null, false)); + IncomePreviewContext.open(IncomePreviewContext.tax(faction, TaxTarget.HUB_TAX, null, 40)); + assertEquals(10, handler.getTaxRate(TaxTarget.HUB_TAX, null, false)); + } + @Test void hubTaxCannotAcquireSpecificRatesAndRestoresItsSavedState() { handler.setHubTax(12); From 7bf8a9d2a686a46aaddd1197dd8ce7a1ea8d341e Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:25:09 +0000 Subject: [PATCH 3/3] Assess hub tax at startup and show the ledger's own figures in the menu. Hub tax was only assessed at the day change, so after a restart the hub list and ledger menu showed none until then. It is now assessed once when the plugin starts. The Hub Tax menu items read the same payable amounts the ledger settles, so they no longer show tax to or from a guild that moves no money. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../simplefactions/SimpleFactions.java | 2 ++ .../simplefactions/guild/income/Ledger.java | 3 ++- .../managers/inventory/GuildCreator.java | 16 +++++++--------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index dda1c9f8..6a513ac2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -346,6 +346,8 @@ public void onEnable() { if (Cache.provincesEnabled) { provinceSnapshot = provinceManager.createSnapshotShell(); provinceManager.recalculate(); + // Hub tax is assessed at the day change; do it once now so menus are right before then. + net.tfminecraft.simplefactions.guild.hub.HubTaxService.refresh(provinceManager); } inventoryManager.start(); vehicleMaintenanceDecayTask.start(); diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java index ec6a7ac5..fff714d3 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java @@ -419,7 +419,8 @@ public double getTotalHubTaxEarned() { return total; } - private Map getPayableHubTaxes() { + /** Hub tax this guild will actually pay today, by host. Empty when either side moves no money. */ + public Map getPayableHubTaxes() { HubTaxBreakdown hubTax = guild.getHubTaxBreakdown(); if (hubTax == null || guild.getFaction() == null || skipsMoneyMovement()) { return Map.of(); 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 8e72c202..4bdf13f3 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java @@ -640,20 +640,18 @@ public ItemStack createLedgerHubTaxItem(Guild g, boolean payments) { ItemMeta m = i.getItemMeta(); m.setDisplayName(StringFormatter.formatHex("#5cc46aHub Tax" + (payments ? " Paid" : ""))); Map today = new HashMap<>(); + // The same figures the ledger settles: nothing to or from a guild that moves no money. if (payments) { - if (!g.isBankrupt() && g.getHubTaxBreakdown() != null) { - g.getHubTaxBreakdown().getTaxesByFaction().forEach((host, tax) -> { - if (tax > 0) { - today.merge(host.getName(), tax, Double::sum); - } - }); + if (g.getLedger() != null) { + g.getLedger().getPayableHubTaxes().forEach( + (host, tax) -> today.merge(host.getName(), tax, Double::sum)); } - } else { + } else if (g.getLedger() != null && !g.isBankrupt()) { for (Guild payer : FactionManager.getAllGuilds()) { - if (payer.isBankrupt() || payer.getBank() == null || payer.getHubTaxBreakdown() == null) { + if (payer.getLedger() == null) { continue; } - double tax = payer.getHubTaxBreakdown().getTax(g.getFaction()); + double tax = payer.getLedger().getPayableHubTaxes().getOrDefault(g.getFaction(), 0.0); if (tax > 0) { today.merge(payer.getFaction().getName(), tax, Double::sum); }