diff --git a/src/main/java/net/tfminecraft/simplefactions/Cache.java b/src/main/java/net/tfminecraft/simplefactions/Cache.java index f25cda73..fc802748 100644 --- a/src/main/java/net/tfminecraft/simplefactions/Cache.java +++ b/src/main/java/net/tfminecraft/simplefactions/Cache.java @@ -166,6 +166,9 @@ public static boolean requireProvinces(org.bukkit.command.CommandSender sender) public static double mercenaryDefaultBreachRefund = 500.0; public static double supplyHubMaxTax = 50.0; + public static double supplyHubMaxFee = 500.0; + public static int supplyHubOfferDays = 7; + public static int supplyHubAgreementDays = 14; public static double infrastructureFull = 20; public static double infrastructureTarget = 0.75; diff --git a/src/main/java/net/tfminecraft/simplefactions/database/Database.java b/src/main/java/net/tfminecraft/simplefactions/database/Database.java index ff7d71aa..2015c4b2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/Database.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/Database.java @@ -21,6 +21,7 @@ import net.tfminecraft.simplefactions.guild.upgrade.Upgrade; import net.tfminecraft.simplefactions.guild.upgrade.UpgradeExpansion; import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.hub.HubAgreementService; import net.tfminecraft.simplefactions.guild.hub.SupplyHubService; import net.tfminecraft.simplefactions.guild.income.LedgerHistory; import net.tfminecraft.simplefactions.loaders.BranchLoader; @@ -166,7 +167,6 @@ 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()) { @@ -192,8 +192,6 @@ public void loadFactions() { if (data.installations != null) { f.getInstallationHandler().load(data.installations); } - f.loadHubPermits(data.hubPermits); - if (data.installationQueue != null) { f.getInstallationHandler().loadConstruction(data.installationQueue); } @@ -330,7 +328,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.hubTax = null; data.specificTaxes = serializeSpecificTaxes(f.getTaxHandler()); data.vehicleFees = new HashMap<>(f.getVehicleFeeHandler().serializeRates()); data.vehicleTypeFees = new HashMap<>(); @@ -348,7 +346,7 @@ public void saveFaction(Faction f) { data.settlements = f.getSettlementHandler().serialize(); data.installations = f.getInstallationHandler().serialize(); - data.hubPermits = new ArrayList<>(f.getHubPermits()); + data.hubPermits = null; data.installationQueue = f.getInstallationHandler().serializeConstruction(); for (int p : f.getProvinces()) data.provinces.add(p); @@ -425,6 +423,8 @@ public void saveFaction(Faction f) { gd.depositsToday = depositsToday.isEmpty() ? null : depositsToday; gd.company = g.getCompany() != null ? g.getCompany().serialize() : null; gd.supplyHubs = SupplyHubService.toData(g.getSupplyHubs()); + gd.hubAgreements = HubAgreementService.toAgreementData(g.getHubAgreements()); + gd.hubOffers = HubAgreementService.toOfferData(g.getHubOffers()); gd.supplyHubTutorialDismissals = g.getSupplyHubTutorialDismissals(); // --- Bank --- diff --git a/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java b/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java index 827ec68d..0bc828c0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java @@ -104,5 +104,5 @@ public class FactionData { public List warReparationsObligations = new ArrayList<>(); @SerializedName("hub permits") - public List hubPermits = new ArrayList<>(); + public List hubPermits; } diff --git a/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java index 36a264b4..234e0c62 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java @@ -71,6 +71,12 @@ public class GuildData { @SerializedName("supply hubs") public List supplyHubs = new ArrayList<>(); + @SerializedName("hub agreements") + public List hubAgreements = new ArrayList<>(); + + @SerializedName("hub offers") + public List hubOffers = new ArrayList<>(); + @SerializedName("supply hub tutorial dismissals") public List supplyHubTutorialDismissals = new ArrayList<>(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/database/HubAgreementData.java b/src/main/java/net/tfminecraft/simplefactions/database/HubAgreementData.java new file mode 100644 index 00000000..8b3d42ac --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/database/HubAgreementData.java @@ -0,0 +1,32 @@ +package net.tfminecraft.simplefactions.database; + +import com.google.gson.annotations.SerializedName; + +public class HubAgreementData { + public String faction; + public String installation; + public Integer rate; + + /** Denars, whole cents. */ + @SerializedName("daily fee") + public Double dailyFee; + + @SerializedName("days remaining") + public Integer daysRemaining; + + @SerializedName("guild renews") + public Boolean guildRenews; + + @SerializedName("host renews") + public Boolean hostRenews; + + @SerializedName("next rate") + public Integer nextRate; + + @SerializedName("next fee") + public Double nextFee; + + /** Set once the guild leader has been told that a dormant hub still costs its fee. */ + @SerializedName("dormant notice") + public Boolean dormantNotice; +} diff --git a/src/main/java/net/tfminecraft/simplefactions/database/HubOfferData.java b/src/main/java/net/tfminecraft/simplefactions/database/HubOfferData.java new file mode 100644 index 00000000..83f155d4 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/database/HubOfferData.java @@ -0,0 +1,24 @@ +package net.tfminecraft.simplefactions.database; + +import com.google.gson.annotations.SerializedName; + +public class HubOfferData { + public String faction; + public String installation; + public Integer rate; + + @SerializedName("daily fee") + public Double dailyFee; + + /** {@code guild} or {@code host}. */ + public String awaiting; + + @SerializedName("last actor") + public String lastActor; + + @SerializedName("created at") + public Long createdAt; + + /** {@code new} or {@code next}. */ + public String kind; +} diff --git a/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java b/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java index a8dd05cb..60e80856 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java @@ -53,6 +53,9 @@ public void apply(Cause cause) { gov.getFaction().applyLaw(law, group); } else if (isTaxProposal()) { TaxTarget target = tax.getTarget(); + if (target == TaxTarget.HUB_TAX) { + return; + } gov.getFaction().getTaxHandler().setTaxRate(target, tax.getId(), tax.getNewTax()); } else if (isFeeProposal()) { gov.getFaction().getVehicleFeeHandler().setRate(fee.getKind(), fee.getVehicleTypeId(), fee.getNewRate()); diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index 45478752..77121670 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -49,6 +49,9 @@ import net.tfminecraft.simplefactions.database.Database; import net.tfminecraft.simplefactions.database.GuildBranchData; import net.tfminecraft.simplefactions.database.GuildData; +import net.tfminecraft.simplefactions.guild.hub.HubAgreement; +import net.tfminecraft.simplefactions.guild.hub.HubAgreementService; +import net.tfminecraft.simplefactions.guild.hub.HubOffer; import net.tfminecraft.simplefactions.guild.hub.SupplyHub; import net.tfminecraft.simplefactions.guild.hub.SupplyHubService; import net.tfminecraft.simplefactions.database.StabilityModifierData; @@ -107,6 +110,8 @@ public class Guild { private List dividendEligible = new ArrayList<>(); private MercenaryCompany company; private final List supplyHubs = new ArrayList<>(); + private final List hubAgreements = new ArrayList<>(); + private final List hubOffers = new ArrayList<>(); private final Set supplyHubTutorialDismissals = new HashSet<>(); public Guild(Faction f) { @@ -248,6 +253,8 @@ public Guild(GuildData data, Faction host) { this, data.company, MercenaryCompany.cloneMercenaryRegiment()); } this.supplyHubs.addAll(SupplyHubService.fromData(data.supplyHubs)); + this.hubAgreements.addAll(HubAgreementService.fromAgreementData(data.hubAgreements)); + this.hubOffers.addAll(HubAgreementService.fromOfferData(data.hubOffers)); if (data.supplyHubTutorialDismissals != null) { supplyHubTutorialDismissals.addAll(data.supplyHubTutorialDismissals); } @@ -433,6 +440,14 @@ public List getSupplyHubs() { return supplyHubs; } + public List getHubAgreements() { + return hubAgreements; + } + + public List getHubOffers() { + return hubOffers; + } + public boolean hasDismissedSupplyHubTutorial(String playerId) { return playerId != null && supplyHubTutorialDismissals.contains(playerId); } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubAgreement.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubAgreement.java new file mode 100644 index 00000000..c368a604 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubAgreement.java @@ -0,0 +1,41 @@ +package net.tfminecraft.simplefactions.guild.hub; + +/** + * Terms for one guild hub at another realm's installation. + * The rate is a whole percent. The fee is whole cents of a denar. + */ +public record HubAgreement( + String hostFactionId, + String installationId, + int taxRatePercent, + long feeCents, + int daysRemaining, + boolean guildRenews, + boolean hostRenews, + HubTerms nextTerms, + boolean dormantNoticeSent) { + + public HubAgreement withDays(int days) { + return new HubAgreement(hostFactionId, installationId, taxRatePercent, feeCents, days, + guildRenews, hostRenews, nextTerms, dormantNoticeSent); + } + + public HubAgreement withRenewal(boolean guildRenews, boolean hostRenews) { + return new HubAgreement(hostFactionId, installationId, taxRatePercent, feeCents, daysRemaining, + guildRenews, hostRenews, nextTerms, dormantNoticeSent); + } + + public HubAgreement withNextTerms(HubTerms nextTerms) { + return new HubAgreement(hostFactionId, installationId, taxRatePercent, feeCents, daysRemaining, + guildRenews, hostRenews, nextTerms, dormantNoticeSent); + } + + public HubAgreement withDormantNotice(boolean sent) { + return new HubAgreement(hostFactionId, installationId, taxRatePercent, feeCents, daysRemaining, + guildRenews, hostRenews, nextTerms, sent); + } + + public boolean willRenew() { + return guildRenews && hostRenews; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementFacts.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementFacts.java new file mode 100644 index 00000000..c27164b4 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementFacts.java @@ -0,0 +1,232 @@ +package net.tfminecraft.simplefactions.guild.hub; + +import java.util.ArrayList; +import java.util.List; + +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.enums.Rules; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.installation.Installation; +import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.RelationManager; +import net.tfminecraft.simplefactions.objects.Faction; + +/** + * World lookups the agreement rules ask for. The live instance reads factions. + * Tests pass their own, so the daily tick does not need a server. + */ +public interface HubAgreementFacts { + boolean sameRealm(Guild guild, String factionId); + + boolean hostExists(String factionId); + + boolean hostHasHubTax(String factionId); + + int minRate(String factionId); + + int maxRate(String factionId); + + double maxFee(); + + int offerDays(); + + int agreementDays(); + + boolean installationExists(String factionId, String installationId); + + boolean allowsSupplyHubs(Guild guild); + + int hubSlots(String factionId, String installationId); + + int hubLimit(Guild guild); + + int hubsAtInstallation(String factionId, String installationId); + + double tradePower(Guild guild, String factionId, String installationId); + + boolean isGuildLeader(Guild guild, String actorName); + + boolean isHostCouncil(String factionId, String actorName); + + List council(String factionId); + + String guildLeader(Guild guild); + + boolean hubDormant(Guild guild, SupplyHub hub); + + String installationLabel(String factionId, String installationId); + + HubAgreementFacts LIVE = new LiveHubAgreementFacts(); +} + +final class LiveHubAgreementFacts implements HubAgreementFacts { + @Override + public boolean sameRealm(Guild guild, String factionId) { + if (guild == null || factionId == null) { + return false; + } + Faction guildFaction = guild.getFaction(); + if (guildFaction != null && guildFaction.getId() != null + && guildFaction.getId().equalsIgnoreCase(factionId)) { + return true; + } + Faction other = FactionManager.getByString(factionId); + return other != null && RelationManager.sameRealm(other, guildFaction); + } + + @Override + public boolean hostExists(String factionId) { + return FactionManager.getByString(factionId) != null; + } + + @Override + public boolean hostHasHubTax(String factionId) { + Faction host = FactionManager.getByString(factionId); + return host != null && host.hasFactionRule(Rules.HUB_TAX); + } + + @Override + public int minRate(String factionId) { + HubAgreementService.RateRange range = range(factionId); + return range.canHost() ? range.minPercent() : 0; + } + + @Override + public int maxRate(String factionId) { + HubAgreementService.RateRange range = range(factionId); + return range.canHost() ? range.maxPercent() : 0; + } + + private static HubAgreementService.RateRange range(String factionId) { + return HubAgreementService.allowedRateRange(FactionManager.getByString(factionId)); + } + + @Override + public double maxFee() { + return Cache.supplyHubMaxFee; + } + + @Override + public int offerDays() { + return Cache.supplyHubOfferDays; + } + + @Override + public int agreementDays() { + return Cache.supplyHubAgreementDays; + } + + @Override + public boolean installationExists(String factionId, String installationId) { + return SupplyHubService.installationExists(factionId, installationId); + } + + @Override + public boolean allowsSupplyHubs(Guild guild) { + return SupplyHubService.allowsSupplyHubs(guild); + } + + @Override + public int hubSlots(String factionId, String installationId) { + Installation installation = SupplyHubService.findInstallation(factionId, installationId); + if (installation == null) { + return 0; + } + return InstallationConfigLoader.getHubSlots(installation.getKind(), installation.getLevel()); + } + + @Override + public int hubLimit(Guild guild) { + return SupplyHubService.limit(guild); + } + + @Override + public int hubsAtInstallation(String factionId, String installationId) { + return SupplyHubService.countLoaded(factionId, installationId); + } + + @Override + public double tradePower(Guild guild, String factionId, String installationId) { + Installation installation = SupplyHubService.findInstallation(factionId, installationId); + if (guild == null || installation == null) { + return 0; + } + return SupplyHubCommands.tradeAvailable(guild, installation); + } + + @Override + public boolean isGuildLeader(Guild guild, String actorName) { + if (guild == null || actorName == null || guild.getLeader() == null) { + return false; + } + return guild.getLeader().equalsIgnoreCase(actorName); + } + + @Override + public boolean isHostCouncil(String factionId, String actorName) { + Faction host = FactionManager.getByString(factionId); + return host != null && host.getGovernment() != null && host.getGovernment().isCouncilMember(actorName); + } + + @Override + public List council(String factionId) { + List names = new ArrayList<>(); + Faction faction = FactionManager.getByString(factionId); + if (faction == null) { + return names; + } + if (faction.getLeader() != null && !faction.getLeader().isBlank()) { + names.add(faction.getLeader()); + } + if (faction.getGovernment() != null) { + for (String member : faction.getGovernment().getCouncilMembers()) { + if (member == null || member.isBlank()) { + continue; + } + boolean seen = false; + for (String name : names) { + if (name.equalsIgnoreCase(member)) { + seen = true; + break; + } + } + if (!seen) { + names.add(member); + } + } + } + return names; + } + + @Override + public String guildLeader(Guild guild) { + return guild == null ? null : guild.getLeader(); + } + + @Override + public boolean hubDormant(Guild guild, SupplyHub hub) { + if (guild == null || hub == null) { + return false; + } + Installation installation = SupplyHubService.findInstallation(hub.ownerFactionId(), hub.installationId()); + int slots = installation == null + ? 0 + : InstallationConfigLoader.getHubSlots(installation.getKind(), installation.getLevel()); + boolean allowed = SupplyHubService.hubPermitted(guild, hub.ownerFactionId(), hub.installationId()); + SupplyHubService.HubStanding standing = SupplyHubService.standing( + guild, hub, installation != null, allowed, slots, + SupplyHubService.atInstallation( + hub.ownerFactionId(), hub.installationId(), SupplyHubService.allGuilds())); + return !standing.active(); + } + + @Override + public String installationLabel(String factionId, String installationId) { + Installation installation = SupplyHubService.findInstallation(factionId, installationId); + if (installation == null || installation.getName() == null || installation.getName().isBlank()) { + return installationId == null ? "installation" : installationId; + } + return installation.getName(); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementMessenger.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementMessenger.java new file mode 100644 index 00000000..82184aee --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementMessenger.java @@ -0,0 +1,38 @@ +package net.tfminecraft.simplefactions.guild.hub; + +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +/** Sends agreement notices to players who are online. Offline players are skipped. */ +final class HubAgreementMessenger { + private HubAgreementMessenger() { + } + + static void deliver(List notices) { + if (notices == null || notices.isEmpty()) { + return; + } + for (HubAgreementService.HubNotice notice : notices) { + Player player = online(notice.playerName()); + if (player != null && notice.message() != null) { + player.sendMessage(notice.message()); + } + } + } + + private static Player online(String name) { + if (name == null || name.isBlank()) { + return null; + } + try { + if (Bukkit.getServer() == null) { + return null; + } + return Bukkit.getPlayerExact(name); + } catch (Throwable ignored) { + return null; + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementService.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementService.java new file mode 100644 index 00000000..e1f346d2 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementService.java @@ -0,0 +1,1102 @@ +package net.tfminecraft.simplefactions.guild.hub; + +import java.util.ArrayList; +import java.util.List; + +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.database.HubAgreementData; +import net.tfminecraft.simplefactions.database.HubOfferData; +import net.tfminecraft.simplefactions.enums.Rules; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.hub.SupplyHubService.BuildFailure; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.utils.Formatter; + +/** + * Hub agreement rules. Decision methods do not call Bukkit. Menus call the public + * actions; each one returns a player-facing message. The daily tick is separate. + */ +public final class HubAgreementService { + static final long DAY_MILLIS = 86_400_000L; + + private HubAgreementService() { + } + + public record AgreementResult(boolean succeeded, String message, List notices, boolean builtHub) { + public static AgreementResult ok(String message, List notices, boolean builtHub) { + return new AgreementResult(true, message, notices == null ? List.of() : List.copyOf(notices), builtHub); + } + + public static AgreementResult fail(String message) { + return new AgreementResult(false, message, List.of(), false); + } + } + + public record HubNotice(String playerName, String message) { + } + + public record RateRange(int minPercent, int maxPercent, boolean canHost) { + } + + public record PendingMatter(Kind kind, String guildId, String hostFactionId, String installationId, String message) { + public enum Kind { + OFFER, + EXPIRING + } + } + + public static AgreementResult propose( + Guild guild, String actorName, String hostFactionId, String installationId, int ratePercent, double feeDenars) { + AgreementResult result = propose( + guild, actorName, hostFactionId, installationId, ratePercent, feeDenars, + HubAgreementFacts.LIVE, System.currentTimeMillis()); + HubAgreementMessenger.deliver(result.notices()); + return result; + } + + public static AgreementResult counter( + Guild guild, String actorName, String hostFactionId, String installationId, int ratePercent, double feeDenars) { + AgreementResult result = counter( + guild, actorName, hostFactionId, installationId, ratePercent, feeDenars, + HubAgreementFacts.LIVE, System.currentTimeMillis()); + HubAgreementMessenger.deliver(result.notices()); + return result; + } + + public static AgreementResult accept( + Guild guild, String actorName, String hostFactionId, String installationId) { + AgreementResult result = accept( + guild, actorName, hostFactionId, installationId, HubAgreementFacts.LIVE, System.currentTimeMillis()); + if (result.builtHub()) { + SupplyHubCommands.recalculateTrade(); + } + HubAgreementMessenger.deliver(result.notices()); + return result; + } + + public static AgreementResult decline( + Guild guild, String actorName, String hostFactionId, String installationId) { + AgreementResult result = decline( + guild, actorName, hostFactionId, installationId, HubAgreementFacts.LIVE, System.currentTimeMillis()); + HubAgreementMessenger.deliver(result.notices()); + return result; + } + + public static AgreementResult withdraw( + Guild guild, String actorName, String hostFactionId, String installationId) { + AgreementResult result = withdraw( + guild, actorName, hostFactionId, installationId, HubAgreementFacts.LIVE, System.currentTimeMillis()); + HubAgreementMessenger.deliver(result.notices()); + return result; + } + + public static AgreementResult setAutoRenewal( + Guild guild, String actorName, String hostFactionId, String installationId, boolean enabled) { + AgreementResult result = setAutoRenewal( + guild, actorName, hostFactionId, installationId, enabled, HubAgreementFacts.LIVE); + HubAgreementMessenger.deliver(result.notices()); + return result; + } + + public static RateRange allowedRateRange(Faction host) { + if (host == null || host.getTaxHandler() == null || !host.hasFactionRule(Rules.HUB_TAX)) { + return new RateRange(0, 0, false); + } + int min = (int) Math.round(host.getTaxHandler().getMin(TaxTarget.HUB_TAX)); + int max = (int) Math.round(host.getTaxHandler().getMax(TaxTarget.HUB_TAX)); + if (max < min) { + return new RateRange(0, 0, false); + } + return new RateRange(min, max, true); + } + + public static List pendingFor(String playerName) { + return pendingFor(playerName, SupplyHubService.allGuilds(), HubAgreementFacts.LIVE); + } + + public static String joinSummary(String playerName) { + return summarize(pendingFor(playerName)); + } + + public static List tick(Iterable guilds, long nowMillis) { + boolean[] hubsRemoved = {false}; + List notices = tick(guilds, nowMillis, HubAgreementFacts.LIVE, hubsRemoved); + if (hubsRemoved[0]) { + SupplyHubCommands.recalculateTrade(); + } + HubAgreementMessenger.deliver(notices); + return notices; + } + + public static boolean hasAgreement(Guild guild, String hostFactionId, String installationId) { + return findAgreement(guild, hostFactionId, installationId) != null; + } + + public static HubAgreement findAgreement(Guild guild, String hostFactionId, String installationId) { + if (guild == null || guild.getHubAgreements() == null) { + return null; + } + for (HubAgreement agreement : guild.getHubAgreements()) { + if (samePlace(agreement == null ? null : agreement.hostFactionId(), + agreement == null ? null : agreement.installationId(), hostFactionId, installationId)) { + return agreement; + } + } + return null; + } + + /** The guild removed its hub. The agreement and any offer end with it. */ + public static void onHubRemoved(Guild guild, String hostFactionId, String installationId) { + dropAgreement(guild, hostFactionId, installationId); + dropOffer(guild, hostFactionId, installationId); + } + + static AgreementResult propose( + Guild guild, String actorName, String hostFactionId, String installationId, + int ratePercent, double feeDenars, HubAgreementFacts facts, long nowMillis) { + if (guild == null || facts == null) { + return AgreementResult.fail("§cThat guild does not exist"); + } + if (!facts.hostExists(hostFactionId)) { + return AgreementResult.fail("§cThat faction does not exist"); + } + if (facts.sameRealm(guild, hostFactionId)) { + return AgreementResult.fail("§cA hub in your own realm does not need an agreement"); + } + if (!facts.hostHasHubTax(hostFactionId)) { + return AgreementResult.fail("§cThat faction's economy cannot host a supply hub"); + } + String termsProblem = termsProblem(ratePercent, feeDenars, facts, hostFactionId); + if (termsProblem != null) { + return AgreementResult.fail(termsProblem); + } + if (findOffer(guild, hostFactionId, installationId) != null) { + return AgreementResult.fail("§cThere is already an offer for this installation"); + } + long cents = wholeCents(feeDenars); + HubAgreement existing = findAgreement(guild, hostFactionId, installationId); + if (existing != null) { + if (!facts.isGuildLeader(guild, actorName) && !facts.isHostCouncil(hostFactionId, actorName)) { + return AgreementResult.fail("§cOnly the guild leader or the host's council can offer new terms"); + } + if (!hasHub(guild, hostFactionId, installationId)) { + return AgreementResult.fail("§cThat hub is no longer there"); + } + OfferSide awaiting = facts.isGuildLeader(guild, actorName) ? OfferSide.HOST : OfferSide.GUILD; + HubOffer offer = new HubOffer( + hostFactionId, installationId, ratePercent, cents, awaiting, actorName, nowMillis, OfferKind.NEXT_TERM); + if (!addOffer(guild, offer)) { + return AgreementResult.fail("§cThat offer could not be saved"); + } + return AgreementResult.ok( + "§aTerms offered for the next term: §e" + ratePercent + "%§a and §e" + money(cents) + "§a a day", + tellOtherSide(guild, hostFactionId, awaiting, facts, + "§eNew terms were offered for the hub at §f" + facts.installationLabel(hostFactionId, installationId) + + "§e: §f" + ratePercent + "%§e and §f" + money(cents) + "§e a day"), + false); + } + if (!facts.isGuildLeader(guild, actorName)) { + return AgreementResult.fail("§cOnly the guild leader can propose a hub"); + } + String buildProblem = buildProblem(guild, hostFactionId, installationId, facts, true); + if (buildProblem != null) { + return AgreementResult.fail(buildProblem); + } + HubOffer offer = new HubOffer( + hostFactionId, installationId, ratePercent, cents, OfferSide.HOST, actorName, nowMillis, OfferKind.NEW_HUB); + if (!addOffer(guild, offer)) { + return AgreementResult.fail("§cThat offer could not be saved"); + } + return AgreementResult.ok( + "§aOffer sent: §e" + ratePercent + "%§a and §e" + money(cents) + "§a a day", + tell(facts.council(hostFactionId), + "§e" + guildName(guild) + " §eoffered a hub at §f" + + facts.installationLabel(hostFactionId, installationId) + + "§e: §f" + ratePercent + "%§e and §f" + money(cents) + "§e a day"), + false); + } + + static AgreementResult counter( + Guild guild, String actorName, String hostFactionId, String installationId, + int ratePercent, double feeDenars, HubAgreementFacts facts, long nowMillis) { + HubOffer offer = readyOffer(guild, hostFactionId, installationId, facts, nowMillis); + if (offer == null) { + return missingOrLapsed(guild, hostFactionId, installationId, facts, nowMillis); + } + if (!actsFor(offer.awaiting(), guild, actorName, hostFactionId, facts)) { + return AgreementResult.fail(wrongSide(offer.awaiting())); + } + String termsProblem = termsProblem(ratePercent, feeDenars, facts, hostFactionId); + if (termsProblem != null) { + return AgreementResult.fail(termsProblem); + } + long cents = wholeCents(feeDenars); + OfferSide next = offer.awaiting() == OfferSide.HOST ? OfferSide.GUILD : OfferSide.HOST; + HubOffer updated = new HubOffer( + offer.hostFactionId(), offer.installationId(), ratePercent, cents, next, actorName, nowMillis, offer.kind()); + replaceOffer(guild, offer, updated); + return AgreementResult.ok( + "§aTerms sent back: §e" + ratePercent + "%§a and §e" + money(cents) + "§a a day", + tellOtherSide(guild, hostFactionId, next, facts, + "§eNew hub terms for §f" + facts.installationLabel(hostFactionId, installationId) + + "§e: §f" + ratePercent + "%§e and §f" + money(cents) + "§e a day"), + false); + } + + static AgreementResult accept( + Guild guild, String actorName, String hostFactionId, String installationId, + HubAgreementFacts facts, long nowMillis) { + HubOffer offer = readyOffer(guild, hostFactionId, installationId, facts, nowMillis); + if (offer == null) { + return missingOrLapsed(guild, hostFactionId, installationId, facts, nowMillis); + } + if (!actsFor(offer.awaiting(), guild, actorName, hostFactionId, facts)) { + OfferSide sender = offer.awaiting() == OfferSide.HOST ? OfferSide.GUILD : OfferSide.HOST; + if (actsFor(sender, guild, actorName, hostFactionId, facts)) { + return AgreementResult.fail("§cYou can only accept terms the other side sent"); + } + return AgreementResult.fail(wrongSide(offer.awaiting())); + } + if (!facts.hostHasHubTax(hostFactionId)) { + return AgreementResult.fail("§cThat faction's economy cannot host a supply hub"); + } + String termsProblem = termsProblem(offer.taxRatePercent(), offer.feeCents() / 100.0, facts, hostFactionId); + if (termsProblem != null) { + return AgreementResult.fail(termsProblem); + } + String label = facts.installationLabel(hostFactionId, installationId); + if (offer.kind() == OfferKind.NEXT_TERM) { + HubAgreement agreement = findAgreement(guild, hostFactionId, installationId); + if (agreement == null || !hasHub(guild, hostFactionId, installationId)) { + dropOffer(guild, hostFactionId, installationId); + return AgreementResult.fail("§cThat hub is no longer there"); + } + HubAgreement updated = agreement + .withNextTerms(new HubTerms(offer.taxRatePercent(), offer.feeCents())) + .withRenewal(true, true); + replaceAgreement(guild, agreement, updated); + dropOffer(guild, hostFactionId, installationId); + List notices = new ArrayList<>(); + notices.addAll(tell(List.of(facts.guildLeader(guild)), + "§aThe next term at §f" + label + " §awill be §f" + offer.taxRatePercent() + + "%§a and §f" + money(offer.feeCents()) + "§a a day")); + notices.addAll(tell(facts.council(hostFactionId), + "§a" + guildName(guild) + " §aagreed the next term at §f" + label + + "§a: §f" + offer.taxRatePercent() + "%§a and §f" + money(offer.feeCents()) + "§a a day")); + return AgreementResult.ok( + "§aThose terms will apply on renewal, and auto-renewal is on for both sides", + notices, false); + } + String buildProblem = buildProblem(guild, hostFactionId, installationId, facts, false); + if (buildProblem != null) { + return AgreementResult.fail(buildProblem); + } + List hubs = guild.getSupplyHubs(); + if (hubs == null) { + return AgreementResult.fail("§cThat hub could not be built"); + } + hubs.add(new SupplyHub(hostFactionId, installationId, nowMillis)); + int days = Math.max(1, facts.agreementDays()); + HubAgreement agreement = new HubAgreement( + hostFactionId, installationId, offer.taxRatePercent(), offer.feeCents(), + days, true, true, null, false); + if (!addAgreement(guild, agreement)) { + hubs.remove(hubs.size() - 1); + return AgreementResult.fail("§cThat agreement could not be saved"); + } + dropOffer(guild, hostFactionId, installationId); + List notices = new ArrayList<>(); + notices.addAll(tell(List.of(facts.guildLeader(guild)), + "§aHub agreement started at §f" + label + " §afor §f" + days + "§a days")); + notices.addAll(tell(facts.council(hostFactionId), + "§a" + guildName(guild) + " §abuilt a hub at §f" + label + + "§a for §f" + days + "§a days at §f" + offer.taxRatePercent() + + "%§a and §f" + money(offer.feeCents()) + "§a a day")); + return AgreementResult.ok( + "§aAgreement started for §e" + days + "§a days at §e" + offer.taxRatePercent() + + "%§a and §e" + money(offer.feeCents()) + "§a a day", + notices, true); + } + + static AgreementResult decline( + Guild guild, String actorName, String hostFactionId, String installationId, + HubAgreementFacts facts, long nowMillis) { + HubOffer offer = readyOffer(guild, hostFactionId, installationId, facts, nowMillis); + if (offer == null) { + return missingOrLapsed(guild, hostFactionId, installationId, facts, nowMillis); + } + if (!actsFor(offer.awaiting(), guild, actorName, hostFactionId, facts)) { + return AgreementResult.fail(wrongSide(offer.awaiting())); + } + dropOffer(guild, hostFactionId, installationId); + OfferSide other = offer.awaiting() == OfferSide.HOST ? OfferSide.GUILD : OfferSide.HOST; + return AgreementResult.ok("§cOffer declined", + tellOtherSide(guild, hostFactionId, other, facts, + "§cThe hub offer for §f" + facts.installationLabel(hostFactionId, installationId) + " §cwas declined"), + false); + } + + static AgreementResult withdraw( + Guild guild, String actorName, String hostFactionId, String installationId, + HubAgreementFacts facts, long nowMillis) { + HubOffer offer = readyOffer(guild, hostFactionId, installationId, facts, nowMillis); + if (offer == null) { + return missingOrLapsed(guild, hostFactionId, installationId, facts, nowMillis); + } + OfferSide sender = offer.awaiting() == OfferSide.HOST ? OfferSide.GUILD : OfferSide.HOST; + if (!actsFor(sender, guild, actorName, hostFactionId, facts)) { + return AgreementResult.fail("§cOnly the side that sent the current terms can withdraw them"); + } + dropOffer(guild, hostFactionId, installationId); + return AgreementResult.ok("§aOffer withdrawn", + tellOtherSide(guild, hostFactionId, offer.awaiting(), facts, + "§7The hub offer for §f" + facts.installationLabel(hostFactionId, installationId) + " §7was withdrawn"), + false); + } + + static AgreementResult setAutoRenewal( + Guild guild, String actorName, String hostFactionId, String installationId, + boolean enabled, HubAgreementFacts facts) { + HubAgreement agreement = findAgreement(guild, hostFactionId, installationId); + if (agreement == null) { + return AgreementResult.fail("§cThere is no hub agreement there"); + } + boolean leader = facts.isGuildLeader(guild, actorName); + boolean council = facts.isHostCouncil(hostFactionId, actorName); + if (!leader && !council) { + return AgreementResult.fail("§cOnly the guild leader or the host's council can change renewal"); + } + boolean guildRenews = leader ? enabled : agreement.guildRenews(); + boolean hostRenews = (!leader && council) ? enabled : agreement.hostRenews(); + replaceAgreement(guild, agreement, agreement.withRenewal(guildRenews, hostRenews)); + String whose = leader ? "Your guild's auto-renewal" : "The host's auto-renewal"; + return AgreementResult.ok("§a" + whose + " is now " + (enabled ? "on" : "off"), List.of(), false); + } + + static List pendingFor(String playerName, Iterable guilds, HubAgreementFacts facts) { + List pending = new ArrayList<>(); + if (playerName == null || guilds == null || facts == null) { + return pending; + } + for (Guild guild : guilds) { + if (guild == null) { + continue; + } + boolean leader = facts.isGuildLeader(guild, playerName); + if (guild.getHubOffers() != null) { + for (HubOffer offer : guild.getHubOffers()) { + if (offer == null || !actsFor(offer.awaiting(), guild, playerName, offer.hostFactionId(), facts)) { + continue; + } + pending.add(new PendingMatter( + PendingMatter.Kind.OFFER, guild.getId(), offer.hostFactionId(), offer.installationId(), + "§eHub offer waiting at §f" + facts.installationLabel(offer.hostFactionId(), offer.installationId()) + + "§e: §f" + offer.taxRatePercent() + "%§e and §f" + money(offer.feeCents()) + "§e a day")); + } + } + if (guild.getHubAgreements() == null) { + continue; + } + for (HubAgreement agreement : guild.getHubAgreements()) { + if (agreement == null || agreement.willRenew() || agreement.daysRemaining() <= 0 + || agreement.daysRemaining() > 3) { + continue; + } + boolean council = facts.isHostCouncil(agreement.hostFactionId(), playerName); + if (!leader && !council) { + continue; + } + pending.add(new PendingMatter( + PendingMatter.Kind.EXPIRING, guild.getId(), agreement.hostFactionId(), agreement.installationId(), + "§eHub agreement at §f" + + facts.installationLabel(agreement.hostFactionId(), agreement.installationId()) + + " §eends in §f" + agreement.daysRemaining() + "§e " + + (agreement.daysRemaining() == 1 ? "day" : "days") + " and will not renew")); + } + } + return pending; + } + + static String summarize(List pending) { + int offers = 0; + int expiring = 0; + if (pending != null) { + for (PendingMatter matter : pending) { + if (matter == null) { + continue; + } + if (matter.kind() == PendingMatter.Kind.OFFER) { + offers++; + } else { + expiring++; + } + } + } + if (offers == 0 && expiring == 0) { + return null; + } + StringBuilder text = new StringBuilder("§e"); + if (offers > 0) { + text.append(offers).append(offers == 1 ? " hub offer is waiting on you" : " hub offers are waiting on you"); + } + if (offers > 0 && expiring > 0) { + text.append(". §e"); + } + if (expiring > 0) { + text.append(expiring).append(expiring == 1 + ? " hub agreement ends within 3 days and will not renew" + : " hub agreements end within 3 days and will not renew"); + } + text.append("."); + return text.toString(); + } + + static List tick(Iterable guilds, long nowMillis, HubAgreementFacts facts) { + return tick(guilds, nowMillis, facts, null); + } + + static List tick( + Iterable guilds, long nowMillis, HubAgreementFacts facts, boolean[] hubsRemoved) { + List notices = new ArrayList<>(); + if (guilds == null || facts == null) { + return notices; + } + for (Guild guild : guilds) { + if (guild == null) { + continue; + } + if (tickAgreements(guild, facts, notices) && hubsRemoved != null && hubsRemoved.length > 0) { + hubsRemoved[0] = true; + } + tickOffers(guild, nowMillis, facts); + } + return notices; + } + + static List onTransferred( + Iterable guilds, String fromFactionId, String toFactionId, String installationId, HubAgreementFacts facts) { + List notices = new ArrayList<>(); + if (guilds == null || toFactionId == null || installationId == null || facts == null) { + return notices; + } + for (Guild guild : guilds) { + if (guild == null || guild.getSupplyHubs() == null) { + continue; + } + SupplyHub hub = SupplyHubService.findHub(guild.getSupplyHubs(), fromFactionId, installationId); + if (hub == null) { + continue; + } + if (facts.sameRealm(guild, toFactionId)) { + List hubs = guild.getSupplyHubs(); + int index = hubs.indexOf(hub); + if (index >= 0) { + hubs.set(index, new SupplyHub(toFactionId, hub.installationId(), hub.createdAt())); + } + dropAgreement(guild, fromFactionId, installationId); + dropOffer(guild, fromFactionId, installationId); + continue; + } + guild.getSupplyHubs().remove(hub); + dropAgreement(guild, fromFactionId, installationId); + dropOffer(guild, fromFactionId, installationId); + String label = facts.installationLabel(toFactionId, installationId); + if (label.equals(installationId)) { + label = facts.installationLabel(fromFactionId, installationId); + } + String message = "§cThe supply hub of §f" + guildName(guild) + " §cat §f" + label + + " §cwas removed because the installation moved to another realm"; + notices.addAll(tell(List.of(facts.guildLeader(guild)), message)); + notices.addAll(tell(facts.council(fromFactionId), message)); + notices.addAll(tell(facts.council(toFactionId), message)); + } + return dedupe(notices); + } + + static List onInstallationRemoved( + Iterable guilds, String ownerFactionId, String installationId, HubAgreementFacts facts) { + List notices = new ArrayList<>(); + if (guilds == null) { + return notices; + } + for (Guild guild : guilds) { + if (guild == null) { + continue; + } + boolean hadHub = guild.getSupplyHubs() != null + && SupplyHubService.findHub(guild.getSupplyHubs(), ownerFactionId, installationId) != null; + boolean hadAgreement = findAgreement(guild, ownerFactionId, installationId) != null; + if (guild.getSupplyHubs() != null) { + SupplyHubService.removeInstallation(List.of(guild), ownerFactionId, installationId); + } + dropAgreement(guild, ownerFactionId, installationId); + dropOffer(guild, ownerFactionId, installationId); + if ((hadHub || hadAgreement) && facts != null) { + notices.addAll(tell(List.of(facts.guildLeader(guild)), + "§cYour supply hub at §f" + facts.installationLabel(ownerFactionId, installationId) + + " §cwas removed because the installation is gone")); + } + } + return notices; + } + + /** Foreign hubs saved without an agreement cannot stay. Returns one log line per hub removed. */ + static List removeUnagreedForeignHubs(Iterable guilds, HubAgreementFacts facts) { + List lines = new ArrayList<>(); + if (guilds == null || facts == null) { + return lines; + } + for (Guild guild : guilds) { + if (guild == null || guild.getSupplyHubs() == null) { + continue; + } + List hubs = guild.getSupplyHubs(); + for (int i = hubs.size() - 1; i >= 0; i--) { + SupplyHub hub = hubs.get(i); + if (hub == null) { + continue; + } + if (facts.sameRealm(guild, hub.ownerFactionId()) + || hasAgreement(guild, hub.ownerFactionId(), hub.installationId())) { + continue; + } + hubs.remove(i); + dropOffer(guild, hub.ownerFactionId(), hub.installationId()); + lines.add("Guild " + guildName(guild) + " (" + guild.getId() + + ") lost its supply hub at " + hub.installationId() + + ": a foreign hub needs an agreement"); + } + } + return lines; + } + + static void logRemovedForeignHubs(List lines) { + if (lines == null || lines.isEmpty()) { + return; + } + try { + SimpleFactions plugin = SimpleFactions.getInstance(); + if (plugin == null) { + return; + } + for (String line : lines) { + plugin.getLogger().info(line); + } + } catch (Throwable ignored) { + } + } + + static double feeDenars(Guild guild, SupplyHub hub) { + if (hub == null) { + return 0; + } + HubAgreement agreement = findAgreement(guild, hub.ownerFactionId(), hub.installationId()); + if (agreement == null || agreement.feeCents() <= 0) { + return 0; + } + return agreement.feeCents() / 100.0; + } + + static void endFor(Guild guild, List removed) { + if (guild == null || removed == null) { + return; + } + for (SupplyHub hub : removed) { + if (hub == null) { + continue; + } + dropAgreement(guild, hub.ownerFactionId(), hub.installationId()); + dropOffer(guild, hub.ownerFactionId(), hub.installationId()); + } + } + + static int taxRatePercent(Guild guild, SupplyHub hub) { + HubAgreement agreement = hub == null ? null : findAgreement(guild, hub.ownerFactionId(), hub.installationId()); + return agreement == null ? 0 : agreement.taxRatePercent(); + } + + public static List fromAgreementData(List data) { + List agreements = new ArrayList<>(); + if (data == null) { + return agreements; + } + for (HubAgreementData entry : data) { + HubAgreement agreement = fromAgreementData(entry); + if (agreement != null) { + agreements.add(agreement); + } + } + return agreements; + } + + static HubAgreement fromAgreementData(HubAgreementData data) { + if (data == null || blank(data.faction) || blank(data.installation) + || data.rate == null || data.dailyFee == null || data.daysRemaining == null) { + return null; + } + Long cents = wholeCents(data.dailyFee); + if (cents == null) { + cents = Math.round(data.dailyFee * 100.0); + if (cents < 0) { + return null; + } + } + HubTerms next = null; + if (data.nextRate != null && data.nextFee != null) { + long nextCents = Math.max(0, Math.round(data.nextFee * 100.0)); + next = new HubTerms(data.nextRate, nextCents); + } + return new HubAgreement( + data.faction, data.installation, data.rate, cents, data.daysRemaining, + data.guildRenews == null || data.guildRenews, + data.hostRenews == null || data.hostRenews, + next, Boolean.TRUE.equals(data.dormantNotice)); + } + + public static List toAgreementData(List agreements) { + List data = new ArrayList<>(); + if (agreements == null) { + return data; + } + for (HubAgreement agreement : agreements) { + if (agreement == null || blank(agreement.hostFactionId()) || blank(agreement.installationId())) { + continue; + } + HubAgreementData entry = new HubAgreementData(); + entry.faction = agreement.hostFactionId(); + entry.installation = agreement.installationId(); + entry.rate = agreement.taxRatePercent(); + entry.dailyFee = agreement.feeCents() / 100.0; + entry.daysRemaining = agreement.daysRemaining(); + entry.guildRenews = agreement.guildRenews(); + entry.hostRenews = agreement.hostRenews(); + if (agreement.nextTerms() != null) { + entry.nextRate = agreement.nextTerms().taxRatePercent(); + entry.nextFee = agreement.nextTerms().feeCents() / 100.0; + } + entry.dormantNotice = agreement.dormantNoticeSent(); + data.add(entry); + } + return data; + } + + public static List fromOfferData(List data) { + List offers = new ArrayList<>(); + if (data == null) { + return offers; + } + for (HubOfferData entry : data) { + HubOffer offer = fromOfferData(entry); + if (offer != null) { + offers.add(offer); + } + } + return offers; + } + + static HubOffer fromOfferData(HubOfferData data) { + if (data == null || blank(data.faction) || blank(data.installation) + || data.rate == null || data.dailyFee == null) { + return null; + } + OfferSide awaiting = "guild".equalsIgnoreCase(data.awaiting) ? OfferSide.GUILD : OfferSide.HOST; + OfferKind kind = "next".equalsIgnoreCase(data.kind) ? OfferKind.NEXT_TERM : OfferKind.NEW_HUB; + long cents = Math.max(0, Math.round(data.dailyFee * 100.0)); + long createdAt = data.createdAt == null ? 0L : data.createdAt; + return new HubOffer( + data.faction, data.installation, data.rate, cents, awaiting, data.lastActor, createdAt, kind); + } + + public static List toOfferData(List offers) { + List data = new ArrayList<>(); + if (offers == null) { + return data; + } + for (HubOffer offer : offers) { + if (offer == null || blank(offer.hostFactionId()) || blank(offer.installationId())) { + continue; + } + HubOfferData entry = new HubOfferData(); + entry.faction = offer.hostFactionId(); + entry.installation = offer.installationId(); + entry.rate = offer.taxRatePercent(); + entry.dailyFee = offer.feeCents() / 100.0; + entry.awaiting = offer.awaiting() == OfferSide.GUILD ? "guild" : "host"; + entry.lastActor = offer.lastActor(); + entry.createdAt = offer.createdAt(); + entry.kind = offer.kind() == OfferKind.NEXT_TERM ? "next" : "new"; + data.add(entry); + } + return data; + } + + private static boolean tickAgreements(Guild guild, HubAgreementFacts facts, List notices) { + if (guild.getHubAgreements() == null) { + return false; + } + boolean removed = false; + List kept = new ArrayList<>(); + for (HubAgreement agreement : new ArrayList<>(guild.getHubAgreements())) { + if (agreement == null) { + continue; + } + String hostId = agreement.hostFactionId(); + String installationId = agreement.installationId(); + String label = facts.installationLabel(hostId, installationId); + if (!facts.hostExists(hostId) || !facts.installationExists(hostId, installationId)) { + removed = removeHub(guild, hostId, installationId) || removed; + notices.addAll(tell(List.of(facts.guildLeader(guild)), + "§cYour supply hub at §f" + label + " §cwas removed because the installation is gone")); + continue; + } + if (facts.sameRealm(guild, hostId)) { + continue; + } + int days = agreement.daysRemaining() - 1; + if (days > 0) { + HubAgreement updated = applyDormant(agreement.withDays(days), guild, facts, notices); + kept.add(updated); + if (days <= 3 && !updated.willRenew()) { + String message = "§eThe hub agreement at §f" + label + " §eends in §f" + days + "§e " + + (days == 1 ? "day" : "days") + " and will not renew"; + notices.addAll(tell(List.of(facts.guildLeader(guild)), message)); + notices.addAll(tell(facts.council(hostId), message)); + } + continue; + } + int rate = agreement.nextTerms() == null + ? agreement.taxRatePercent() : agreement.nextTerms().taxRatePercent(); + long fee = agreement.nextTerms() == null + ? agreement.feeCents() : agreement.nextTerms().feeCents(); + boolean inBracket = facts.hostHasHubTax(hostId) + && rate >= facts.minRate(hostId) && rate <= facts.maxRate(hostId); + if (agreement.willRenew() && inBracket) { + HubAgreement renewed = new HubAgreement( + hostId, installationId, rate, fee, Math.max(1, facts.agreementDays()), + true, true, null, agreement.dormantNoticeSent()); + kept.add(applyDormant(renewed, guild, facts, notices)); + String message = "§aThe hub agreement at §f" + label + " §arenewed for §f" + + Math.max(1, facts.agreementDays()) + "§a days"; + notices.addAll(tell(List.of(facts.guildLeader(guild)), message)); + notices.addAll(tell(facts.council(hostId), message)); + } else { + removed = removeHub(guild, hostId, installationId) || removed; + String message = "§cThe hub agreement at §f" + label + " §cended and the hub was removed"; + notices.addAll(tell(List.of(facts.guildLeader(guild)), message)); + notices.addAll(tell(facts.council(hostId), message)); + } + } + replaceAllAgreements(guild, kept); + return removed; + } + + private static HubAgreement applyDormant( + HubAgreement agreement, Guild guild, HubAgreementFacts facts, List notices) { + SupplyHub hub = SupplyHubService.findHub(guild.getSupplyHubs(), agreement.hostFactionId(), agreement.installationId()); + boolean dormant = hub != null && facts.hubDormant(guild, hub); + if (!dormant) { + return agreement.dormantNoticeSent() ? agreement.withDormantNotice(false) : agreement; + } + if (agreement.dormantNoticeSent()) { + return agreement; + } + notices.addAll(tell(List.of(facts.guildLeader(guild)), + "§eYour hub at §f" + facts.installationLabel(agreement.hostFactionId(), agreement.installationId()) + + " §eis dormant. The daily fee is still charged. Remove the hub to stop it")); + return agreement.withDormantNotice(true); + } + + private static void tickOffers(Guild guild, long nowMillis, HubAgreementFacts facts) { + if (guild.getHubOffers() == null) { + return; + } + List kept = new ArrayList<>(); + for (HubOffer offer : guild.getHubOffers()) { + if (offer == null) { + continue; + } + if (lapsed(offer, nowMillis, facts.offerDays()) + || !facts.hostExists(offer.hostFactionId()) + || !facts.installationExists(offer.hostFactionId(), offer.installationId()) + || facts.sameRealm(guild, offer.hostFactionId())) { + continue; + } + boolean hubThere = hasHub(guild, offer.hostFactionId(), offer.installationId()); + if (offer.kind() == OfferKind.NEXT_TERM + && (!hubThere || findAgreement(guild, offer.hostFactionId(), offer.installationId()) == null)) { + continue; + } + if (offer.kind() == OfferKind.NEW_HUB && hubThere) { + continue; + } + kept.add(offer); + } + replaceAllOffers(guild, kept); + } + + private static String buildProblem( + Guild guild, String hostFactionId, String installationId, HubAgreementFacts facts, boolean proposing) { + if (!facts.installationExists(hostFactionId, installationId)) { + return "§cThat installation does not exist"; + } + if (!facts.allowsSupplyHubs(guild)) { + return SupplyHubService.buildFailureMessage(BuildFailure.ECONOMY_DISALLOWS, null, facts.hubLimit(guild)); + } + int hubs = guild.getSupplyHubs() == null ? 0 : guild.getSupplyHubs().size(); + BuildFailure failure = SupplyHubService.checkBuild( + facts.hubSlots(hostFactionId, installationId), + hasHub(guild, hostFactionId, installationId), + hubs, + facts.hubLimit(guild), + facts.hubsAtInstallation(hostFactionId, installationId), + facts.tradePower(guild, hostFactionId, installationId), + true); + if (failure == null) { + return null; + } + if (proposing && failure == BuildFailure.NO_AGREEMENT) { + return null; + } + return SupplyHubService.buildFailureMessage(failure, "installation", facts.hubLimit(guild)); + } + + private static String termsProblem(int ratePercent, double feeDenars, HubAgreementFacts facts, String hostFactionId) { + int min = facts.minRate(hostFactionId); + int max = facts.maxRate(hostFactionId); + if (ratePercent < min || ratePercent > max) { + return "§cThat rate is outside the host's range (§e" + min + "-" + max + "%§c)"; + } + Long cents = wholeCents(feeDenars); + if (cents == null) { + return "§cThe daily fee must be at least zero, in whole cents, and at most §e" + + Formatter.formatMoney(facts.maxFee()) + "§c"; + } + long maxCents = Math.round(Math.max(0, facts.maxFee()) * 100.0); + if (cents > maxCents) { + return "§cThe daily fee must be at least zero, in whole cents, and at most §e" + + Formatter.formatMoney(facts.maxFee()) + "§c"; + } + return null; + } + + private static AgreementResult missingOrLapsed( + Guild guild, String hostFactionId, String installationId, HubAgreementFacts facts, long nowMillis) { + HubOffer offer = findOffer(guild, hostFactionId, installationId); + if (offer != null && lapsed(offer, nowMillis, facts.offerDays())) { + dropOffer(guild, hostFactionId, installationId); + return AgreementResult.fail("§cThat offer has lapsed"); + } + return AgreementResult.fail("§cThere is no offer for that installation"); + } + + private static HubOffer readyOffer( + Guild guild, String hostFactionId, String installationId, HubAgreementFacts facts, long nowMillis) { + HubOffer offer = findOffer(guild, hostFactionId, installationId); + if (offer == null) { + return null; + } + if (lapsed(offer, nowMillis, facts.offerDays())) { + return null; + } + return offer; + } + + static boolean lapsed(HubOffer offer, long nowMillis, int offerDays) { + if (offer == null) { + return true; + } + long limit = Math.max(1, offerDays) * DAY_MILLIS; + return nowMillis - offer.createdAt() >= limit; + } + + private static Long wholeCents(double fee) { + if (Double.isNaN(fee) || Double.isInfinite(fee) || fee < 0) { + return null; + } + double scaled = fee * 100.0; + long cents = Math.round(scaled); + if (Math.abs(scaled - cents) > 0.0001d) { + return null; + } + return cents; + } + + private static boolean actsFor( + OfferSide side, Guild guild, String actorName, String hostFactionId, HubAgreementFacts facts) { + if (side == OfferSide.GUILD) { + return facts.isGuildLeader(guild, actorName); + } + return facts.isHostCouncil(hostFactionId, actorName); + } + + private static String wrongSide(OfferSide awaiting) { + if (awaiting == OfferSide.GUILD) { + return "§cOnly the guild leader can answer this offer"; + } + return "§cOnly the host's council can answer this offer"; + } + + private static List tellOtherSide( + Guild guild, String hostFactionId, OfferSide side, HubAgreementFacts facts, String message) { + if (side == OfferSide.GUILD) { + return tell(List.of(facts.guildLeader(guild)), message); + } + return tell(facts.council(hostFactionId), message); + } + + private static List tell(List players, String message) { + List notices = new ArrayList<>(); + if (players == null || message == null) { + return notices; + } + for (String player : players) { + if (player == null || player.isBlank()) { + continue; + } + notices.add(new HubNotice(player, message)); + } + return notices; + } + + private static List dedupe(List notices) { + List unique = new ArrayList<>(); + for (HubNotice notice : notices) { + if (notice == null || notice.playerName() == null) { + continue; + } + boolean seen = false; + for (HubNotice kept : unique) { + if (kept.playerName().equalsIgnoreCase(notice.playerName()) + && kept.message().equals(notice.message())) { + seen = true; + break; + } + } + if (!seen) { + unique.add(notice); + } + } + return unique; + } + + private static boolean hasHub(Guild guild, String hostFactionId, String installationId) { + return guild != null && SupplyHubService.hasHub(guild.getSupplyHubs(), hostFactionId, installationId); + } + + private static boolean removeHub(Guild guild, String hostFactionId, String installationId) { + if (guild == null || guild.getSupplyHubs() == null) { + return false; + } + SupplyHub hub = SupplyHubService.findHub(guild.getSupplyHubs(), hostFactionId, installationId); + if (hub == null) { + return false; + } + guild.getSupplyHubs().remove(hub); + return true; + } + + private static HubOffer findOffer(Guild guild, String hostFactionId, String installationId) { + if (guild == null || guild.getHubOffers() == null) { + return null; + } + for (HubOffer offer : guild.getHubOffers()) { + if (offer != null && samePlace(offer.hostFactionId(), offer.installationId(), hostFactionId, installationId)) { + return offer; + } + } + return null; + } + + private static boolean addOffer(Guild guild, HubOffer offer) { + if (guild.getHubOffers() == null) { + return false; + } + guild.getHubOffers().add(offer); + return true; + } + + private static boolean addAgreement(Guild guild, HubAgreement agreement) { + if (guild.getHubAgreements() == null) { + return false; + } + guild.getHubAgreements().add(agreement); + return true; + } + + private static void replaceOffer(Guild guild, HubOffer previous, HubOffer updated) { + List offers = guild.getHubOffers(); + if (offers == null) { + return; + } + int index = offers.indexOf(previous); + if (index >= 0) { + offers.set(index, updated); + } + } + + private static void replaceAgreement(Guild guild, HubAgreement previous, HubAgreement updated) { + List agreements = guild.getHubAgreements(); + if (agreements == null) { + return; + } + int index = agreements.indexOf(previous); + if (index >= 0) { + agreements.set(index, updated); + } + } + + private static void replaceAllAgreements(Guild guild, List agreements) { + List live = guild.getHubAgreements(); + if (live == null) { + return; + } + live.clear(); + live.addAll(agreements); + } + + private static void replaceAllOffers(Guild guild, List offers) { + List live = guild.getHubOffers(); + if (live == null) { + return; + } + live.clear(); + live.addAll(offers); + } + + private static void dropAgreement(Guild guild, String hostFactionId, String installationId) { + if (guild == null || guild.getHubAgreements() == null) { + return; + } + guild.getHubAgreements().removeIf(agreement -> agreement != null + && samePlace(agreement.hostFactionId(), agreement.installationId(), hostFactionId, installationId)); + } + + private static void dropOffer(Guild guild, String hostFactionId, String installationId) { + if (guild == null || guild.getHubOffers() == null) { + return; + } + guild.getHubOffers().removeIf(offer -> offer != null + && samePlace(offer.hostFactionId(), offer.installationId(), hostFactionId, installationId)); + } + + private static boolean samePlace(String hostId, String installationId, String otherHost, String otherInstallation) { + return hostId != null && installationId != null && otherHost != null && otherInstallation != null + && hostId.equalsIgnoreCase(otherHost) && installationId.equalsIgnoreCase(otherInstallation); + } + + private static boolean blank(String value) { + return value == null || value.isBlank(); + } + + private static String guildName(Guild guild) { + if (guild == null || guild.getName() == null || guild.getName().isBlank()) { + return guild == null || guild.getId() == null ? "A guild" : guild.getId(); + } + return guild.getName(); + } + + private static String money(long cents) { + return Formatter.formatMoney(cents / 100.0); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubNetwork.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubNetwork.java index c1ac1986..f86a8d0e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubNetwork.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubNetwork.java @@ -204,19 +204,16 @@ static List linksBetween(List sites, ProvinceManager provinc /** Installations of the guild's hubs that are active right now. */ private static List activeSites(Guild guild, List guilds) { List sites = new ArrayList<>(); - String guildFactionId = guild.getFaction() == null ? null : guild.getFaction().getId(); for (SupplyHub hub : guild.getSupplyHubs()) { Installation installation = SupplyHubService.findInstallation(hub.ownerFactionId(), hub.installationId()); if (installation == null) { continue; } - Faction owner = FactionManager.getByString(hub.ownerFactionId()); - boolean permit = owner != null && owner.hasHubPermit(guild.getId()); HubStanding standing = SupplyHubService.standing( guild, hub, true, - SupplyHubService.ownerAllows(guildFactionId, hub.ownerFactionId(), permit), + SupplyHubService.hubPermitted(guild, hub.ownerFactionId(), hub.installationId()), InstallationConfigLoader.getHubSlots(installation.getKind(), installation.getLevel()), SupplyHubService.atInstallation(hub.ownerFactionId(), hub.installationId(), guilds)); if (standing.active()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubOffer.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubOffer.java new file mode 100644 index 00000000..6ff99505 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubOffer.java @@ -0,0 +1,16 @@ +package net.tfminecraft.simplefactions.guild.hub; + +/** + * An open proposal for a new hub or for the next term. + * {@code createdAt} is the epoch millis of the last move. The offer lapses from that moment. + */ +public record HubOffer( + String hostFactionId, + String installationId, + int taxRatePercent, + long feeCents, + OfferSide awaiting, + String lastActor, + long createdAt, + OfferKind kind) { +} diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxBreakdown.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxBreakdown.java index c9ac4065..476d0af0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxBreakdown.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxBreakdown.java @@ -1,11 +1,8 @@ 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; @@ -45,19 +42,7 @@ public double getTaxableIncome(Faction host) { } 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; + return taxesByFaction; } public double getTax(Faction host) { diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxService.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxService.java index 8d5ef87b..78fffc67 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxService.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTaxService.java @@ -6,7 +6,6 @@ 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; @@ -46,8 +45,7 @@ public static HubTaxBreakdown assess(ProvinceManager provinces, Guild guild, Lis if (installation == null || host == null) { continue; } - boolean allowed = SupplyHubService.ownerAllows( - guild.getFaction().getId(), host.getId(), host.hasHubPermit(guild.getId())); + boolean allowed = SupplyHubService.hubPermitted(guild, host.getId(), installation.getId()); if (!SupplyHubService.standing( guild, hub, true, allowed, InstallationConfigLoader.getHubSlots(installation.getKind(), installation.getLevel()), @@ -73,7 +71,7 @@ public static HubTaxBreakdown assess(ProvinceManager provinces, Guild guild, Lis if (RelationManager.sameRealm(host, guild.getFaction())) { exemptHosts.add(host); } else { - tax = income * host.getTaxRate(TaxTarget.HUB_TAX, null, true) / 100.0; + tax = income * HubAgreementService.taxRatePercent(guild, entry.getKey()) / 100.0; taxesByFaction.merge(host, tax, Double::sum); } assessments.put(entry.getKey(), new Assessment(income, tax)); diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTerms.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTerms.java new file mode 100644 index 00000000..70d53223 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubTerms.java @@ -0,0 +1,5 @@ +package net.tfminecraft.simplefactions.guild.hub; + +/** Rate and fee agreed for the term that follows the current one. */ +public record HubTerms(int taxRatePercent, long feeCents) { +} diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/OfferKind.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/OfferKind.java new file mode 100644 index 00000000..1767b30f --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/OfferKind.java @@ -0,0 +1,7 @@ +package net.tfminecraft.simplefactions.guild.hub; + +/** A new hub, or the next term of a hub that already exists. */ +public enum OfferKind { + NEW_HUB, + NEXT_TERM +} diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/OfferSide.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/OfferSide.java new file mode 100644 index 00000000..7e6cbe92 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/OfferSide.java @@ -0,0 +1,7 @@ +package net.tfminecraft.simplefactions.guild.hub; + +/** Which side has to answer the current offer. */ +public enum OfferSide { + GUILD, + HOST +} 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 ec73ba8b..90f43150 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubCommands.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubCommands.java @@ -5,7 +5,6 @@ import org.bukkit.Bukkit; import org.bukkit.Location; -import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import net.tfminecraft.simplefactions.Cache; @@ -61,50 +60,34 @@ public static boolean guild(Player player, String[] args) { return true; } - public static boolean permit(Player player, String[] args) { - if (!Cache.requireProvinces(player)) { - return true; - } - Faction faction = FactionManager.getByLeader(player.getName()); - if (faction == null) { - player.sendMessage("§cYou need to be a faction leader to grant hub permits"); - return true; - } - if (args.length < 2) { - listPermits(player, faction); - return true; - } - Guild target = FactionManager.getGuildByString(args[1]); - if (target == null) { - player.sendMessage("§cNo guild by the id " + args[1]); - return true; - } - if (target.getFaction() != null - && target.getFaction().getId() != null - && target.getFaction().getId().equalsIgnoreCase(faction.getId())) { - player.sendMessage("§cYour faction's own guilds do not need a hub permit"); - return true; - } - boolean granted = faction.toggleHubPermit(target.getId()); - if (granted) { - player.sendMessage("§aGranted §f" + target.getName() + " §aa hub permit"); - } else { - player.sendMessage("§cRemoved the hub permit for §f" + target.getName()); - } - recalculateTrade(); - return true; - } - public static void notifyRemoved(Guild guild, List removed) { if (guild == null || removed == null || removed.isEmpty()) { return; } Player leader = online(guild.getLeader()); - if (leader == null) { - return; - } for (SupplyHub hub : removed) { - leader.sendMessage(SupplyHubService.removalMessage(label(hub))); + if (leader != null) { + leader.sendMessage(SupplyHubService.removalMessage(label(hub))); + } + Faction host = FactionManager.getByString(hub.ownerFactionId()); + if (host == null || host.getGovernment() == null) { + continue; + } + String message = "§c" + (guild.getName() == null ? "A guild" : guild.getName()) + + "'s supply hub at §f" + label(hub) + " §cwas removed §7(they could not pay)"; + java.util.LinkedHashSet council = new java.util.LinkedHashSet<>(); + if (host.getLeader() != null) { + council.add(host.getLeader()); + } + if (host.getGovernment().getCouncilMembers() != null) { + council.addAll(host.getGovernment().getCouncilMembers()); + } + for (String name : council) { + Player member = online(name); + if (member != null) { + member.sendMessage(message); + } + } } } @@ -131,28 +114,6 @@ public static List completeGuild(Player player, String[] args) { return completions; } - public static List completePermits(CommandSender sender, String[] args) { - List completions = new ArrayList<>(); - if (!(sender instanceof Player player) || !Cache.provincesEnabled) { - return completions; - } - if (FactionManager.getByLeader(player.getName()) == null) { - return completions; - } - if (args.length != 2) { - return completions; - } - for (Guild guild : FactionManager.getAllGuilds()) { - if (guild == null || guild.getId() == null) { - continue; - } - if (!completions.contains(guild.getId())) { - completions.add(guild.getId()); - } - } - return completions; - } - public static List completeRemove(List hubs) { List completions = new ArrayList<>(); if (hubs == null) { @@ -222,9 +183,6 @@ private static boolean build(Player player) { capitalInstallation, capitalOwner == null ? null : capitalOwner.getName())); return true; } - String guildFactionId = guild.getFaction() == null ? null : guild.getFaction().getId(); - Faction owner = FactionManager.getByString(chosen.ownerFactionId()); - boolean permit = owner != null && owner.hasHubPermit(guild.getId()); BuildFailure failure = SupplyHubService.checkBuild( chosen.hubSlots(), SupplyHubService.hasHub(guild.getSupplyHubs(), chosen.ownerFactionId(), installation.getId()), @@ -234,7 +192,7 @@ private static boolean build(Player player) { Math.max( tradeInProvince(installation.getProvince(), guild.getId()), reachableTrade(guild, installation)), - SupplyHubService.ownerAllows(guildFactionId, chosen.ownerFactionId(), permit)); + SupplyHubService.hubPermitted(guild, chosen.ownerFactionId(), installation.getId())); if (failure != null) { player.sendMessage(SupplyHubService.buildFailureMessage( failure, installation.getKind().getDisplayName(), SupplyHubService.limit(guild))); @@ -295,15 +253,13 @@ private static Installation capitalPlacement(Guild guild) { if (faction == null || faction.getInstallationHandler() == null) { continue; } - boolean permitted = faction.hasHubPermit(guild.getId()); - if (!SupplyHubService.ownerAllows( - guild.getFaction() == null ? null : guild.getFaction().getId(), faction.getId(), permitted)) { - continue; - } for (Installation installation : faction.getInstallationHandler().getAll()) { if (installation == null || installation.getProvince() != guild.getCapital()) { continue; } + if (!SupplyHubService.hubPermitted(guild, faction.getId(), installation.getId())) { + continue; + } int slots = InstallationConfigLoader.getHubSlots(installation.getKind(), installation.getLevel()); if (slots <= SupplyHubService.countLoaded(faction.getId(), installation.getId())) continue; if (SupplyHubService.hasHub(guild.getSupplyHubs(), faction.getId(), installation.getId())) continue; @@ -384,36 +340,17 @@ private static boolean remove(Player player, String argument) { } String label = label(match.hub()); guild.getSupplyHubs().remove(match.hub()); + HubAgreementService.onHubRemoved(guild, match.hub().ownerFactionId(), match.hub().installationId()); player.sendMessage("§aRemoved the supply hub at §f" + label); recalculateTrade(); return true; } - private static void listPermits(Player player, Faction faction) { - List permits = faction.getHubPermits(); - if (permits.isEmpty()) { - player.sendMessage("§7No hub permits"); - return; - } - player.sendMessage("§eHub permits"); - for (String guildId : permits) { - Guild guild = FactionManager.getGuildByString(guildId); - if (guild == null) { - player.sendMessage("§7- §f" + guildId); - } else { - player.sendMessage("§7- §f" + guild.getName() + " §7(" + guild.getId() + ")"); - } - } - } - private static String statusOf(Guild guild, SupplyHub hub, Installation installation) { return SupplyHubService.statusText(standingOf(guild, hub, installation)); } private static HubStanding standingOf(Guild guild, SupplyHub hub, Installation installation) { - String guildFactionId = guild.getFaction() == null ? null : guild.getFaction().getId(); - Faction owner = FactionManager.getByString(hub.ownerFactionId()); - boolean permit = owner != null && owner.hasHubPermit(guild.getId()); int slots = 0; if (installation != null) { slots = InstallationConfigLoader.getHubSlots(installation.getKind(), installation.getLevel()); @@ -423,7 +360,7 @@ private static HubStanding standingOf(Guild guild, SupplyHub hub, Installation i HubStanding standing = SupplyHubService.standing( guild, hub, installation != null, - SupplyHubService.ownerAllows(guildFactionId, hub.ownerFactionId(), permit), + SupplyHubService.hubPermitted(guild, hub.ownerFactionId(), hub.installationId()), slots, atInstallation); return standing; @@ -472,6 +409,15 @@ private static PlacedCandidate placed(List covering, SupplyHubS return SupplyHubService.nearest(covering); } + static double tradeAvailable(Guild guild, Installation installation) { + if (guild == null || installation == null) { + return 0; + } + return Math.max( + tradeInProvince(installation.getProvince(), guild.getId()), + reachableTrade(guild, installation)); + } + /** Trade power the guild's existing hubs would deliver to a hub at this installation. */ private static double reachableTrade(Guild guild, Installation installation) { SimpleFactions plugin = SimpleFactions.getInstance(); 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 d430e752..3692be62 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubService.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubService.java @@ -17,6 +17,7 @@ import net.tfminecraft.simplefactions.installation.Installation; import net.tfminecraft.simplefactions.installation.InstallationKind; import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.RelationManager; import net.tfminecraft.simplefactions.map.provinces.Province; import net.tfminecraft.simplefactions.map.provinces.ProvinceDataEntry; import net.tfminecraft.simplefactions.objects.Bank; @@ -43,13 +44,13 @@ public enum BuildFailure { HUB_LIMIT, NO_FREE_SLOT, NO_TRADE, - NO_PERMIT + NO_AGREEMENT } public enum DormantReason { ECONOMY_DISALLOWS, INSTALLATION_GONE, - NO_PERMIT, + NO_AGREEMENT, BEYOND_HUB_SLOTS, BEYOND_GUILD_LIMIT } @@ -75,6 +76,12 @@ public record HubCandidate( 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; + double maxFee = config == null ? 500.0 : config.getDouble("supply-hubs.max-fee", 500.0); + Cache.supplyHubMaxFee = Double.isFinite(maxFee) ? Math.max(0, maxFee) : 500.0; + int offerDays = config == null ? 7 : config.getInt("supply-hubs.offer-days", 7); + Cache.supplyHubOfferDays = Math.max(1, offerDays); + int agreementDays = config == null ? 14 : config.getInt("supply-hubs.agreement-days", 14); + Cache.supplyHubAgreementDays = Math.max(1, agreementDays); } public static int limit(Guild guild) { @@ -158,7 +165,7 @@ public static BuildFailure checkBuild( return BuildFailure.NO_TRADE; } if (!permitted) { - return BuildFailure.NO_PERMIT; + return BuildFailure.NO_AGREEMENT; } return null; } @@ -178,45 +185,28 @@ public static String buildFailureMessage(BuildFailure failure, String kindDispla return "§cThis installation has no free hub slot"; case NO_TRADE: return "§cYour guild has no trade power in this province, and none of its hubs can reach it"; - case NO_PERMIT: - return "§cThe faction that owns this installation has not granted your guild a hub permit"; + case NO_AGREEMENT: + return "§cA supply hub in another realm needs an agreement"; default: return "§cYou cannot build a supply hub here"; } } - public static boolean ownerAllows(String guildFactionId, String ownerFactionId, boolean permit) { - if (guildFactionId != null && guildFactionId.equalsIgnoreCase(ownerFactionId)) { - return true; - } - return permit; - } - - public static boolean hasPermit(Iterable permits, String guildId) { - if (permits == null || guildId == null) { +/** Own realm, or a hub agreement with the installation's owner. */ + public static boolean hubPermitted(Guild guild, String ownerFactionId, String installationId) { + if (guild == null || ownerFactionId == null || installationId == null) { return false; } - for (String id : permits) { - if (guildId.equalsIgnoreCase(id)) { - return true; - } - } - return false; - } - - /** @return true when the guild is permitted after the toggle */ - public static boolean togglePermit(List permits, String guildId) { - if (permits == null || guildId == null || guildId.isBlank()) { - return false; + Faction guildFaction = guild.getFaction(); + if (guildFaction != null && guildFaction.getId() != null + && guildFaction.getId().equalsIgnoreCase(ownerFactionId)) { + return true; } - for (int i = 0; i < permits.size(); i++) { - if (guildId.equalsIgnoreCase(permits.get(i))) { - permits.remove(i); - return false; - } + Faction owner = FactionManager.getByString(ownerFactionId); + if (owner != null && RelationManager.sameRealm(owner, guildFaction)) { + return true; } - permits.add(guildId); - return true; + return HubAgreementService.hasAgreement(guild, ownerFactionId, installationId); } public static boolean allowsSupplyHubs(Guild guild) { @@ -255,7 +245,7 @@ public static HubStanding standing( return new HubStanding(false, DormantReason.INSTALLATION_GONE); } if (!allowed) { - return new HubStanding(false, DormantReason.NO_PERMIT); + return new HubStanding(false, DormantReason.NO_AGREEMENT); } int index = indexOfHub(atInstallationOldestFirst, hub); if (index < 0 || index >= hubSlots) { @@ -281,8 +271,8 @@ public static String dormantReasonText(DormantReason reason) { return "your faction's economy does not allow supply hubs"; case INSTALLATION_GONE: return "its installation no longer exists"; - case NO_PERMIT: - return "the owning faction has not granted a hub permit"; + case NO_AGREEMENT: + return "there is no hub agreement"; case BEYOND_HUB_SLOTS: return "it is beyond this installation's hub slots"; case BEYOND_GUILD_LIMIT: @@ -323,7 +313,28 @@ public static List shedUnpaid(List hubs, double wealth, do } List ordered = oldestFirst(hubs); int guard = 0; - while (!ordered.isEmpty() && totalUpkeep(ordered.size(), perHub) > wealth && guard++ < 10000) { + while (!ordered.isEmpty() && shedCost(ordered, perHub, null) > wealth && guard++ < 10000) { + SupplyHub newest = ordered.remove(ordered.size() - 1); + if (!hubs.remove(newest)) { + break; + } + removed.add(newest); + } + return removed; + } + + /** + * Same as {@link #shedUnpaid(List, double, double)}, counting each hub's agreement fee as well. + */ + public static List shedUnpaid( + List hubs, double wealth, double perHub, java.util.function.ToDoubleFunction feeOf) { + List removed = new ArrayList<>(); + if (hubs == null || hubs.isEmpty()) { + return removed; + } + List ordered = oldestFirst(hubs); + int guard = 0; + while (!ordered.isEmpty() && shedCost(ordered, perHub, feeOf) > wealth && guard++ < 10000) { SupplyHub newest = ordered.remove(ordered.size() - 1); if (!hubs.remove(newest)) { break; @@ -333,6 +344,18 @@ public static List shedUnpaid(List hubs, double wealth, do return removed; } + private static double shedCost( + List ordered, double perHub, java.util.function.ToDoubleFunction feeOf) { + double total = totalUpkeep(ordered.size(), perHub); + if (feeOf == null) { + return total; + } + for (SupplyHub hub : ordered) { + total += Math.max(0, feeOf.applyAsDouble(hub)); + } + return total; + } + public static List shedUnpaid(Guild guild) { if (guild == null || guild.getSupplyHubs() == null) { return List.of(); @@ -342,7 +365,10 @@ public static List shedUnpaid(Guild guild) { if (bank != null && bank.getWealth() != null) { wealth = bank.getWealth(); } - return shedUnpaid(guild.getSupplyHubs(), wealth, upkeepPerHub(guild)); + List removed = shedUnpaid( + guild.getSupplyHubs(), wealth, upkeepPerHub(guild), hub -> HubAgreementService.feeDenars(guild, hub)); + HubAgreementService.endFor(guild, removed); + return removed; } public static boolean hasHub(List guildHubs, String ownerFactionId, String installationId) { @@ -456,12 +482,23 @@ public static void dropMissingLoaded() { dropMissing(allGuilds(), SupplyHubService::installationExists); } + /** + * Called once relations are loaded. Before that, a vassal hub at its overlord's + * installation looks foreign and would be deleted. + */ + public static void dropUnagreedForeignLoaded() { + HubAgreementService.logRemovedForeignHubs( + HubAgreementService.removeUnagreedForeignHubs(allGuilds(), HubAgreementFacts.LIVE)); + } + public static void onInstallationRemoved(String ownerFactionId, String installationId) { - removeInstallation(allGuilds(), ownerFactionId, installationId); + HubAgreementMessenger.deliver(HubAgreementService.onInstallationRemoved( + allGuilds(), ownerFactionId, installationId, HubAgreementFacts.LIVE)); } public static void onInstallationTransferred(String fromFactionId, String toFactionId, String installationId) { - retarget(allGuilds(), fromFactionId, toFactionId, installationId); + HubAgreementMessenger.deliver(HubAgreementService.onTransferred( + allGuilds(), fromFactionId, toFactionId, installationId, HubAgreementFacts.LIVE)); } public static boolean installationExists(String ownerFactionId, String installationId) { 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 d57b03e8..f5fb7834 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Cashflow.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Cashflow.java @@ -14,6 +14,8 @@ public enum Cashflow { TARIFFS("#5cc46aTariffs", false, true), HUB_TAX_PAYMENTS("#5cc46aHub Tax", false, false), HUB_TAX("#5cc46aHub Tax", false, true), + HUB_FEE_PAYMENTS("#5cc46aHub Fee", false, false), + HUB_FEE("#5cc46aHub Fee", 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 f40cb21f..bc5e4b79 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewContext.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewContext.java @@ -222,10 +222,6 @@ 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; } @@ -236,6 +232,9 @@ private boolean replaces(TaxHandler handler, TaxTarget query, String id) { } TaxTarget family = family(taxTarget); TaxTarget queryFamily = family(query); + if (family == TaxTarget.HUB_TAX || queryFamily == TaxTarget.HUB_TAX) { + return false; + } if (family != queryFamily) { 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 483a5677..15602a90 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java @@ -243,6 +243,18 @@ public double getIncome(Cashflow cashflow) { } break; } + case HUB_FEE: + if (!guild.isBase()) { + return 0; + } + amount = getTotalHubFeeEarned(); + break; + case HUB_FEE_PAYMENTS: { + for (double fee : getPayableHubFees().values()) { + amount -= fee; + } + break; + } case TARIFF_PAYMENTS: { TradeBreakdown tariffs = guild.getTradeBreakdown(); amount = tariffs == null ? 0 : -tariffs.getTariffs(); @@ -448,6 +460,56 @@ public Map getPayableHubTaxes() { return payable; } + public double getTotalHubFeeEarned() { + if (skipsMoneyMovement()) { + return 0; + } + double total = 0; + for (Guild payer : FactionManager.getAllGuilds()) { + if (payer.getLedger() == null) { + continue; + } + total += payer.getLedger().getPayableHubFees().getOrDefault(guild.getFaction(), 0.0); + } + return total; + } + + /** Agreement fees this guild will pay today, by host. Empty when either side moves no money. */ + public Map getPayableHubFees() { + if (guild.getFaction() == null || skipsMoneyMovement() || guild.getHubAgreements() == null) { + return Map.of(); + } + IncomePreviewContext context = IncomePreviewContext.current(); + if (context != null && context.previewsLaw(guild.getFaction()) + && !context.allowsHubRule(guild.getFaction(), Rules.SUPPLY_HUBS)) { + return Map.of(); + } + Map payable = new HashMap<>(); + for (net.tfminecraft.simplefactions.guild.hub.HubAgreement agreement : guild.getHubAgreements()) { + if (agreement == null || agreement.feeCents() <= 0) { + continue; + } + Faction host = FactionManager.getByString(agreement.hostFactionId()); + if (host == null || RelationManager.sameRealm(host, guild.getFaction())) { + continue; + } + Guild receiver = mainGuild(host); + if (agreement.feeCents() > 0 && receiver != null && receiver.getLedger() != null + && !receiver.getLedger().skipsMoneyMovement()) { + payable.merge(host, agreement.feeCents() / 100.0, Double::sum); + } + } + return payable; + } + + /** The host's capital, if it already exists. A missing capital receives nothing. */ + private static Guild mainGuild(Faction host) { + if (host == null || host.getGuildHandler() == null) { + return null; + } + return host.getGuildHandler().getGuild(host.getId()); + } + public List> getCitizenTaxEntriesDescending() { return citizenTaxes.entrySet().stream() .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) @@ -539,6 +601,7 @@ public double getNetIncome() { case CITIZENS: case TARIFFS: case HUB_TAX: + case HUB_FEE: case GAMBLING: case VEHICLE_FEES: case GUILDS: @@ -568,6 +631,7 @@ public double getNetIncome() { case TRIBUTE_PAYMENTS: case TARIFF_PAYMENTS: case HUB_TAX_PAYMENTS: + case HUB_FEE_PAYMENTS: case DIVIDEND_PAYMENT: case DIVIDEND_PAYOUT: case WAR_REPARATIONS_PAYMENT: @@ -619,6 +683,7 @@ public double getDividendBase() { case CITIZENS: case TARIFFS: case HUB_TAX: + case HUB_FEE: case GAMBLING: case VEHICLE_FEES: case GUILDS: @@ -645,6 +710,7 @@ public double getDividendBase() { case TRIBUTE_PAYMENTS: case TARIFF_PAYMENTS: case HUB_TAX_PAYMENTS: + case HUB_FEE_PAYMENTS: case WAR_REPARATIONS_PAYMENT: case LOAN_PAYMENTS: case INTEREST_PAYMENTS: @@ -912,6 +978,14 @@ public static Map>> collect recordHistory(out, payer, LedgerHistory.Source.HUB_TAX_PAYMENTS, entry.getKey().getName(), entry.getValue()); } + for (Map.Entry entry : ledger.getPayableHubFees().entrySet()) { + Guild receiver = entry.getKey().getGuildHandler() == null + ? null : entry.getKey().getGuildHandler().getGuild(entry.getKey().getId()); + recordHistory(out, receiver, LedgerHistory.Source.HUB_FEE, + f.getName(), entry.getValue()); + recordHistory(out, payer, LedgerHistory.Source.HUB_FEE_PAYMENTS, + entry.getKey().getName(), entry.getValue()); + } TradeBreakdown trade = payer.getTradeBreakdown(); if (trade != null && trade.getTariffsByFactionMap() != null) { for (Map.Entry entry : trade.getTariffsByFactionMap().entrySet()) { @@ -1146,6 +1220,17 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { break; } + case HUB_FEE_PAYMENTS: { + for (Map.Entry entry : getPayableHubFees().entrySet()) { + Guild receiver = mainGuild(entry.getKey()); + if (receiver == null) { + continue; + } + buffer.add(guild, receiver, entry.getValue()); + } + break; + } + //Taxes and Tariffs case TARIFF_PAYMENTS: { TradeBreakdown tariffs = guild.getTradeBreakdown(); @@ -1250,6 +1335,7 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { case TRIBUTES: case TARIFFS: case HUB_TAX: + case HUB_FEE: 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 316d7f6c..297fc4ce 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/LedgerHistory.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/LedgerHistory.java @@ -21,6 +21,8 @@ public enum Source { TARIFFS, HUB_TAX, HUB_TAX_PAYMENTS, + HUB_FEE, + HUB_FEE_PAYMENTS, DEPOSITS } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java index e9513a87..12e61606 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java @@ -624,8 +624,6 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String p.playSound(p, Sound.BLOCK_ANVIL_USE, 1f, 1f); } return true; - } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("hubpermit")) { - return net.tfminecraft.simplefactions.guild.hub.SupplyHubCommands.permit(p, args); } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("deconstruct")) { if(!Cache.requireProvinces(p)) { return true; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java index 03661f5d..55c55018 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java @@ -509,6 +509,9 @@ public void time() { SimpleFactions.getInstance().getProvinceManager()); }); runDailyStep("income", this::settleIncome); + runDailyStep("hub agreements", () -> + net.tfminecraft.simplefactions.guild.hub.HubAgreementService.tick( + getAllGuilds(), System.currentTimeMillis())); timer = 0; day++; } @@ -625,6 +628,7 @@ public void run() { updateAllPrestigeConverged(); net.tfminecraft.simplefactions.inactivity.InactivityService.armAll(factions, System.currentTimeMillis()); fixRelations(); + net.tfminecraft.simplefactions.guild.hub.SupplyHubService.dropUnagreedForeignLoaded(); loadDBLoans(); loaded = true; } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java index 92111422..f6d31aea 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java @@ -43,6 +43,10 @@ public class PlayerManager implements Listener{ @EventHandler public void joinEvent(PlayerJoinEvent e) { net.tfminecraft.simplefactions.inactivity.InactivityService.onLogin(e.getPlayer()); + String hubSummary = net.tfminecraft.simplefactions.guild.hub.HubAgreementService.joinSummary(e.getPlayer().getName()); + if (hubSummary != null) { + e.getPlayer().sendMessage(hubSummary); + } } //Loans and stuff diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java index 7daef7b2..93f93437 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java @@ -526,7 +526,8 @@ public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { //if(foreignTax > 0) lore.add(StringFormatter.formatHex("#d4c9aeForeign Taxes§e: #a39a84"+foreignTax+"%")); // Show base rates for non-ID tax targets (read-only) for (TaxTarget target : TaxTarget.values()) { - if (target == TaxTarget.GUILD_ID || target == TaxTarget.VASSAL_ID || target == TaxTarget.TARIFF_ID) continue; + if (target == TaxTarget.GUILD_ID || target == TaxTarget.VASSAL_ID || target == TaxTarget.TARIFF_ID + || target == TaxTarget.HUB_TAX) continue; if (!f.getTaxHandler().canCollectTax(target)) continue; lore.add(StringFormatter.formatHex("#93c9a7" + target.getDisplayName() + ": #a39a84" + f.getTaxRate(target, null, false) + "%"+ " §8(§7"+f.getTaxRate(target, null, true)+"% effective§8)")); } 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 25d3ba5b..c5b3bdef 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java @@ -195,6 +195,7 @@ public void taxProposalView(Player player, Faction f, Inventory i) { i.clear(); int x = 0; for(TaxTarget target : TaxTarget.values()) { + if(target == TaxTarget.HUB_TAX) continue; if(!f.getTaxHandler().canCollectTax(target)) continue; i.setItem(x, creator.createTaxTypeItem(player, f, target, true)); x++; @@ -405,6 +406,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if(id == null) return; try { TaxTarget target = TaxTarget.valueOf(id); + if(target == TaxTarget.HUB_TAX) return; if(target == TaxTarget.GUILD_ID) { specificTaxProposalView(p, f, null, TaxTarget.GUILD_ID); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); 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 1022037e..95d7a335 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java @@ -670,6 +670,35 @@ public ItemStack createLedgerHubTaxItem(Guild g, boolean payments) { return i; } + // Keep the existing legacy text representation, formatting, and exact-string comparisons. + @SuppressWarnings("deprecation") + public ItemStack createLedgerHubFeeItem(Guild g, boolean payments) { + ItemStack i = new ItemStack(Material.EMERALD); + ItemMeta m = i.getItemMeta(); + m.setDisplayName(StringFormatter.formatHex("#5cc46aHub Fee" + (payments ? " Paid" : ""))); + Map today = new HashMap<>(); + if (payments) { + if (g.getLedger() != null) { + g.getLedger().getPayableHubFees().forEach( + (host, fee) -> today.merge(host.getName(), fee, Double::sum)); + } + } else if (g.getLedger() != null && !g.isBankrupt() && g.getFaction() != null) { + for (Guild payer : FactionManager.getAllGuilds()) { + if (payer.getLedger() == null) { + continue; + } + double fee = payer.getLedger().getPayableHubFees().getOrDefault(g.getFaction(), 0.0); + if (fee > 0) { + today.merge(payer.getFaction().getName(), fee, Double::sum); + } + } + } + m.setLore(ledgerSourceLore(g, payments ? LedgerHistory.Source.HUB_FEE_PAYMENTS : LedgerHistory.Source.HUB_FEE, + payments ? "Top hub fee hosts" : "Top hub fee payers", today, "No hub fee 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 51d9c97d..4f482937 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java @@ -132,8 +132,10 @@ public void ledgerView(Player player, Guild guild, Inventory i) { i.setItem(14, creator.createLedgerTariffsItem(guild)); i.setItem(15, creator.createLedgerDepositsItem(guild)); i.setItem(16, creator.createLedgerHubTaxItem(guild, false)); + i.setItem(18, creator.createLedgerHubFeeItem(guild, false)); } i.setItem(17, creator.createLedgerHubTaxItem(guild, true)); + i.setItem(19, creator.createLedgerHubFeeItem(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/SupplyHubView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubView.java index 3fc4ff52..2cb312d7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubView.java @@ -18,6 +18,7 @@ import net.tfminecraft.simplefactions.enums.GuildModifier; import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.guild.GuildModifierOverride; +import net.tfminecraft.simplefactions.guild.hub.HubAgreementService; import net.tfminecraft.simplefactions.guild.hub.SupplyHubCommands; import net.tfminecraft.simplefactions.guild.hub.HubNetwork; import net.tfminecraft.simplefactions.guild.hub.HubTransport.Link; @@ -125,7 +126,8 @@ private void populateHostedView(Player player, Faction faction, Installation ins ItemStack item = SupplyHubCreator.item(Material.CHEST, "§e" + guild.getName(), SupplyHubCreator.hostHubLore(guild.getName(), host == null ? "Unknown" : host.getName(), standing)); - if (faction.getLeader().equalsIgnoreCase(player.getName())) { + boolean agreed = HubAgreementService.hasAgreement(guild, faction.getId(), installation.getId()); + if (!agreed && faction.getLeader() != null && faction.getLeader().equalsIgnoreCase(player.getName())) { ItemMeta meta = item.getItemMeta(); meta.getPersistentDataContainer().set(Keys.STRING_KEY, PersistentDataType.STRING, guild.getId()); List lore = new ArrayList<>(meta.getLore()); @@ -137,25 +139,9 @@ private void populateHostedView(Player player, Faction faction, Installation ins } inventory.setItem(47, SupplyHubCreator.item(Material.PAPER, "§eHub Slots", List.of("§7Used: §e" + guilds.size() + "/" + max))); - inventory.setItem(49, SupplyHubCreator.item(Material.WRITABLE_BOOK, "§eHub Permits", - permitLore(faction))); inventory.setItem(53, inv.createBackButton(SFGUI.HOSTED_SUPPLY_HUB_VIEW)); } - public static List permitLore(Faction faction) { - List lore = new ArrayList<>(); - lore.add("§7Changed with §e/faction hubpermit "); - lore.add("§7Permitted guilds:"); - if (faction.getHubPermits().isEmpty()) { - lore.add("§8None"); - } else { - for (String permit : faction.getHubPermits()) { - lore.add("§e" + permit); - } - } - return lore; - } - public static boolean removeGuildHub( List hubs, String ownerFactionId, String installationId) { SupplyHub hub = SupplyHubService.findHub(hubs, ownerFactionId, installationId); @@ -231,6 +217,7 @@ public boolean confirm(Player player, String data, boolean accepted) { } if (accepted) { if (removeGuildHub(guild.getSupplyHubs(), parts[2], parts[3])) { + HubAgreementService.onHubRemoved(guild, parts[2], parts[3]); SupplyHubCommands.recalculateTrade(); } } @@ -244,6 +231,9 @@ public boolean confirm(Player player, String data, boolean accepted) { } if (accepted) { Guild guild = FactionManager.getGuildByString(parts[3]); + if (guild != null && HubAgreementService.hasAgreement(guild, parts[1], parts[2])) { + return false; + } if (guild != null && removeGuildHub(guild.getSupplyHubs(), parts[1], parts[2])) { SupplyHubCommands.recalculateTrade(); org.bukkit.entity.Player online = SimpleFactions.plugin.getServer() @@ -272,10 +262,7 @@ private static boolean isLeader(Guild guild, Player player) { private static HubStanding standing(Guild guild, SupplyHub hub) { Installation installation = SupplyHubService.findInstallation( hub.ownerFactionId(), hub.installationId()); - Faction owner = FactionManager.getByString(hub.ownerFactionId()); - boolean allowed = SupplyHubService.ownerAllows( - guild.getFaction() == null ? null : guild.getFaction().getId(), hub.ownerFactionId(), - owner != null && owner.hasHubPermit(guild.getId())); + boolean allowed = SupplyHubService.hubPermitted(guild, hub.ownerFactionId(), hub.installationId()); int slots = installation == null ? 0 : InstallationConfigLoader.getHubSlots(installation.getKind(), installation.getLevel()); 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 bef8afcf..2eb913a4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TaxView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TaxView.java @@ -36,6 +36,7 @@ public void taxView(Player player, Faction f, Inventory i) { i.clear(); int slot = 0; for(TaxTarget target : TaxTarget.values()) { + if(target == TaxTarget.HUB_TAX) continue; if(!f.getTaxHandler().canCollectTax(target)) continue; i.setItem(slot, creator.createTaxTypeItem(player, f, target, false)); slot++; diff --git a/src/main/java/net/tfminecraft/simplefactions/map/export/Markers.java b/src/main/java/net/tfminecraft/simplefactions/map/export/Markers.java index 070fd431..2f66dedd 100644 --- a/src/main/java/net/tfminecraft/simplefactions/map/export/Markers.java +++ b/src/main/java/net/tfminecraft/simplefactions/map/export/Markers.java @@ -224,13 +224,10 @@ private static Installation activeHubAt(Guild guild, int provinceId, String fact || !hub.installationId().equalsIgnoreCase(installationId)) { continue; } - Faction owner = FactionManager.getByString(hub.ownerFactionId()); - boolean permit = owner != null && owner.hasHubPermit(guild.getId()); - String guildFactionId = guild.getFaction() == null ? null : guild.getFaction().getId(); SupplyHubService.HubStanding standing = SupplyHubService.standing( hub, true, - SupplyHubService.ownerAllows(guildFactionId, hub.ownerFactionId(), permit), + SupplyHubService.hubPermitted(guild, hub.ownerFactionId(), hub.installationId()), InstallationConfigLoader.getHubSlots(installation.getKind(), installation.getLevel()), SupplyHubService.atInstallation( hub.ownerFactionId(), hub.installationId(), SupplyHubService.allGuilds())); diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java index f69a542e..062a6c4a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java @@ -30,7 +30,6 @@ import net.tfminecraft.simplefactions.diplomacy.DiplomacyHandler; import net.tfminecraft.simplefactions.diplomacy.Relation; import net.tfminecraft.simplefactions.guild.Guild; -import net.tfminecraft.simplefactions.guild.hub.SupplyHubService; import net.tfminecraft.simplefactions.loaders.GuildLoader; import net.tfminecraft.simplefactions.loaders.RankLoader; import net.tfminecraft.simplefactions.loaders.TierLoader; @@ -138,8 +137,6 @@ public class Faction { //Installations private final InstallationHandler installationHandler; - private final List hubPermits = new ArrayList<>(); - private final List warReparationsObligations = new ArrayList<>(); public Faction(String id, String leader) { @@ -471,33 +468,6 @@ public InstallationHandler getInstallationHandler() { return installationHandler; } - public List getHubPermits() { - return hubPermits; - } - - public void loadHubPermits(List permits) { - hubPermits.clear(); - if (permits == null) { - return; - } - for (String guildId : permits) { - if (guildId == null || guildId.isBlank()) { - continue; - } - if (!SupplyHubService.hasPermit(hubPermits, guildId)) { - hubPermits.add(guildId); - } - } - } - - public boolean hasHubPermit(String guildId) { - return SupplyHubService.hasPermit(hubPermits, guildId); - } - - public boolean toggleHubPermit(String guildId) { - return SupplyHubService.togglePermit(hubPermits, guildId); - } - public ProvinceHandler getProvinceHandler() { return provinceHandler; } diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java b/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java index 97c3bd69..879905e0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java @@ -194,11 +194,6 @@ else if(cmd.getName().equalsIgnoreCase("guild") } return new ArrayList<>(); } - else if(cmd.getName().equalsIgnoreCase("faction") - && args.length >= 2 - && args[0].equalsIgnoreCase("hubpermit")) { - return net.tfminecraft.simplefactions.guild.hub.SupplyHubCommands.completePermits(sender, args); - } else if(cmd.getName().equalsIgnoreCase("guild") && args.length >= 0 && args.length < 2 ) { if(sender instanceof Player){ Player p = (Player) sender; @@ -336,7 +331,6 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le completions.add("construct"); completions.add("deconstruct"); completions.add("upgrade"); - completions.add("hubpermit"); completions.add("unclaim"); completions.add("setcapital"); } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 1fbe82eb..baa596d9 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -84,8 +84,14 @@ mercenary-default-breach-refund: 500.0 # smaller refund would make no-showing more profitable per head than fighting. supply-hubs: - # Maximum hub tax rate a host faction can set. + # Highest hub tax percent an agreement may set. The host's economy law can set a lower cap. max-tax: 50 + # Highest daily fee, in denars, an agreement may set. + max-fee: 500 + # Days an offer stays open after the last move. + offer-days: 7 + # Days a hub agreement runs. + agreement-days: 14 # A hub takes the guild's trade power and production in its province and delivers a share # to each of the guild's hubs it is connected to. Train track joins any two kinds; ports # also connect across the sea, and airports through the air. diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementServiceTest.java new file mode 100644 index 00000000..2732a528 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementServiceTest.java @@ -0,0 +1,609 @@ +package net.tfminecraft.simplefactions.guild.hub; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.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.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.database.FactionData; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.enums.GuildModifier; +import net.tfminecraft.simplefactions.enums.Rules; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.hub.HubAgreementService.AgreementResult; +import net.tfminecraft.simplefactions.guild.hub.HubAgreementService.HubNotice; +import net.tfminecraft.simplefactions.guild.hub.HubAgreementService.PendingMatter; +import net.tfminecraft.simplefactions.guild.hub.HubAgreementService.RateRange; +import net.tfminecraft.simplefactions.guild.income.Cashflow; +import net.tfminecraft.simplefactions.guild.income.Ledger; +import net.tfminecraft.simplefactions.guild.income.LedgerHistory; +import net.tfminecraft.simplefactions.guild.income.TradeBreakdown; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.RelationManager; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.Bracket; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.handler.GuildHandler; +import net.tfminecraft.simplefactions.objects.handler.TaxHandler; +import net.tfminecraft.simplefactions.utils.DailyGuildTransfers; + +class HubAgreementServiceTest { + @AfterEach + void restoreCap() { + Cache.supplyHubMaxTax = 50; + } + + @Test + void offerFlowBuildsTheHubAtTheLastTermsAndRefusesTheWrongActor() { + Facts facts = new Facts(); + Guild guild = guild(); + long start = 1_000L; + + AgreementResult stranger = HubAgreementService.propose( + guild, "Stranger", "host", "port", 10, 1.0, facts, start); + assertFalse(stranger.succeeded()); + assertTrue(guild.getHubOffers().isEmpty()); + + AgreementResult proposed = HubAgreementService.propose( + guild, "Leader", "host", "port", 10, 1.0, facts, start); + assertTrue(proposed.succeeded()); + assertEquals(OfferSide.HOST, guild.getHubOffers().get(0).awaiting()); + assertEquals(10, guild.getHubOffers().get(0).taxRatePercent()); + + AgreementResult second = HubAgreementService.propose( + guild, "Leader", "host", "port", 11, 1.0, facts, start); + assertFalse(second.succeeded()); + assertEquals(1, guild.getHubOffers().size()); + + AgreementResult outsider = HubAgreementService.counter( + guild, "Stranger", "host", "port", 12, 2.0, facts, start + 1); + assertFalse(outsider.succeeded()); + assertEquals(10, guild.getHubOffers().get(0).taxRatePercent()); + + AgreementResult hostCounter = HubAgreementService.counter( + guild, "Council", "host", "port", 12, 2.0, facts, start + 2); + assertTrue(hostCounter.succeeded()); + assertEquals(OfferSide.GUILD, guild.getHubOffers().get(0).awaiting()); + + AgreementResult guildCounter = HubAgreementService.counter( + guild, "Leader", "host", "port", 15, 3.5, facts, start + 3); + assertTrue(guildCounter.succeeded()); + assertEquals(OfferSide.HOST, guild.getHubOffers().get(0).awaiting()); + assertEquals(15, guild.getHubOffers().get(0).taxRatePercent()); + + AgreementResult own = HubAgreementService.accept(guild, "Leader", "host", "port", facts, start + 4); + assertFalse(own.succeeded()); + assertTrue(own.message().contains("other side")); + assertTrue(guild.getSupplyHubs().isEmpty()); + assertEquals(1, guild.getHubOffers().size()); + + AgreementResult accepted = HubAgreementService.accept(guild, "Council", "host", "port", facts, start + 5); + assertTrue(accepted.succeeded()); + assertTrue(accepted.builtHub()); + assertTrue(guild.getHubOffers().isEmpty()); + assertEquals(1, guild.getSupplyHubs().size()); + assertEquals("host", guild.getSupplyHubs().get(0).ownerFactionId()); + assertEquals("port", guild.getSupplyHubs().get(0).installationId()); + HubAgreement agreement = HubAgreementService.findAgreement(guild, "host", "port"); + assertNotNull(agreement); + assertEquals(15, agreement.taxRatePercent()); + assertEquals(350, agreement.feeCents()); + assertEquals(14, agreement.daysRemaining()); + assertTrue(agreement.guildRenews()); + assertTrue(agreement.hostRenews()); + } + + @Test + void lapsedOfferIsRefusedAndTheTickDropsIt() { + Facts facts = new Facts(); + Guild guild = guild(); + HubAgreementService.propose(guild, "Leader", "host", "port", 10, 1.0, facts, 0L); + long lapsedAt = HubAgreementService.DAY_MILLIS * 7; + + AgreementResult refused = HubAgreementService.accept(guild, "Council", "host", "port", facts, lapsedAt); + assertFalse(refused.succeeded()); + assertTrue(refused.message().toLowerCase(Locale.ROOT).contains("lapsed")); + assertTrue(guild.getHubOffers().isEmpty()); + + HubAgreementService.propose(guild, "Leader", "host", "port", 10, 1.0, facts, 0L); + assertEquals(1, guild.getHubOffers().size()); + HubAgreementService.tick(List.of(guild), lapsedAt, facts); + assertTrue(guild.getHubOffers().isEmpty()); + } + + @Test + void boundsRefuseARateOrFeeOutsideTheHostRange() { + Facts facts = new Facts(); + facts.minRate = 5; + facts.maxRate = 20; + Guild guild = guild(); + + assertFalse(HubAgreementService.propose(guild, "Leader", "host", "port", 4, 1.0, facts, 0L).succeeded()); + assertFalse(HubAgreementService.propose(guild, "Leader", "host", "port", 21, 1.0, facts, 0L).succeeded()); + facts.maxRate = 50; + assertFalse(HubAgreementService.propose(guild, "Leader", "host", "port", 51, 1.0, facts, 0L).succeeded()); + assertFalse(HubAgreementService.propose(guild, "Leader", "host", "port", 10, -0.01, facts, 0L).succeeded()); + assertFalse(HubAgreementService.propose(guild, "Leader", "host", "port", 10, 1.234, facts, 0L).succeeded()); + assertFalse(HubAgreementService.propose(guild, "Leader", "host", "port", 10, 500.01, facts, 0L).succeeded()); + assertTrue(guild.getHubOffers().isEmpty()); + + facts.hubTax = false; + assertFalse(HubAgreementService.propose(guild, "Leader", "host", "port", 10, 1.0, facts, 0L).succeeded()); + assertTrue(guild.getHubOffers().isEmpty()); + + Faction host = mock(Faction.class); + when(host.hasFactionRule(Rules.HUB_TAX)).thenReturn(true); + TaxHandler taxes = new TaxHandler(host, 0, 0, 0, 0, 0); + taxes.applyBracket(TaxTarget.HUB_TAX, new Bracket(0, 80)); + when(host.getTaxHandler()).thenReturn(taxes); + Cache.supplyHubMaxTax = 50; + RateRange range = HubAgreementService.allowedRateRange(host); + assertTrue(range.canHost()); + assertEquals(0, range.minPercent()); + assertEquals(50, range.maxPercent()); + when(host.hasFactionRule(Rules.HUB_TAX)).thenReturn(false); + assertFalse(HubAgreementService.allowedRateRange(host).canHost()); + } + + @Test + void acceptanceBuildsNothingWhenTheLimitOrSlotIsGone() { + Facts facts = new Facts(); + Guild limited = guild(); + limited.getHubOffers().add(offer(10, 100)); + limited.getSupplyHubs().add(new SupplyHub("other", "yard", 1)); + facts.limit = 1; + AgreementResult atLimit = HubAgreementService.accept(limited, "Council", "host", "port", facts, 5L); + assertFalse(atLimit.succeeded()); + assertEquals(1, limited.getHubOffers().size()); + assertEquals(1, limited.getSupplyHubs().size()); + assertNull(HubAgreementService.findAgreement(limited, "host", "port")); + + Facts full = new Facts(); + full.slots = 1; + full.occupied = 1; + Guild taken = guild(); + taken.getHubOffers().add(offer(10, 100)); + AgreementResult noSlot = HubAgreementService.accept(taken, "Council", "host", "port", full, 5L); + assertFalse(noSlot.succeeded()); + assertEquals(1, taken.getHubOffers().size()); + assertTrue(taken.getSupplyHubs().isEmpty()); + } + + @Test + void termRenewsRemovesAndAppliesNextTerms() { + Facts facts = new Facts(); + Guild renewing = agreed(facts, 14, true, true, null); + tick(renewing, facts, 13); + assertNotNull(HubAgreementService.findAgreement(renewing, "host", "port")); + assertEquals(1, HubAgreementService.findAgreement(renewing, "host", "port").daysRemaining()); + tick(renewing, facts, 1); + HubAgreement renewed = HubAgreementService.findAgreement(renewing, "host", "port"); + assertNotNull(renewed); + assertEquals(14, renewed.daysRemaining()); + assertEquals(10, renewed.taxRatePercent()); + assertEquals(1, renewing.getSupplyHubs().size()); + + Guild ending = agreed(facts, 14, true, false, null); + tick(ending, facts, 13); + assertEquals(1, ending.getSupplyHubs().size()); + tick(ending, facts, 1); + assertTrue(ending.getSupplyHubs().isEmpty()); + assertNull(HubAgreementService.findAgreement(ending, "host", "port")); + + Guild next = agreed(facts, 14, true, true, new HubTerms(8, 50)); + tick(next, facts, 14); + HubAgreement applied = HubAgreementService.findAgreement(next, "host", "port"); + assertNotNull(applied); + assertEquals(8, applied.taxRatePercent()); + assertEquals(50, applied.feeCents()); + assertNull(applied.nextTerms()); + assertEquals(14, applied.daysRemaining()); + + Facts tight = new Facts(); + Guild outside = agreed(tight, 14, true, true, null); + tick(outside, tight, 13); + tight.maxRate = 9; + tick(outside, tight, 1); + assertTrue(outside.getSupplyHubs().isEmpty()); + assertNull(HubAgreementService.findAgreement(outside, "host", "port")); + } + + @Test + void nextTermAcceptanceStoresTheTermsAndTurnsRenewalOn() { + Facts facts = new Facts(); + Guild guild = agreed(facts, 10, false, false, null); + AgreementResult offered = HubAgreementService.propose( + guild, "Leader", "host", "port", 18, 4.25, facts, 20L); + assertTrue(offered.succeeded()); + AgreementResult accepted = HubAgreementService.accept(guild, "Council", "host", "port", facts, 21L); + assertTrue(accepted.succeeded()); + assertFalse(accepted.builtHub()); + HubAgreement agreement = HubAgreementService.findAgreement(guild, "host", "port"); + assertNotNull(agreement.nextTerms()); + assertEquals(18, agreement.nextTerms().taxRatePercent()); + assertEquals(425, agreement.nextTerms().feeCents()); + assertTrue(agreement.guildRenews()); + assertTrue(agreement.hostRenews()); + assertTrue(guild.getHubOffers().isEmpty()); + } + + @Test + void pendingListIsOnlyTheOfferAndTheAgreementThatWillNotRenew() { + Facts facts = new Facts(); + Guild guild = guild(); + guild.getHubOffers().add(new HubOffer( + "host", "port", 10, 100, OfferSide.GUILD, "Council", 1L, OfferKind.NEW_HUB)); + guild.getHubOffers().add(new HubOffer( + "host", "yard", 10, 100, OfferSide.HOST, "Leader", 1L, OfferKind.NEW_HUB)); + guild.getHubAgreements().add(new HubAgreement("host", "old", 10, 100, 3, false, true, null, false)); + guild.getHubAgreements().add(new HubAgreement("host", "later", 10, 100, 4, false, true, null, false)); + guild.getHubAgreements().add(new HubAgreement("host", "renews", 10, 100, 3, true, true, null, false)); + + List pending = HubAgreementService.pendingFor("Leader", List.of(guild), facts); + assertEquals(2, pending.size()); + assertEquals(PendingMatter.Kind.OFFER, pending.get(0).kind()); + assertEquals("port", pending.get(0).installationId()); + assertEquals(PendingMatter.Kind.EXPIRING, pending.get(1).kind()); + assertEquals("old", pending.get(1).installationId()); + String summary = HubAgreementService.summarize(pending); + assertNotNull(summary); + assertTrue(summary.contains("1 hub offer")); + assertTrue(summary.contains("1 hub agreement")); + + assertTrue(HubAgreementService.pendingFor("Stranger", List.of(guild), facts).isEmpty()); + } + + @Test + void feeMovesToTheHostMainGuildAndSkipsSameRealmOrABankruptHost() { + Faction home = mock(Faction.class); + Faction host = mock(Faction.class); + when(home.getId()).thenReturn("home"); + when(home.getName()).thenReturn("Home"); + when(host.getId()).thenReturn("host"); + when(host.getName()).thenReturn("Host"); + when(host.hasFactionRule(Rules.HUB_TAX)).thenReturn(true); + Guild payer = ledgerGuild(home, false); + Guild receiver = ledgerGuild(host, true); + when(payer.getName()).thenReturn("Payer"); + when(payer.getHubAgreements()).thenReturn(new ArrayList<>(List.of( + new HubAgreement("host", "port", 10, 250, 14, true, true, null, false)))); + when(payer.getSupplyHubs()).thenReturn(new ArrayList<>()); + GuildHandler hosts = mock(GuildHandler.class); + when(host.getGuildHandler()).thenReturn(hosts); + when(hosts.getGuild("host")).thenReturn(receiver); + try (MockedStatic factions = mockStatic(FactionManager.class); + MockedStatic relations = mockStatic(RelationManager.class)) { + factions.when(() -> FactionManager.getByString("host")).thenReturn(host); + factions.when(() -> FactionManager.getByString("home")).thenReturn(home); + factions.when(FactionManager::getAllGuilds).thenReturn(List.of(payer, receiver)); + relations.when(() -> RelationManager.sameRealm(any(), any())).thenReturn(false); + + assertEquals(-2.5, payer.getLedger().getIncome(Cashflow.HUB_FEE_PAYMENTS), 1e-9); + assertEquals(2.5, receiver.getLedger().getIncome(Cashflow.HUB_FEE), 1e-9); + DailyGuildTransfers transfers = new DailyGuildTransfers(); + payer.getLedger().populateDailyTransfers(transfers); + assertEquals(2.5, transfers.getTransfers().get(payer).get(receiver), 1e-9); + var day = Ledger.collectHistoryDay(List.of(payer, receiver)); + assertEquals(2.5, day.get(receiver).get(LedgerHistory.Source.HUB_FEE).get("Home"), 1e-9); + assertEquals(2.5, day.get(payer).get(LedgerHistory.Source.HUB_FEE_PAYMENTS).get("Host"), 1e-9); + + relations.when(() -> RelationManager.sameRealm(any(), any())).thenReturn(true); + assertEquals(0, payer.getLedger().getIncome(Cashflow.HUB_FEE_PAYMENTS), 1e-9); + assertEquals(0, receiver.getLedger().getIncome(Cashflow.HUB_FEE), 1e-9); + + relations.when(() -> RelationManager.sameRealm(any(), any())).thenReturn(false); + when(receiver.isBankrupt()).thenReturn(true); + assertEquals(0, payer.getLedger().getIncome(Cashflow.HUB_FEE_PAYMENTS), 1e-9); + assertEquals(0, receiver.getLedger().getIncome(Cashflow.HUB_FEE), 1e-9); + + when(receiver.isBankrupt()).thenReturn(false); + when(host.hasFactionRule(Rules.HUB_TAX)).thenReturn(false); + assertEquals(-2.5, payer.getLedger().getIncome(Cashflow.HUB_FEE_PAYMENTS), 1e-9); + assertEquals(2.5, receiver.getLedger().getIncome(Cashflow.HUB_FEE), 1e-9); + } + } + + @Test + void twoHostsAreTaxedAtTheirOwnAgreementRates() { + Guild guild = guild(); + guild.getHubAgreements().add(new HubAgreement("east", "port", 10, 0, 14, true, true, null, false)); + guild.getHubAgreements().add(new HubAgreement("west", "yard", 40, 0, 14, true, true, null, false)); + SupplyHub east = new SupplyHub("east", "port", 1); + SupplyHub west = new SupplyHub("west", "yard", 2); + assertEquals(10, HubAgreementService.taxRatePercent(guild, east)); + assertEquals(40, HubAgreementService.taxRatePercent(guild, west)); + assertEquals(0, HubAgreementService.taxRatePercent(guild, new SupplyHub("east", "missing", 3))); + } + + @Test + void sheddingDropsTheNewestHubAndItsAgreementWhenFeesCannotBePaid() { + Guild guild = guild(); + SupplyHub older = new SupplyHub("host", "old", 1); + SupplyHub newest = new SupplyHub("host", "new", 2); + guild.getSupplyHubs().add(older); + guild.getSupplyHubs().add(newest); + guild.getHubAgreements().add(new HubAgreement("host", "old", 10, 0, 14, true, true, null, false)); + guild.getHubAgreements().add(new HubAgreement("host", "new", 10, 1000, 14, true, true, null, false)); + when(guild.getModifier(GuildModifier.HUB_UPKEEP)).thenReturn(15.0); + Bank bank = mock(Bank.class); + when(bank.getWealth()).thenReturn(30.0); + when(guild.getBank()).thenReturn(bank); + + List removed = SupplyHubService.shedUnpaid(guild); + assertEquals(List.of(newest), removed); + assertEquals(List.of(older), guild.getSupplyHubs()); + assertNull(HubAgreementService.findAgreement(guild, "host", "new")); + assertNotNull(HubAgreementService.findAgreement(guild, "host", "old")); + } + + @Test + void transferToAnotherRealmRemovesTheHubAndTransferHomeKeepsIt() { + Facts facts = new Facts(); + facts.councils.put("rome", List.of("OldCouncil")); + facts.councils.put("venice", List.of("NewCouncil")); + facts.councils.put("home", List.of("HomeCouncil")); + facts.same.add("home"); + + Guild abroad = guild(); + abroad.getSupplyHubs().add(new SupplyHub("rome", "port", 5)); + abroad.getHubAgreements().add(new HubAgreement("rome", "port", 10, 100, 8, true, true, null, false)); + List removed = HubAgreementService.onTransferred(List.of(abroad), "rome", "venice", "port", facts); + assertTrue(abroad.getSupplyHubs().isEmpty()); + assertNull(HubAgreementService.findAgreement(abroad, "rome", "port")); + Set told = new HashSet<>(); + for (HubNotice notice : removed) { + told.add(notice.playerName()); + assertTrue(notice.message().contains("another realm")); + } + assertEquals(Set.of("Leader", "OldCouncil", "NewCouncil"), told); + + Guild home = guild(); + home.getSupplyHubs().add(new SupplyHub("rome", "port", 5)); + home.getHubAgreements().add(new HubAgreement("rome", "port", 10, 100, 8, true, true, null, false)); + List kept = HubAgreementService.onTransferred(List.of(home), "rome", "home", "port", facts); + assertTrue(kept.isEmpty()); + assertNull(HubAgreementService.findAgreement(home, "rome", "port")); + assertNull(HubAgreementService.findAgreement(home, "home", "port")); + assertEquals(1, home.getSupplyHubs().size()); + assertEquals("home", home.getSupplyHubs().get(0).ownerFactionId()); + assertEquals("port", home.getSupplyHubs().get(0).installationId()); + assertEquals(5L, home.getSupplyHubs().get(0).createdAt()); + } + + @Test + void dormantHubKeepsItsFeeAndTheLeaderIsToldOnce() { + Facts facts = new Facts(); + facts.dormant = true; + Guild guild = agreed(facts, 14, true, true, null); + List first = HubAgreementService.tick(List.of(guild), 1L, facts); + assertEquals(1, first.size()); + assertEquals("Leader", first.get(0).playerName()); + assertTrue(first.get(0).message().contains("dormant")); + assertTrue(first.get(0).message().contains("Remove the hub")); + HubAgreement agreement = HubAgreementService.findAgreement(guild, "host", "port"); + assertTrue(agreement.dormantNoticeSent()); + assertEquals(5.0, HubAgreementService.feeDenars(guild, guild.getSupplyHubs().get(0)), 1e-9); + List second = HubAgreementService.tick(List.of(guild), 2L, facts); + assertTrue(second.stream().noneMatch(notice -> notice.message().contains("dormant"))); + assertEquals(5.0, HubAgreementService.feeDenars(guild, guild.getSupplyHubs().get(0)), 1e-9); + assertNotNull(HubAgreementService.findAgreement(guild, "host", "port")); + } + + @Test + void loadDropsAForeignHubWithNoAgreementAndIgnoresOldPermitData() { + Facts facts = new Facts(); + facts.same.add("home"); + Guild guild = guild(); + guild.getSupplyHubs().add(new SupplyHub("host", "port", 1)); + guild.getSupplyHubs().add(new SupplyHub("home", "yard", 2)); + List lines = HubAgreementService.removeUnagreedForeignHubs(List.of(guild), facts); + assertEquals(1, lines.size()); + assertTrue(lines.get(0).contains("Merchants")); + assertTrue(lines.get(0).contains("port")); + assertNull(SupplyHubService.findHub(guild.getSupplyHubs(), "host", "port")); + assertNotNull(SupplyHubService.findHub(guild.getSupplyHubs(), "home", "yard")); + + FactionData loaded = JsonUtil.GSON.fromJson( + "{\"hub permits\":[\"merchants\"],\"hub tax\":12.0,\"id\":\"rome\"}", FactionData.class); + assertEquals("rome", loaded.id); + assertEquals(List.of("merchants"), loaded.hubPermits); + assertEquals(12.0, loaded.hubTax); + String fresh = JsonUtil.GSON.toJson(new FactionData()); + assertFalse(fresh.contains("hub permits")); + assertFalse(fresh.contains("hub tax")); + } + + private static void tick(Guild guild, Facts facts, int days) { + for (int day = 0; day < days; day++) { + HubAgreementService.tick(List.of(guild), day, facts); + } + } + + private static Guild agreed(Facts facts, int days, boolean guildRenews, boolean hostRenews, HubTerms next) { + Guild guild = guild(); + guild.getSupplyHubs().add(new SupplyHub("host", "port", 1)); + guild.getHubAgreements().add(new HubAgreement( + "host", "port", 10, 500, days, guildRenews, hostRenews, next, false)); + return guild; + } + + private static HubOffer offer(int rate, long cents) { + return new HubOffer("host", "port", rate, cents, OfferSide.HOST, "Leader", 1L, OfferKind.NEW_HUB); + } + + private static Guild guild() { + Guild guild = mock(Guild.class); + when(guild.getId()).thenReturn("merchants"); + when(guild.getName()).thenReturn("Merchants"); + when(guild.getSupplyHubs()).thenReturn(new ArrayList<>()); + when(guild.getHubAgreements()).thenReturn(new ArrayList<>()); + when(guild.getHubOffers()).thenReturn(new ArrayList<>()); + return guild; + } + + private static Guild ledgerGuild(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.isBankrupt()).thenReturn(false); + when(guild.getTradeBreakdown()).thenReturn(new TradeBreakdown()); + when(guild.getLedger()).thenReturn(new Ledger(guild)); + when(guild.getId()).thenReturn(base ? "host-guild" : "payer"); + return guild; + } + + private static final class Facts implements HubAgreementFacts { + final Set same = new HashSet<>(); + final Map> councils = new HashMap<>(); + boolean hostExists = true; + boolean hubTax = true; + int minRate; + int maxRate = 50; + double maxFee = 500; + int offerDays = 7; + int agreementDays = 14; + boolean installation = true; + boolean allows = true; + int slots = 2; + int limit = 4; + int occupied; + double trade = 12; + boolean dormant; + String leader = "Leader"; + + Facts() { + councils.put("host", List.of("Council")); + } + + @Override + public boolean sameRealm(Guild guild, String factionId) { + return factionId != null && same.contains(factionId.toLowerCase(Locale.ROOT)); + } + + @Override + public boolean hostExists(String factionId) { + return hostExists; + } + + @Override + public boolean hostHasHubTax(String factionId) { + return hubTax; + } + + @Override + public int minRate(String factionId) { + return minRate; + } + + @Override + public int maxRate(String factionId) { + return maxRate; + } + + @Override + public double maxFee() { + return maxFee; + } + + @Override + public int offerDays() { + return offerDays; + } + + @Override + public int agreementDays() { + return agreementDays; + } + + @Override + public boolean installationExists(String factionId, String installationId) { + return installation; + } + + @Override + public boolean allowsSupplyHubs(Guild guild) { + return allows; + } + + @Override + public int hubSlots(String factionId, String installationId) { + return slots; + } + + @Override + public int hubLimit(Guild guild) { + return limit; + } + + @Override + public int hubsAtInstallation(String factionId, String installationId) { + return occupied; + } + + @Override + public double tradePower(Guild guild, String factionId, String installationId) { + return trade; + } + + @Override + public boolean isGuildLeader(Guild guild, String actorName) { + return actorName != null && actorName.equalsIgnoreCase(leader); + } + + @Override + public boolean isHostCouncil(String factionId, String actorName) { + if (actorName == null) { + return false; + } + List names = councils.getOrDefault(factionId, List.of()); + for (String name : names) { + if (actorName.equalsIgnoreCase(name)) { + return true; + } + } + return false; + } + + @Override + public List council(String factionId) { + return councils.getOrDefault(factionId, List.of()); + } + + @Override + public String guildLeader(Guild guild) { + return leader; + } + + @Override + public boolean hubDormant(Guild guild, SupplyHub hub) { + return dormant; + } + + @Override + public String installationLabel(String factionId, String installationId) { + return installationId == null ? "installation" : installationId; + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubTaxServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubTaxServiceTest.java index 1d50b49d..339ab7c4 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubTaxServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubTaxServiceTest.java @@ -6,7 +6,6 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.ArrayList; @@ -21,7 +20,6 @@ import net.tfminecraft.simplefactions.enums.GuildModifier; import net.tfminecraft.simplefactions.enums.Rules; 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; @@ -138,17 +136,23 @@ void snapshotsAssessForeignHubsWithoutChangingLiveDataOrTheNetwork() { Guild guild = mock(Guild.class); Faction home = mock(Faction.class); Faction host = mock(Faction.class); + Faction west = mock(Faction.class); when(home.getId()).thenReturn("home"); when(home.hasFactionRule(Rules.SUPPLY_HUBS)).thenReturn(true); + when(home.getRelations()).thenReturn(new HashMap<>()); when(host.getId()).thenReturn("host"); - when(host.hasHubPermit("guild")).thenReturn(true); - when(host.getTaxRate(TaxTarget.HUB_TAX, null, true)).thenReturn(50.0); + when(host.getRelations()).thenReturn(new HashMap<>()); + when(west.getId()).thenReturn("west"); + when(west.getRelations()).thenReturn(new HashMap<>()); when(guild.getId()).thenReturn("guild"); + when(guild.getHubAgreements()).thenReturn(new ArrayList<>(List.of( + new HubAgreement("host", "b", 50, 0, 14, true, true, null, false), + new HubAgreement("west", "c", 10, 0, 14, true, true, null, false)))); 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.HUB_LIMIT)).thenReturn(3.0); + when(guild.getModifier(GuildModifier.HUB_LIMIT)).thenReturn(4.0); when(guild.getModifier(GuildModifier.TRADE_CARRY)).thenReturn(1.0); when(guild.getModifier(GuildModifier.PRODUCTION)).thenReturn(10.0); TradeBreakdown trade = new TradeBreakdown(); @@ -156,16 +160,21 @@ void snapshotsAssessForeignHubsWithoutChangingLiveDataOrTheNetwork() { 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))); + SupplyHub other = new SupplyHub("west", "c", 3); + SupplyHub dormant = new SupplyHub("host", "dormant", 4); + when(guild.getSupplyHubs()).thenReturn(new ArrayList<>(List.of(first, last, other, 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); + Installation c = new Installation("c", "C", InstallationKind.TRAIN_STATION, 12, 0, 0, 0L); InstallationHandler homeSites = mock(InstallationHandler.class); InstallationHandler hostSites = mock(InstallationHandler.class); + InstallationHandler westSites = mock(InstallationHandler.class); when(home.getInstallationHandler()).thenReturn(homeSites); when(host.getInstallationHandler()).thenReturn(hostSites); + when(west.getInstallationHandler()).thenReturn(westSites); when(homeSites.getById("a")).thenReturn(a); when(hostSites.getById("b")).thenReturn(b); + when(westSites.getById("c")).thenReturn(c); ProvinceManager provinces = new ProvinceManager(); Map map = new HashMap<>(); for (int id = 1; id <= 24; id++) { @@ -177,13 +186,16 @@ void snapshotsAssessForeignHubsWithoutChangingLiveDataOrTheNetwork() { } provinces.start(map); List links = List.of(new Link(1, 22, "home", "a", "host", "b", Mode.RAIL, 0, 0.7, 0.25), - new Link(22, 1, "host", "b", "home", "a", Mode.RAIL, 0, 0.7, 0.25)); + new Link(22, 1, "host", "b", "home", "a", Mode.RAIL, 0, 0.7, 0.25), + new Link(1, 12, "home", "a", "west", "c", Mode.RAIL, 0, 0.7, 0.25), + new Link(12, 1, "west", "c", "home", "a", Mode.RAIL, 0, 0.7, 0.25)); HubNetwork.setLinksForTests(Map.of("guild", links)); try (MockedStatic factions = mockStatic(FactionManager.class); MockedStatic titles = mockStatic(TitleManager.class); MockedStatic config = mockStatic(InstallationConfigLoader.class)) { factions.when(() -> FactionManager.getByString("home")).thenReturn(home); factions.when(() -> FactionManager.getByString("host")).thenReturn(host); + factions.when(() -> FactionManager.getByString("west")).thenReturn(west); factions.when(() -> FactionManager.getGuildByString("guild")).thenReturn(guild); factions.when(FactionManager::getAllGuilds).thenReturn(List.of(guild)); titles.when(() -> TitleManager.getByProvince(anyInt())).thenReturn(null); @@ -210,11 +222,12 @@ void snapshotsAssessForeignHubsWithoutChangingLiveDataOrTheNetwork() { 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); + assertTrue(assessment.forHub(last).taxableIncome() > 0); + assertTrue(assessment.forHub(other).taxableIncome() > 0); + assertEquals(assessment.forHub(last).taxableIncome() * 0.5, assessment.forHub(last).tax(), 1e-6); + assertEquals(assessment.forHub(other).taxableIncome() * 0.1, assessment.forHub(other).tax(), 1e-6); + assertEquals(assessment.forHub(last).taxableIncome(), 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")); @@ -231,10 +244,11 @@ void snapshotsAssessForeignHubsWithoutChangingLiveDataOrTheNetwork() { assertEquals(links, HubNetwork.linksFor(guild)); try (MockedStatic relations = mockStatic(RelationManager.class)) { relations.when(() -> RelationManager.sameRealm(host, home)).thenReturn(true); + relations.when(() -> RelationManager.sameRealm(west, 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); + assertTrue(exempt.forHub(other).taxableIncome() > 0); } HubNetwork.setLinksForTests(null); assertEquals(0, HubTaxService.assess(provinces, guild, List.of(guild)).getTotalTax()); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubServiceTest.java index e755bdd9..2e6039e5 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubServiceTest.java @@ -108,7 +108,7 @@ void build_eachPreconditionFailsOnItsOwn() { assertEquals(BuildFailure.HUB_LIMIT, SupplyHubService.checkBuild(2, false, 2, 2, 0, 1, true)); assertEquals(BuildFailure.NO_FREE_SLOT, SupplyHubService.checkBuild(2, false, 0, 2, 2, 1, true)); assertEquals(BuildFailure.NO_TRADE, SupplyHubService.checkBuild(2, false, 0, 2, 0, 0, true)); - assertEquals(BuildFailure.NO_PERMIT, SupplyHubService.checkBuild(2, false, 0, 2, 0, 1, false)); + assertEquals(BuildFailure.NO_AGREEMENT, SupplyHubService.checkBuild(2, false, 0, 2, 0, 1, false)); assertNull(SupplyHubService.checkBuild(2, false, 1, 2, 1, 0.01, true)); } @@ -123,10 +123,17 @@ void build_failureMessagesAreDistinct() { } @Test - void build_ownFactionNeedsNoPermit_otherFactionDoes() { - assertTrue(SupplyHubService.ownerAllows("rome", "rome", false)); - assertFalse(SupplyHubService.ownerAllows("venice", "rome", false)); - assertTrue(SupplyHubService.ownerAllows("venice", "rome", true)); + void build_ownFactionNeedsNoAgreement_otherFactionDoes() { + Guild guild = mock(Guild.class); + Faction faction = mock(Faction.class); + when(guild.getFaction()).thenReturn(faction); + when(faction.getId()).thenReturn("rome"); + when(guild.getHubAgreements()).thenReturn(new ArrayList<>()); + FactionManager.factions = new ArrayList<>(); + assertTrue(SupplyHubService.hubPermitted(guild, "rome", "harbour")); + assertFalse(SupplyHubService.hubPermitted(guild, "venice", "harbour")); + guild.getHubAgreements().add(new HubAgreement("venice", "harbour", 10, 0, 14, true, true, null, false)); + assertTrue(SupplyHubService.hubPermitted(guild, "venice", "harbour")); } @Test @@ -216,13 +223,13 @@ void standing_eachDormantReason_andActive() { HubStanding missing = SupplyHubService.standing(first, false, true, 2, oldest); assertEquals(DormantReason.INSTALLATION_GONE, missing.reason()); - HubStanding noPermit = SupplyHubService.standing(first, true, false, 2, oldest); - assertEquals(DormantReason.NO_PERMIT, noPermit.reason()); + HubStanding noAgreement = SupplyHubService.standing(first, true, false, 2, oldest); + assertEquals(DormantReason.NO_AGREEMENT, noAgreement.reason()); assertTrue(SupplyHubService.statusText(active).contains("Active")); assertTrue(SupplyHubService.statusText(beyond).contains("hub slots")); assertTrue(SupplyHubService.statusText(missing).contains("no longer exists")); - assertTrue(SupplyHubService.statusText(noPermit).contains("hub permit")); + assertTrue(SupplyHubService.statusText(noAgreement).contains("no hub agreement")); } @Test @@ -275,12 +282,10 @@ void lifecycle_deconstructRemovesHubs_transferKeepsThem() { assertEquals("venice", moved.ownerFactionId()); assertEquals("harbour", moved.installationId()); assertEquals(4L, moved.createdAt()); - HubStanding afterMove = SupplyHubService.standing( - moved, true, SupplyHubService.ownerAllows("rome", "venice", false), 2, List.of(moved)); - assertEquals(DormantReason.NO_PERMIT, afterMove.reason()); - HubStanding permitted = SupplyHubService.standing( - moved, true, SupplyHubService.ownerAllows("rome", "venice", true), 2, List.of(moved)); - assertTrue(permitted.active()); + HubStanding afterMove = SupplyHubService.standing(moved, true, false, 2, List.of(moved)); + assertEquals(DormantReason.NO_AGREEMENT, afterMove.reason()); + HubStanding agreed = SupplyHubService.standing(moved, true, true, 2, List.of(moved)); + assertTrue(agreed.active()); } @Test @@ -291,12 +296,10 @@ void lifecycle_disbandedGuildIsNotScanned_movedGuildFollowsTheNewFaction() { assertEquals(0, SupplyHubService.countAt("rome", "harbour", List.of(staying))); assertEquals(1, SupplyHubService.countAt("rome", "harbour", List.of(gone, staying))); - HubStanding atHome = SupplyHubService.standing( - hub, true, SupplyHubService.ownerAllows("rome", "rome", false), 1, List.of(hub)); + HubStanding atHome = SupplyHubService.standing(hub, true, true, 1, List.of(hub)); assertTrue(atHome.active()); - HubStanding moved = SupplyHubService.standing( - hub, true, SupplyHubService.ownerAllows("venice", "rome", false), 1, List.of(hub)); - assertEquals(DormantReason.NO_PERMIT, moved.reason()); + HubStanding moved = SupplyHubService.standing(hub, true, false, 1, List.of(hub)); + assertEquals(DormantReason.NO_AGREEMENT, moved.reason()); } @Test @@ -328,6 +331,11 @@ void saveLoad_dropsAHubWhoseInstallationIsGone() { handler.load(List.of(installation)); when(faction.getInstallationHandler()).thenReturn(handler); Guild guild = guildWith(loaded.toArray(SupplyHub[]::new)); + when(guild.getFaction()).thenReturn(faction); + when(guild.getHubAgreements()).thenReturn(new ArrayList<>()); + when(guild.getHubOffers()).thenReturn(new ArrayList<>()); + when(guild.getId()).thenReturn("merchants"); + when(guild.getName()).thenReturn("Merchants"); GuildHandler guilds = mock(GuildHandler.class); when(guilds.getGuilds()).thenReturn(List.of(guild)); when(faction.getGuildHandler()).thenReturn(guilds); @@ -341,20 +349,15 @@ void saveLoad_dropsAHubWhoseInstallationIsGone() { } @Test - void permits_toggleAndRoundTripWithTheFaction() { - List permits = new ArrayList<>(); - assertTrue(SupplyHubService.togglePermit(permits, "merchants")); - assertTrue(SupplyHubService.hasPermit(permits, "Merchants")); - assertFalse(SupplyHubService.togglePermit(permits, "merchants")); - assertFalse(SupplyHubService.hasPermit(permits, "merchants")); - SupplyHubService.togglePermit(permits, "merchants"); - - FactionData data = new FactionData(); - data.hubPermits = new ArrayList<>(permits); - String json = JsonUtil.GSON.toJson(data); - assertTrue(json.contains("hub permits")); - FactionData restored = JsonUtil.GSON.fromJson(json, FactionData.class); - assertEquals(List.of("merchants"), restored.hubPermits); + void savedHubPermitsAndHubTaxLoadAndAreNotWritten() { + FactionData loaded = JsonUtil.GSON.fromJson( + "{\"hub permits\":[\"merchants\"],\"hub tax\":12.0,\"id\":\"rome\"}", FactionData.class); + assertEquals("rome", loaded.id); + assertEquals(List.of("merchants"), loaded.hubPermits); + assertEquals(12.0, loaded.hubTax); + String fresh = JsonUtil.GSON.toJson(new FactionData()); + assertFalse(fresh.contains("hub permits")); + assertFalse(fresh.contains("hub tax")); } @Test @@ -365,6 +368,9 @@ void oldUpkeepAndLimitConfigKeysAreIgnored() { old.set("supply-hubs.upkeep-growth", 3.0); SupplyHubService.loadConfig(old); assertEquals(50.0, Cache.supplyHubMaxTax, 1e-9); + assertEquals(500.0, Cache.supplyHubMaxFee, 1e-9); + assertEquals(7, Cache.supplyHubOfferDays); + assertEquals(14, Cache.supplyHubAgreementDays); } @Test 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 5182c1fc..1af40046 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHubTaxTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHubTaxTest.java @@ -163,8 +163,8 @@ void missingBankPayersAreFrozenAndBaseGuildsAlsoPay() { @Test void ratePreviewUsesStoredBasesAndLeavesTheAssessmentAndRateAlone() { Map impact = EconomicPreview.tax(host, TaxTarget.HUB_TAX, null, 40); - assertEquals(-10, impact.get(payer)); - assertEquals(10, impact.get(receiver)); + assertEquals(0, impact.get(payer)); + assertEquals(0, impact.get(receiver)); assertEquals(20, taxes.getHubTax()); assertEquals(10, assessment.getTotalTax()); assertEquals(50, assessment.getTaxableIncome(host)); @@ -190,8 +190,8 @@ void lawPreviewUsesIndependentBracketAndRuleOnBothLedgerSides() { config.set("effects.faction.brackets.hub_tax", "0-10"); LawGroup group = mock(LawGroup.class); IncomePreviewContext.open(IncomePreviewContext.law(host, group, new Law("economy", "free_trade", config))); - assertEquals(-5, payer.getLedger().getIncome(Cashflow.HUB_TAX_PAYMENTS)); - assertEquals(5, receiver.getLedger().getIncome(Cashflow.HUB_TAX)); + assertEquals(-10, payer.getLedger().getIncome(Cashflow.HUB_TAX_PAYMENTS)); + assertEquals(10, receiver.getLedger().getIncome(Cashflow.HUB_TAX)); IncomePreviewContext.clear(); config.set("effects.faction.rules", List.of("hub_tax false", "supply_hubs false")); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubViewTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubViewTest.java index 1a27757b..7669fc7d 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubViewTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubViewTest.java @@ -24,8 +24,8 @@ void guildHubLoreShowsActiveAndDormantState() { List dormant = SupplyHubCreator.guildHubLore( "North Port", "Port", "Northland", 15, - new HubStanding(false, DormantReason.NO_PERMIT), List.of(), 12, 4); - assertTrue(dormant.contains("§cDormant §7(the owning faction has not granted a hub permit)")); + new HubStanding(false, DormantReason.NO_AGREEMENT), List.of(), 12, 4); + assertTrue(dormant.contains("§cDormant §7(there is no hub agreement)")); } @Test 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 9a36737d..1e92553b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxHandlerHubTaxTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxHandlerHubTaxTest.java @@ -96,7 +96,7 @@ void lawPreviewUsesTheProposedHubTaxBracketAndConfiguredCap() { 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)); + assertEquals(5, handler.getTaxRate(TaxTarget.HUB_TAX, null, false)); } @Test @@ -150,16 +150,16 @@ void hubTaxCannotAcquireSpecificRatesAndRestoresItsSavedState() { } @Test - void factionRateRoundTripsAndOldSavesDefaultToZero() { + void savedHubTaxAndPermitsLoadWithoutChangingTheLiveRate() { handler.applyBracket(TaxTarget.HUB_TAX, new Bracket(0, 50)); - 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()); + handler.setHubTax(8); + FactionData data = JsonUtil.GSON.fromJson( + "{\"hub tax\":23.5,\"hub permits\":[\"merchants\"]}", FactionData.class); + assertEquals(23.5, data.hubTax); + assertEquals(java.util.List.of("merchants"), data.hubPermits); + assertEquals(8, handler.getHubTax()); + String fresh = JsonUtil.GSON.toJson(new FactionData()); + assertFalse(fresh.contains("hub tax")); + assertFalse(fresh.contains("hub permits")); } }