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/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/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..9dcbac2e 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,14 @@ 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); + } + + 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; @@ -253,7 +261,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 +309,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..fff714d3 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,18 @@ 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: { + for (double tax : getPayableHubTaxes().values()) { + amount -= tax; + } + break; + } case TARIFF_PAYMENTS: { TradeBreakdown tariffs = guild.getTradeBreakdown(); amount = tariffs == null ? 0 : -tariffs.getTariffs(); @@ -392,6 +405,37 @@ public double getTotalTariffsEarned() { return total; } + public double getTotalHubTaxEarned() { + if (skipsMoneyMovement()) { + return 0; + } + double total = 0; + for (Guild payer : FactionManager.getAllGuilds()) { + if (payer.getLedger() == null) { + continue; + } + total += payer.getLedger().getPayableHubTaxes().getOrDefault(guild.getFaction(), 0.0); + } + return total; + } + + /** 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(); + } + 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())) @@ -482,6 +526,7 @@ public double getNetIncome() { case TRADE: case CITIZENS: case TARIFFS: + case HUB_TAX: case GAMBLING: case VEHICLE_FEES: case GUILDS: @@ -509,6 +554,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 +605,7 @@ public double getDividendBase() { case TRADE: case CITIZENS: case TARIFFS: + case HUB_TAX: case GAMBLING: case VEHICLE_FEES: case GUILDS: @@ -583,6 +630,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 +881,12 @@ public static Map>> collect payer.getName(), Math.abs(ledger.getIncome(Cashflow.GUILD_PAYMENTS))); } + 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) { for (Map.Entry entry : trade.getTariffsByFactionMap().entrySet()) { @@ -1062,6 +1116,13 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { return; } + case HUB_TAX_PAYMENTS: { + for (Map.Entry entry : getPayableHubTaxes().entrySet()) { + buffer.add(guild, entry.getKey().getOrCreateMainGuild(), entry.getValue()); + } + break; + } + //Taxes and Tariffs case TARIFF_PAYMENTS: { TradeBreakdown tariffs = guild.getTradeBreakdown(); @@ -1165,6 +1226,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..4bdf13f3 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,36 @@ 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<>(); + // The same figures the ledger settles: nothing to or from a guild that moves no money. + if (payments) { + if (g.getLedger() != null) { + g.getLedger().getPayableHubTaxes().forEach( + (host, tax) -> today.merge(host.getName(), tax, Double::sum)); + } + } else if (g.getLedger() != null && !g.isBankrupt()) { + for (Guild payer : FactionManager.getAllGuilds()) { + if (payer.getLedger() == null) { + continue; + } + double tax = payer.getLedger().getPayableHubTaxes().getOrDefault(g.getFaction(), 0.0); + 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..5f68aeec 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,27 @@ 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) { + return clampHubTax(rate, taxBrackets.get(TaxTarget.TARIFFS)); + } + + private double clampHubTax(double rate, Bracket bracket) { + if (!Double.isFinite(rate)) { + return 0; + } + 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 +118,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 +147,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 +172,12 @@ 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) { + // 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; } @@ -189,6 +223,7 @@ public void applyBracket(TaxTarget target, Bracket bracket) { case TARIFFS: tariffs = applyBracket(tariffs, bracket); + setHubTax(hubTax); applySpecificBracket(target, bracket); break; @@ -203,14 +238,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 +270,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 +278,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 +340,7 @@ public void saveState() { vassalTax, dividendTax, tariffs, + hubTax, copiedSpecificTaxes ); } @@ -307,6 +353,7 @@ public void restoreState() { this.vassalTax = savedSnapshot.vassalTax; this.dividendTax = savedSnapshot.dividendTax; this.tariffs = savedSnapshot.tariffs; + setHubTax(savedSnapshot.hubTax); this.specificTaxes.clear(); @@ -326,6 +373,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..3d14a7a6 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHubTaxTest.java @@ -0,0 +1,178 @@ +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 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); + 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..a022a7be --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxHandlerHubTaxTest.java @@ -0,0 +1,132 @@ +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.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; + +class TaxHandlerHubTaxTest { + private double previousCap; + private int previousLimit; + private double previousUpkeep; + private double previousGrowth; + private Faction faction; + private TaxHandler handler; + + @BeforeEach + void setUp() { + previousCap = Cache.supplyHubMaxTax; + previousLimit = Cache.supplyHubBaseLimit; + previousUpkeep = Cache.supplyHubBaseUpkeep; + previousGrowth = Cache.supplyHubUpkeepGrowth; + SupplyHubService.loadConfig(null); + faction = mock(Faction.class); + Government government = mock(Government.class); + 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() { + IncomePreviewContext.clear(); + 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 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); + 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()); + } +}