From ba27f6613f5c07f4959b44dcd95cfc25e983ab03 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:05:30 +0000 Subject: [PATCH 1/3] Choose hub sites by trade power and drop the daily estimates The proposal list now shows installations by the trade power the guild has there, and by what would arrive, instead of a daily income estimate. The first hub has to be in the guild's own territory. Rate and fee are typed in chat. A dev-only supply-hubs.auto-accept flag lets one player finish an agreement. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../net/tfminecraft/simplefactions/Cache.java | 3 +- .../simplefactions/SimpleFactions.java | 5 + .../guild/hub/HubAgreementService.java | 23 +- .../guild/hub/HubEstimates.java | 682 ++++-------------- .../guild/hub/HubProposalCopy.java | 111 +-- .../guild/hub/InfrastructureMenuCopy.java | 2 +- .../guild/hub/InfrastructurePreview.java | 112 +++ .../guild/hub/SupplyHubCommands.java | 18 + .../guild/hub/SupplyHubService.java | 3 +- .../managers/FactionManager.java | 2 - .../managers/inventory/FactionView.java | 19 - .../managers/inventory/HubProposalMenu.java | 199 ++--- .../managers/inventory/HubTermsPrompt.java | 161 +++++ .../inventory/InstallationBuildConfirm.java | 4 +- .../managers/inventory/SupplyHubView.java | 61 +- src/main/resources/config.yml | 6 +- .../guild/hub/HubAgreementServiceTest.java | 22 + .../guild/hub/HubEstimatesTest.java | 437 +++-------- .../guild/hub/HubProposalCopyTest.java | 75 +- .../guild/hub/InfrastructureMenuCopyTest.java | 2 +- .../guild/hub/SupplyHubServiceTest.java | 22 + 21 files changed, 835 insertions(+), 1134 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/guild/hub/InfrastructurePreview.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/managers/inventory/HubTermsPrompt.java diff --git a/src/main/java/net/tfminecraft/simplefactions/Cache.java b/src/main/java/net/tfminecraft/simplefactions/Cache.java index f986fa55..67a09e4d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/Cache.java +++ b/src/main/java/net/tfminecraft/simplefactions/Cache.java @@ -169,7 +169,8 @@ public static boolean requireProvinces(org.bukkit.command.CommandSender sender) public static double supplyHubMaxFee = 500.0; public static int supplyHubOfferDays = 7; public static int supplyHubAgreementDays = 14; - public static int supplyHubEstimateCandidates = 24; + /** Dev servers only. Sending hub terms also accepts them. */ + public static boolean supplyHubAutoAccept = false; public static double infrastructureFull = 20; public static double infrastructureTarget = 0.75; diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index 432a10ed..38dfa994 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -361,6 +361,9 @@ public void onEnable() { provinceManager.recalculate(); // Hub tax is assessed at the day change; do it once now so menus are right before then. net.tfminecraft.simplefactions.guild.hub.HubTaxService.refresh(provinceManager); + if (Cache.supplyHubAutoAccept) { + getLogger().info("Hub auto-accept is on."); + } long refreshTicks = Cache.infrastructureTrackRefreshSeconds * 20L; trackInfrastructureRefreshTask = getServer().getScheduler().runTaskTimer( this, () -> { refreshTrackProvinces(); }, refreshTicks, refreshTicks); @@ -433,6 +436,8 @@ public void loadConfigs() { public void registerListeners() { getServer().getPluginManager().registerEvents(commands, this); getServer().getPluginManager().registerEvents(inventoryManager, this); + getServer().getPluginManager().registerEvents( + new net.tfminecraft.simplefactions.managers.inventory.HubTermsPrompt(), this); getServer().getPluginManager().registerEvents(bankManager, this); getServer().getPluginManager().registerEvents(titleManager, this); getServer().getPluginManager().registerEvents(playerManager, this); diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementService.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementService.java index 14a3c82f..0e6c0d2c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementService.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementService.java @@ -19,6 +19,8 @@ */ public final class HubAgreementService { static final long DAY_MILLIS = 86_400_000L; + /** Not a player name. Used only while supply-hubs.auto-accept is on. */ + static final String DEV_ACTOR = "dev-auto-accept"; private HubAgreementService() { } @@ -75,6 +77,24 @@ public static AgreementResult accept( return result; } + /** + * Finishes the current offer as the host. Does nothing unless + * {@code supply-hubs.auto-accept} is on, so a live server still needs the other side. + */ + public static AgreementResult acceptAutomatically( + Guild guild, String hostFactionId, String installationId) { + if (!net.tfminecraft.simplefactions.Cache.supplyHubAutoAccept) { + return AgreementResult.fail("§cThat offer is waiting on the other side"); + } + AgreementResult result = accept( + guild, DEV_ACTOR, 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( @@ -258,7 +278,8 @@ static AgreementResult accept( if (offer.lastActor() != null && offer.lastActor().equalsIgnoreCase(actorName)) { return AgreementResult.fail("§cYou can only accept terms the other side sent"); } - if (!actsFor(offer.awaiting(), guild, actorName, hostFactionId, facts)) { + boolean devHost = net.tfminecraft.simplefactions.Cache.supplyHubAutoAccept && DEV_ACTOR.equals(actorName); + if (!devHost && !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"); diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubEstimates.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubEstimates.java index 9c791a2e..a996b254 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubEstimates.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubEstimates.java @@ -2,281 +2,64 @@ import java.util.ArrayList; import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.logging.Level; -import org.bukkit.Bukkit; - -import net.tfminecraft.simplefactions.Cache; -import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.enums.Rules; import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.GuildModifierOverride; +import net.tfminecraft.simplefactions.enums.GuildModifier; import net.tfminecraft.simplefactions.guild.hub.HubTransport.Link; -import net.tfminecraft.simplefactions.guild.hub.HubTransport.Mode; import net.tfminecraft.simplefactions.installation.Installation; import net.tfminecraft.simplefactions.installation.InstallationKind; import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.managers.ProvinceManager; import net.tfminecraft.simplefactions.managers.RelationManager; -import net.tfminecraft.simplefactions.managers.TitleManager; import net.tfminecraft.simplefactions.map.provinces.Province; -import net.tfminecraft.simplefactions.guild.income.EconomicPreview; import net.tfminecraft.simplefactions.objects.Faction; /** - * What-if income for a hub, a realm's infrastructure, or one new installation. - * Menus read the cache. Tax and fee are arithmetic on a result that was already run. + * Where a guild can place a hub, and how much of its trade power would arrive there + * from the hubs it already has. Read from the live map. No income projection. */ public final class HubEstimates { - public enum Group { - READY, - WORTH_BUILDING - } - /** - * One ranked destination. {@code installationId} is null when the installation - * is only imagined. Gains are net income before this hub's tax, fee, and upkeep. + * One existing installation. {@code arrivesHere} is the most trade power an existing + * hub would deliver here on a link that already exists. {@code connected} is false when + * the only way to reach this site is new track. */ - public record Destination( - Group group, + public record Site( String hostFactionId, String installationId, String label, int provinceId, InstallationKind kind, boolean ownRealm, - boolean assumedRailway, - double assumedTrackBlocks, - double operatorGain, - double hostGain, - Map hostGuildGains, - double taxableIncome) { + double tradeHere, + double arrivesHere, + boolean connected) { } - /** Operator pays the tax and the fee. The host realm receives both. Own-realm terms are zero. */ - public record Terms(double operatorNet, double hostNet, double tax, double fee) { + private record Arrival(double trade, boolean connected) { } - /** What building this installation is worth to the realm that would own it. */ - public record InstallationPreview(double infrastructureHere, double realmPerDay, double upkeep) { - public static final InstallationPreview NONE = new InstallationPreview(0, 0, 0); - } - - record Candidate( - Group group, - Faction host, - Installation installation, - boolean ownRealm, - double distance) { - } - - record Planned(List added, boolean assumedRailway, double assumedTrackBlocks) { - } - - record Result( - double operatorGain, - double hostGain, - Map hostGuildGains, - double taxableIncome) { - } - - static void clearForTests() { - proposals = Map.of(); - headlines = Map.of(); - RUNNING.set(false); - } - - private static volatile Map> proposals = Map.of(); - private static volatile Map headlines = Map.of(); - private static final AtomicBoolean RUNNING = new AtomicBoolean(); - private HubEstimates() { } - public static List destinations(Guild guild) { - if (guild == null || guild.getId() == null) { - return List.of(); - } - return destinations(guild.getId()); + public static List sites(Guild guild) { + ProvinceManager provinces = net.tfminecraft.simplefactions.SimpleFactions.getInstance() == null + ? null + : net.tfminecraft.simplefactions.SimpleFactions.getInstance().getProvinceManager(); + List factions = FactionManager.factions == null ? List.of() : new ArrayList<>(FactionManager.factions); + return sites(guild, provinces, factions, SupplyHubService.allGuilds()); } - public static List destinations(String guildId) { - if (guildId == null) { + static List sites(Guild guild, ProvinceManager provinces, List factions, List guilds) { + if (guild == null || guild.getId() == null || !SupplyHubService.allowsSupplyHubs(guild)) { return List.of(); } - List exact = proposals.get(guildId); - if (exact != null) { - return exact; - } - for (Map.Entry> entry : proposals.entrySet()) { - if (guildId.equalsIgnoreCase(entry.getKey())) { - return entry.getValue(); - } - } - return List.of(); - } - - /** How much infrastructure is worth to this realm, from the last daily pass. Positive earns. */ - public static double infrastructureWorth(Faction realm) { - if (realm == null) { - return 0; - } - return infrastructureWorth(realm.getId()); - } - - public static double infrastructureWorth(String realmId) { - if (realmId == null) { - return 0; - } - Double exact = headlines.get(realmId); - if (exact != null) { - return exact; - } - for (Map.Entry entry : headlines.entrySet()) { - if (realmId.equalsIgnoreCase(entry.getKey())) { - return entry.getValue(); - } - } - return 0; - } - - /** False before the first daily pass, so a menu can avoid showing a missing figure as +0. */ - public static boolean hasInfrastructureWorth(Faction realm) { - return realm != null && hasInfrastructureWorth(realm.getId()); - } - - public static boolean hasInfrastructureWorth(String realmId) { - if (realmId == null) { - return false; - } - if (headlines.containsKey(realmId)) { - return true; - } - for (String key : headlines.keySet()) { - if (realmId.equalsIgnoreCase(key)) { - return true; - } - } - return false; - } - - public static Terms applyTerms(Destination destination, int ratePercent, long feeCents) { - if (destination == null) { - return new Terms(0, 0, 0, 0); - } - if (destination.ownRealm()) { - return new Terms(destination.operatorGain(), destination.hostGain(), 0, 0); - } - int rate = Math.max(0, Math.min(100, ratePercent)); - double fee = Math.max(0, feeCents) / 100.0; - double tax = destination.taxableIncome() * rate / 100.0; - return new Terms( - round(destination.operatorGain() - tax - fee), - round(destination.hostGain() + tax + fee), - round(tax), - round(fee)); - } - - /** - * On demand. Adds this kind's infrastructure in the province and reads the realm's net change. - * Writes live trade breakdowns, the same way a law preview does. - */ - public static InstallationPreview previewInstallation( - ProvinceManager live, Faction realm, int provinceId, InstallationKind kind) { - if (live == null || realm == null || kind == null || !live.contains(provinceId)) { - return InstallationPreview.NONE; - } - Province province = live.get(provinceId); - if (province.isSea()) { - return InstallationPreview.NONE; - } - double added = sourceAmount(kind); - Map before = EconomicPreview.projectNets(copy(live)); - ProvinceManager after = copy(live); - if (added > 0) { - after.setExtraInfrastructure(Map.of(provinceId, added)); - } - Map next = EconomicPreview.projectNets(after); - return new InstallationPreview(added, realmDelta(realm, before, next), levelOneUpkeep(kind)); - } - - /** After income and the agreement tick. One pass, then the live trade numbers are put back. */ - public static void scheduleDaily() { - SimpleFactions plugin = SimpleFactions.getInstance(); - if (plugin == null || plugin.getProvinceManager() == null || !Cache.provincesEnabled) { - return; - } - if (!RUNNING.compareAndSet(false, true)) { - return; - } - ProvinceManager live = plugin.getProvinceManager(); - List guilds = new ArrayList<>(FactionManager.getAllGuilds()); - List realms = FactionManager.getCopy(); - try { - Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> runDaily(plugin, live, guilds, realms)); - } catch (RuntimeException | LinkageError ex) { - RUNNING.set(false); - } - } - - private static void runDaily( - SimpleFactions plugin, ProvinceManager live, List guilds, List realms) { - try { - long started = System.nanoTime(); - rebuild(live, guilds, realms); - long millis = (System.nanoTime() - started) / 1_000_000L; - plugin.getLogger().info("Hub estimates took " + millis + " ms for " + guilds.size() + " guilds"); - } catch (RuntimeException ex) { - plugin.getLogger().log(Level.WARNING, "Hub estimates failed", ex); - } finally { - try { - Bukkit.getScheduler().runTask(plugin, () -> { - try { - if (plugin.isEnabled() && plugin.getProvinceManager() == live) { - live.recalculate(); - } - } finally { - RUNNING.set(false); - } - }); - } catch (RuntimeException | LinkageError ex) { - RUNNING.set(false); - } - } - } - - static void rebuild(ProvinceManager live, List guilds, List realms) { - List safeGuilds = guilds == null ? List.of() : guilds; - List safeRealms = realms == null ? List.of() : realms; - Map> nextProposals = new HashMap<>(); - int cap = Math.max(1, Cache.supplyHubEstimateCandidates); - for (Guild guild : safeGuilds) { - if (guild == null || guild.getId() == null || !guild.hasCapital() || guild.getLedger() == null) { - continue; - } - if (!SupplyHubService.allowsSupplyHubs(guild)) { - continue; - } - nextProposals.put(guild.getId(), List.copyOf(build(live, guild, safeGuilds, safeRealms, cap))); - } - Map nextHeadlines = headlinesFor(live, safeRealms); - proposals = Map.copyOf(nextProposals); - headlines = Map.copyOf(nextHeadlines); - } - - static List shortlist( - Guild guild, List guilds, List factions, ProvinceManager provinces, int cap) { - int limit = Math.max(1, cap); - List partners = activeHubs(guild, guilds); - Set occupied = new HashSet<>(); - List ready = new ArrayList<>(); + List sources = activeSources(guild, guilds); + List sites = new ArrayList<>(); if (factions != null) { for (Faction host : factions) { if (host == null || host.getId() == null || host.getInstallationHandler() == null @@ -284,308 +67,204 @@ static List shortlist( continue; } boolean realm = sameRealm(guild.getFaction(), host); + if (!realm && !host.hasFactionRule(Rules.HUB_TAX)) { + continue; + } for (Installation installation : host.getInstallationHandler().getAll()) { - if (installation == null || installation.getId() == null) { - continue; - } - occupied.add(installation.getProvince()); - if (!hubKind(installation.getKind())) { - continue; - } - int slots = hubSlots(installation); - if (slots <= 0) { - continue; - } - if (SupplyHubService.hasHub(guild.getSupplyHubs(), host.getId(), installation.getId())) { - continue; - } - if (SupplyHubService.countAt(host.getId(), installation.getId(), guilds) >= slots) { + if (!eligible(guild, host, installation, guilds)) { continue; } - if (!realm && !host.hasFactionRule(Rules.HUB_TAX)) { - continue; - } - ready.add(new Candidate( - Group.READY, host, installation, realm, - closeness(guild, installation.getCenterX(), installation.getCenterZ(), partners, provinces))); + double here = tradeHere(provinces, guild, installation.getProvince()); + Arrival delivered = arrival(guild, installation, sources, provinces); + sites.add(new Site( + host.getId(), + installation.getId(), + installation.getName() == null ? installation.getId() : installation.getName(), + installation.getProvince(), + installation.getKind(), + realm, + here, + delivered.trade(), + delivered.connected())); } } } - List worth = new ArrayList<>(); - if (provinces != null) { - for (Province province : provinces.getProvinces()) { - if (province == null || !province.isValid() || province.isSea() || occupied.contains(province.getId())) { - continue; - } - Faction host = TitleManager.getByProvince(province.getId()); - if (host == null || host.getId() == null) { - continue; - } - boolean realm = sameRealm(guild.getFaction(), host); - if (!realm && !host.hasFactionRule(Rules.HUB_TAX)) { - continue; - } - Installation imagined = new Installation( - "proposed-" + province.getId(), - terrainLabel(province), - InstallationKind.TRAIN_STATION, - province.getId(), - province.getCenterX(), - province.getCenterZ(), - 0L); - worth.add(new Candidate( - Group.WORTH_BUILDING, host, imagined, realm, - closeness(guild, province.getCenterX(), province.getCenterZ(), partners, provinces))); - } - } - List chosen = new ArrayList<>(); - chosen.addAll(closest(ready, limit)); - chosen.addAll(closest(worth, limit)); - return chosen; + return List.copyOf(sites); + } + + /** Trade power that would be placed at {@code target} by the guild's existing hubs. */ + static double arrives(Guild guild, Installation target, List sources, ProvinceManager provinces) { + return arrival(guild, target, sources, provinces).trade(); } - /** New links from the guild's active hubs to this installation. Assumed rail is a straight line. */ - static Planned plan(List existing, Installation target, String targetFactionId, ProvinceManager provinces) { - List added = new ArrayList<>(); - boolean assumed = false; - double blocks = 0; - if (existing == null || target == null) { - return new Planned(added, false, 0); + private static Arrival arrival( + Guild guild, Installation target, List sources, ProvinceManager provinces) { + if (guild == null || target == null || sources == null || sources.isEmpty() || provinces == null) { + return new Arrival(0, false); } - String toFaction = targetFactionId == null ? "" : targetFactionId; - for (Installation from : existing) { - if (from == null || from.getProvince() == target.getProvince()) { + double bonus = GuildModifierOverride.resolve(guild, GuildModifier.HUB_TRADE); + double best = 0; + boolean connected = false; + for (Installation source : sources) { + if (source == null || source.getProvince() == target.getProvince()) { continue; } - String fromFaction = SupplyHubService.owningFactionId(from); - Choice forward = choose(from, fromFaction, target, toFaction, provinces); - Choice back = choose(target, toFaction, from, fromFaction, provinces); - if (forward != null) { - added.add(forward.link()); - if (forward.assumed()) { - assumed = true; - blocks = Math.max(blocks, forward.link().distance()); - } + Link link = HubNetwork.connect(source, target, provinces); + if (link == null) { + continue; } - if (back != null) { - added.add(back.link()); - if (back.assumed()) { - assumed = true; - blocks = Math.max(blocks, back.link().distance()); - } + connected = true; + double delivered = tradeHere(provinces, guild, source.getProvince()) * link.boostedTradeFactor(bonus); + if (delivered > best) { + best = delivered; } } - return new Planned(List.copyOf(added), assumed, blocks); + return new Arrival(best, connected); } - static Result compare( - ProvinceManager live, Guild operator, Faction host, List links, Map extra) { - ProvinceManager baseline = copy(live); - Map before = EconomicPreview.projectNets(baseline); - double gross = baseline.getGrossTradeIncome(operator); - return diff(live, operator, host, before, gross, links, extra); - } - - private static List build( - ProvinceManager live, Guild guild, List guilds, List factions, int cap) { - List chosen = shortlist(guild, guilds, factions, live, cap); - if (chosen.isEmpty()) { + public static List territory(List sites) { + if (sites == null || sites.isEmpty()) { return List.of(); } - ProvinceManager baseline = copy(live); - Map before = EconomicPreview.projectNets(baseline); - double gross = baseline.getGrossTradeIncome(guild); - List partners = activeHubs(guild, guilds); - List current = new ArrayList<>(HubNetwork.linksFor(guild)); - List built = new ArrayList<>(); - for (Candidate candidate : chosen) { - Planned planned = plan(partners, candidate.installation(), candidate.host().getId(), live); - List links = new ArrayList<>(current); - links.addAll(planned.added()); - Map extra = null; - if (candidate.group() == Group.WORTH_BUILDING) { - double amount = sourceAmount(candidate.installation().getKind()); - if (amount > 0) { - extra = Map.of(candidate.installation().getProvince(), amount); - } + List shown = new ArrayList<>(); + for (Site site : sites) { + if (site != null && site.ownRealm()) { + shown.add(site); } - Result result = diff(live, guild, candidate.host(), before, gross, links, extra); - String installationId = candidate.group() == Group.READY ? candidate.installation().getId() : null; - built.add(new Destination( - candidate.group(), - candidate.host().getId(), - installationId, - candidate.installation().getName(), - candidate.installation().getProvince(), - candidate.installation().getKind(), - candidate.ownRealm(), - planned.assumedRailway(), - planned.assumedTrackBlocks(), - result.operatorGain(), - result.hostGain(), - result.hostGuildGains(), - result.taxableIncome())); } - built.sort(Comparator.comparingInt((Destination destination) -> destination.group() == Group.READY ? 0 : 1) - .thenComparing(Comparator.comparingDouble(Destination::operatorGain).reversed())); - return built; + return List.copyOf(shown); } - private static Result diff( - ProvinceManager live, Guild operator, Faction host, - Map before, double baselineGross, List links, Map extra) { - ProvinceManager after = copy(live); - if (operator.getId() != null && links != null) { - after.setHubLinksOverride(Map.of(operator.getId(), List.copyOf(links))); - } - if (extra != null && !extra.isEmpty()) { - after.setExtraInfrastructure(extra); + public static List withTrade(List sites) { + if (sites == null || sites.isEmpty()) { + return List.of(); } - Map next = EconomicPreview.projectNets(after); - double operatorGain = round(next.getOrDefault(operator, 0.0) - before.getOrDefault(operator, 0.0)); - double hostGain = 0; - Map hostGuilds = new HashMap<>(); - Set seen = new HashSet<>(); - seen.addAll(before.keySet()); - seen.addAll(next.keySet()); - for (Guild other : seen) { - if (other == null || sameGuild(other, operator) || !sameRealm(host, other.getFaction())) { - continue; - } - double delta = round(next.getOrDefault(other, 0.0) - before.getOrDefault(other, 0.0)); - hostGain += delta; - if (delta != 0 && other.getId() != null) { - hostGuilds.put(other.getId(), delta); + List shown = new ArrayList<>(); + for (Site site : sites) { + if (site != null && tradeHundredths(site.tradeHere()) > 0) { + shown.add(site); } } - double taxable = round(Math.max(0, after.getGrossTradeIncome(operator) - baselineGross)); - return new Result(operatorGain, round(hostGain), Map.copyOf(hostGuilds), taxable); + return List.copyOf(shown); } - private static Map headlinesFor(ProvinceManager live, List realms) { - if (realms.isEmpty()) { - return Map.of(); + /** Own installations tied for the most trade power already in the province. */ + public static List bestTerritory(List sites) { + if (sites == null || sites.isEmpty()) { + return List.of(); } - Map before = EconomicPreview.projectNets(copy(live)); - ProvinceManager off = copy(live); - off.setInfrastructureSuppressed(true); - Map after = EconomicPreview.projectNets(off); - Map worth = new HashMap<>(); - for (Faction realm : realms) { - if (realm == null || realm.getId() == null) { + long best = Long.MIN_VALUE; + boolean any = false; + for (Site site : sites) { + if (site == null || !site.ownRealm()) { continue; } - worth.put(realm.getId(), realmDelta(realm, after, before)); + any = true; + best = Math.max(best, tradeHundredths(site.tradeHere())); + } + if (!any) { + return List.of(); + } + List shown = new ArrayList<>(); + for (Site site : sites) { + if (site != null && site.ownRealm() && tradeHundredths(site.tradeHere()) == best) { + shown.add(site); + } } - return worth; + return List.copyOf(shown); } - private static double realmDelta(Faction realm, Map before, Map after) { - double sum = 0; - Set guilds = new HashSet<>(); - guilds.addAll(before.keySet()); - guilds.addAll(after.keySet()); - for (Guild guild : guilds) { - if (guild == null || !sameRealm(realm, guild.getFaction())) { - continue; + public static List byTradePower(List sites) { + return sorted(sites, Comparator.comparingLong((Site site) -> tradeHundredths(site.tradeHere())).reversed()); + } + + /** Sites an existing hub can already reach. New track is not a connection. */ + public static List connected(List sites) { + if (sites == null || sites.isEmpty()) { + return List.of(); + } + List shown = new ArrayList<>(); + for (Site site : sites) { + if (site != null && site.connected()) { + shown.add(site); } - sum += after.getOrDefault(guild, 0.0) - before.getOrDefault(guild, 0.0); } - return round(sum); + return List.copyOf(shown); } - private record Choice(Link link, boolean assumed) { + public static List byArrival(List sites) { + return sorted(sites, Comparator.comparingDouble(Site::arrivesHere).reversed() + .thenComparing(Comparator.comparingDouble(Site::tradeHere).reversed())); } - private static Choice choose( - Installation from, String fromFactionId, Installation to, String toFactionId, ProvinceManager provinces) { - if (from == null || to == null || from.getProvince() == to.getProvince()) { - return null; - } - Link real = provinces == null ? null : HubNetwork.connect(from, to, provinces); - Link assumed = assumedRail(from, fromFactionId, to, toFactionId); - if (real != null && real.mode() == Mode.RAIL) { - return new Choice(real, false); + private static List sorted(List sites, Comparator order) { + if (sites == null || sites.isEmpty()) { + return List.of(); } - if (assumed != null && (real == null || assumed.tradeFactor() > real.tradeFactor())) { - return new Choice(assumed, true); + List sorted = new ArrayList<>(); + for (Site site : sites) { + if (site != null) { + sorted.add(site); + } } - return real == null ? null : new Choice(real, false); + sorted.sort(order); + return List.copyOf(sorted); } - private static Link assumedRail(Installation from, String fromFactionId, Installation to, String toFactionId) { - if (from.getProvince() == to.getProvince()) { - return null; + private static boolean eligible(Guild guild, Faction host, Installation installation, List guilds) { + if (installation == null || installation.getId() == null || !hubKind(installation.getKind())) { + return false; } - double dx = from.getCenterX() - to.getCenterX(); - double dz = from.getCenterZ() - to.getCenterZ(); - double distance = Math.sqrt(dx * dx + dz * dz); - if (!Double.isFinite(distance) || !HubTransport.inRange(HubTransport.rates(Mode.RAIL), distance)) { - return null; + int slots = hubSlots(installation); + if (slots <= 0) { + return false; + } + if (SupplyHubService.hasHub(guild.getSupplyHubs(), host.getId(), installation.getId())) { + return false; } - return HubTransport.link( - from, fromFactionId == null ? "" : fromFactionId, - to, toFactionId == null ? "" : toFactionId, - Mode.RAIL, distance); + return SupplyHubService.countAt(host.getId(), installation.getId(), guilds) < slots; } - private static List activeHubs(Guild guild, List guilds) { - List sites = new ArrayList<>(); + private static List activeSources(Guild guild, List guilds) { + List sources = new ArrayList<>(); if (guild == null || guild.getSupplyHubs() == null) { - return sites; + return sources; } for (SupplyHub hub : guild.getSupplyHubs()) { + if (hub == null) { + continue; + } Installation installation = SupplyHubService.findInstallation(hub.ownerFactionId(), hub.installationId()); if (installation == null) { continue; } - int slots = hubSlots(installation); boolean allowed = SupplyHubService.hubPermitted(guild, hub.ownerFactionId(), hub.installationId()); SupplyHubService.HubStanding standing = SupplyHubService.standing( - guild, hub, true, allowed, slots, + guild, hub, true, allowed, hubSlots(installation), SupplyHubService.atInstallation(hub.ownerFactionId(), hub.installationId(), guilds)); if (standing.active()) { - sites.add(installation); + sources.add(installation); } } - return sites; + return sources; } - private static List closest(List sites, int cap) { - sites.sort(Comparator.comparingDouble(Candidate::distance)); - if (sites.size() <= cap) { - return sites; - } - return new ArrayList<>(sites.subList(0, cap)); - } - - private static double closeness( - Guild guild, int x, int z, List hubs, ProvinceManager provinces) { - double best = Double.POSITIVE_INFINITY; - if (hubs != null) { - for (Installation hub : hubs) { - if (hub == null) { - continue; - } - best = Math.min(best, distance(hub.getCenterX(), hub.getCenterZ(), x, z)); - } - } - if (best < Double.POSITIVE_INFINITY) { - return best; + private static double tradeHere(ProvinceManager provinces, Guild guild, int provinceId) { + if (provinces == null || guild == null || guild.getId() == null) { + return 0; } - if (guild != null && guild.hasCapital() && provinces != null) { - Province capital = provinces.get(guild.getCapital()); - if (capital != null && capital.isValid()) { - return distance(capital.getCenterX(), capital.getCenterZ(), x, z); - } + Province province = provinces.get(provinceId); + if (province == null || !province.isValid()) { + return 0; } - return 0; + return province.getRawGuildTrade(guild); } - private static double distance(double x1, double z1, double x2, double z2) { - double dx = x1 - x2; - double dz = z1 - z2; - return Math.sqrt(dx * dx + dz * dz); + private static long tradeHundredths(double trade) { + if (!Double.isFinite(trade) || trade <= 0) { + return 0; + } + return Math.round(trade * 100.0); } private static boolean hubKind(InstallationKind kind) { @@ -602,39 +281,6 @@ private static int hubSlots(Installation installation) { } } - static double sourceAmount(InstallationKind kind) { - if (kind == null) { - return 0; - } - return switch (kind) { - case TRAIN_STATION -> Cache.infrastructureStation; - case PORT -> Cache.infrastructurePort; - case AIRPORT -> Cache.infrastructureAirport; - default -> 0; - }; - } - - private static double levelOneUpkeep(InstallationKind kind) { - if (kind == null) { - return 0; - } - try { - return InstallationConfigLoader.getDailyUpkeep(kind, 1); - } catch (IllegalStateException ex) { - return switch (kind) { - case TRAIN_STATION -> 5; - case PORT -> 15; - case AIRPORT -> 20; - case FORT -> 30; - }; - } - } - - private static String terrainLabel(Province province) { - String name = province.getTerrain().name().toLowerCase(Locale.ROOT); - return name.substring(0, 1).toUpperCase(Locale.ROOT) + name.substring(1); - } - private static boolean sameRealm(Faction left, Faction right) { if (left == null || right == null) { return false; @@ -644,24 +290,4 @@ private static boolean sameRealm(Faction left, Faction right) { } return RelationManager.sameRealm(left, right); } - - private static boolean sameGuild(Guild left, Guild right) { - if (left == right) { - return true; - } - return left != null && right != null && left.getId() != null && left.getId().equalsIgnoreCase(right.getId()); - } - - private static ProvinceManager copy(ProvinceManager live) { - ProvinceManager snapshot = live.createSnapshotShell(); - snapshot.copyAllDataFrom(live); - return snapshot; - } - - private static double round(double value) { - if (!Double.isFinite(value)) { - return 0; - } - return Math.round(value * 100.0) / 100.0; - } } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubProposalCopy.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubProposalCopy.java index 1d2b3bd0..da92a974 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubProposalCopy.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/HubProposalCopy.java @@ -2,14 +2,11 @@ import java.util.ArrayList; import java.util.List; -import java.util.Map; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Destination; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Group; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Terms; +import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Site; import net.tfminecraft.simplefactions.utils.Formatter; -/** Lore for the hub proposal list and the negotiation chest. Tax and fee are not recalculated here. */ +/** Lore for the hub proposal list and the negotiation chest. */ public final class HubProposalCopy { private HubProposalCopy() { } @@ -30,22 +27,18 @@ public static long clampFeeCents(long cents, double maxFeeDenars) { return Math.max(0, Math.min(maxCents, cents)); } - public static List destinationLore(Destination destination) { + public static List destinationLore(Site site, boolean showArrival) { List lore = new ArrayList<>(); - if (destination == null) { - lore.add("§7Estimates are not ready yet"); + if (site == null) { + lore.add("§7This installation is not available"); return lore; } - lore.add(destination.group() == Group.READY ? "§aReady now" : "§eWorth building"); - lore.add("§7You: about " + signed(destination.operatorGain()) + "§7 a day"); - lore.add("§7Host: about " + signed(destination.hostGain()) + "§7 a day"); - if (destination.assumedRailway()) { - lore.add("§7No railway yet; assumes about " - + Math.round(destination.assumedTrackBlocks()) + " blocks of track"); + lore.add("§aReady now"); + lore.add("§7Trade power here: §e" + Formatter.formatDouble(site.tradeHere())); + if (showArrival) { + lore.add("§7Arrives here: §e" + Formatter.formatDouble(site.arrivesHere())); } - if (destination.group() == Group.WORTH_BUILDING || destination.installationId() == null) { - lore.add("§7A station has to be built here first"); - } else if (destination.ownRealm()) { + if (site.ownRealm()) { lore.add("§eClick to build this hub"); } else { lore.add("§eClick to negotiate"); @@ -53,87 +46,21 @@ public static List destinationLore(Destination destination) { return lore; } - public static List operatorLines(Destination destination, Terms terms) { + public static List powerLines(Site site) { List lore = new ArrayList<>(); - if (destination == null || terms == null) { - lore.add("§7Estimates are not ready yet"); + if (site == null) { + lore.add("§7This installation is not available"); return lore; } - if (destination.ownRealm()) { - lore.add("§7Your guild: about " + signed(destination.operatorGain()) + "§7 a day"); - return lore; - } - lore.add("§7Your guild: about " + signed(destination.operatorGain()) + "§7 a day before tax"); - lore.add("§7Tax §c-" + Formatter.formatMoney(terms.tax()) - + "§7, fee §c-" + Formatter.formatMoney(terms.fee())); - lore.add("§7Net " + signed(terms.operatorNet()) + "§7 a day"); + lore.add("§7Trade power here: §e" + Formatter.formatDouble(site.tradeHere())); + lore.add("§7Arrives here: §e" + Formatter.formatDouble(site.arrivesHere())); return lore; } - public static List hostLines(Destination destination, Terms terms, Map guildNames) { - List lore = new ArrayList<>(); - if (destination == null || terms == null) { - lore.add("§7Estimates are not ready yet"); - return lore; - } - if (destination.ownRealm()) { - lore.add("§7Your realm: about " + signed(destination.hostGain()) + "§7 a day"); - return lore; - } - lore.add("§7Your guilds: about " + signed(destination.hostGain()) + "§7 a day"); - lore.add("§7Hub tax §a+" + Formatter.formatMoney(terms.tax()) - + "§7, fee §a+" + Formatter.formatMoney(terms.fee())); - lore.add("§7Net " + signed(terms.hostNet()) + "§7 a day"); - if (destination.hostGuildGains() != null) { - for (Map.Entry entry : destination.hostGuildGains().entrySet()) { - if (entry.getKey() == null || entry.getValue() == null) { - continue; - } - String name = guildNames == null ? null : guildNames.get(entry.getKey()); - lore.add("§7" + (name == null || name.isBlank() ? entry.getKey() : name) - + ": about " + signed(entry.getValue())); - } - } - return lore; - } - - /** - * The rate at which the host's net is zero, given the fee already on the screen. - * The host gains before that rate when their other guilds already gain from the hub. - */ - public static String breakEvenLine(Destination destination, long feeCents, int minPercent, int maxPercent) { - if (destination == null || destination.ownRealm()) { - return "§7A hub in your own realm has no tax or fee"; - } - double fee = Math.max(0, feeCents) / 100.0; - double taxable = destination.taxableIncome(); - if (taxable <= 0.0001) { - if (destination.hostGain() + fee >= 0) { - return "§7The host gains even at " + minPercent + "%"; - } - return "§7The host does not break even inside " + minPercent + "-" + maxPercent + "%"; - } - int shown = (int) Math.round((-destination.hostGain() - fee) * 100.0 / taxable); - if (shown < minPercent) { - return "§7The host gains even at " + minPercent + "%"; - } - if (shown > maxPercent) { - return "§7The host does not break even inside " + minPercent + "-" + maxPercent + "%"; - } - if (fee > 0) { - return "§7Break-even is about §e" + shown + "%§7 at this fee"; - } - return "§7Break-even is about §e" + shown + "%"; - } - - static String signed(double amount) { - String money = Formatter.formatMoney(Math.abs(amount)); - if (amount > 0.0001) { - return "§a+" + money; - } - if (amount < -0.0001) { - return "§c-" + money; + public static List hostLines(Site site) { + if (site != null && site.ownRealm()) { + return List.of("§7A hub in your own realm has no tax or fee"); } - return "§e" + money; + return List.of("§7The rate and the daily fee are what this realm charges."); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/InfrastructureMenuCopy.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/InfrastructureMenuCopy.java index ddba856a..a81d32bd 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/InfrastructureMenuCopy.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/InfrastructureMenuCopy.java @@ -20,7 +20,7 @@ public static String calculating() { return "§7Working out what this would earn..."; } - public static List installationPreview(HubEstimates.InstallationPreview preview) { + public static List installationPreview(InfrastructurePreview.InstallationPreview preview) { if (preview == null) { return List.of(calculating()); } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/InfrastructurePreview.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/InfrastructurePreview.java new file mode 100644 index 00000000..e4d85dbe --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/InfrastructurePreview.java @@ -0,0 +1,112 @@ +package net.tfminecraft.simplefactions.guild.hub; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.EconomicPreview; +import net.tfminecraft.simplefactions.installation.InstallationKind; +import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader; +import net.tfminecraft.simplefactions.managers.ProvinceManager; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Faction; + +/** What one new installation would earn the realm. Used by the build confirmation. */ +public final class InfrastructurePreview { + public record InstallationPreview(double infrastructureHere, double realmPerDay, double upkeep) { + public static final InstallationPreview NONE = new InstallationPreview(0, 0, 0); + } + + private InfrastructurePreview() { + } + + /** + * Adds this kind's infrastructure in the province and reads the realm's net change. + * Writes live trade breakdowns, the same way a law preview does. + */ + public static InstallationPreview previewInstallation( + ProvinceManager live, Faction realm, int provinceId, InstallationKind kind) { + if (live == null || realm == null || kind == null || !live.contains(provinceId)) { + return InstallationPreview.NONE; + } + Province province = live.get(provinceId); + if (province.isSea()) { + return InstallationPreview.NONE; + } + double added = sourceAmount(kind); + Map before = EconomicPreview.projectNets(copy(live)); + ProvinceManager after = copy(live); + if (added > 0) { + after.setExtraInfrastructure(Map.of(provinceId, added)); + } + Map next = EconomicPreview.projectNets(after); + return new InstallationPreview(added, realmDelta(realm, before, next), levelOneUpkeep(kind)); + } + + private static double sourceAmount(InstallationKind kind) { + if (kind == null) { + return 0; + } + return switch (kind) { + case TRAIN_STATION -> Cache.infrastructureStation; + case PORT -> Cache.infrastructurePort; + case AIRPORT -> Cache.infrastructureAirport; + default -> 0; + }; + } + + private static double levelOneUpkeep(InstallationKind kind) { + if (kind == null) { + return 0; + } + try { + return InstallationConfigLoader.getDailyUpkeep(kind, 1); + } catch (IllegalStateException ex) { + return switch (kind) { + case TRAIN_STATION -> 5; + case PORT -> 15; + case AIRPORT -> 20; + case FORT -> 30; + }; + } + } + + private static double realmDelta(Faction realm, Map before, Map after) { + double sum = 0; + Set guilds = new HashSet<>(); + guilds.addAll(before.keySet()); + guilds.addAll(after.keySet()); + for (Guild guild : guilds) { + if (guild == null || !sameRealm(realm, guild.getFaction())) { + continue; + } + sum += after.getOrDefault(guild, 0.0) - before.getOrDefault(guild, 0.0); + } + return round(sum); + } + + private static double round(double value) { + if (!Double.isFinite(value)) { + return 0; + } + return Math.round(value * 100.0) / 100.0; + } + + private static boolean sameRealm(Faction left, Faction right) { + if (left == null || right == null) { + return false; + } + if (left.getId() != null && left.getId().equalsIgnoreCase(right.getId())) { + return true; + } + return net.tfminecraft.simplefactions.managers.RelationManager.sameRealm(left, right); + } + + private static ProvinceManager copy(ProvinceManager live) { + ProvinceManager snapshot = live.createSnapshotShell(); + snapshot.copyAllDataFrom(live); + return snapshot; + } +} 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 c5bee9a0..777f1e18 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubCommands.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubCommands.java @@ -16,6 +16,7 @@ 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.map.provinces.Province; import net.tfminecraft.simplefactions.objects.Faction; @@ -89,6 +90,11 @@ public static boolean place(Player player, Guild guild, String ownerFactionId, S player.sendMessage("§cThat installation is not there"); return false; } + if ((guild.getSupplyHubs() == null || guild.getSupplyHubs().isEmpty()) + && !inOwnTerritory(guild, ownerFactionId)) { + player.sendMessage("§cYour first hub has to be in your own territory"); + return false; + } boolean capitalHub = hasCapitalHub(guild); SupplyHubTutorial.Decision tutorial = SupplyHubTutorial.decision( guild.hasCapital(), capitalHub, installation.getProvince() == guild.getCapital(), @@ -153,6 +159,18 @@ private static boolean gotIt(Player player) { return true; } + private static boolean inOwnTerritory(Guild guild, String ownerFactionId) { + Faction guildFaction = guild == null ? null : guild.getFaction(); + if (guildFaction == null || ownerFactionId == null || guildFaction.getId() == null) { + return false; + } + if (guildFaction.getId().equalsIgnoreCase(ownerFactionId)) { + return true; + } + Faction owner = FactionManager.getByString(ownerFactionId); + return owner != null && RelationManager.sameRealm(owner, guildFaction); + } + private static boolean hasCapitalHub(Guild guild) { if (guild == null || !guild.hasCapital()) return false; for (SupplyHub hub : guild.getSupplyHubs()) { 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 78910f41..84180dd4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubService.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubService.java @@ -82,8 +82,7 @@ public static void loadConfig(FileConfiguration config) { Cache.supplyHubOfferDays = Math.max(1, offerDays); int agreementDays = config == null ? 14 : config.getInt("supply-hubs.agreement-days", 14); Cache.supplyHubAgreementDays = Math.max(1, agreementDays); - int candidates = config == null ? 24 : config.getInt("supply-hubs.estimate-candidates", 24); - Cache.supplyHubEstimateCandidates = Math.max(1, candidates); + Cache.supplyHubAutoAccept = config != null && config.getBoolean("supply-hubs.auto-accept", false); } public static int limit(Guild guild) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java index 02d096e0..06ea68cc 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java @@ -512,8 +512,6 @@ public void time() { runDailyStep("hub agreements", () -> net.tfminecraft.simplefactions.guild.hub.HubAgreementService.tick( getAllGuilds(), System.currentTimeMillis())); - runDailyStep("hub estimates", () -> - net.tfminecraft.simplefactions.guild.hub.HubEstimates.scheduleDaily()); timer = 0; day++; } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java index 384306a8..8c147714 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java @@ -23,11 +23,8 @@ import net.tfminecraft.simplefactions.managers.RelationManager; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.rest.BannerFetcher; -import net.tfminecraft.simplefactions.Cache; import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.guild.Guild; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates; -import net.tfminecraft.simplefactions.guild.hub.InfrastructureMenuCopy; import net.tfminecraft.simplefactions.utils.FactionRanker; import net.tfminecraft.simplefactions.enums.MenuItemType; import net.tfminecraft.simplefactions.enums.RankType; @@ -132,9 +129,6 @@ public void factionView(Player player, Faction f, Inventory i) { i.setItem(11, creator.createMenuItem(player, f, MenuItemType.GOVERNMENT)); i.setItem(12, creator.createMenuItem(player, f, MenuItemType.WEALTH)); i.setItem(13, creator.createMenuItem(player, f, MenuItemType.PRESTIGE)); - if (Cache.provincesEnabled) { - i.setItem(21, infrastructureHeadline(f)); - } i.setItem(14, creator.createMenuItem(player, f, MenuItemType.MEMBERS)); i.setItem(23, creator.createMenuItem(player, f, MenuItemType.GUILDS)); String hubOffers = net.tfminecraft.simplefactions.guild.hub.HubAgreementService.joinSummary(player.getName()); @@ -157,19 +151,6 @@ public void factionView(Player player, Faction f, Inventory i) { if(open) player.openInventory(i); } - @SuppressWarnings("deprecation") - private ItemStack infrastructureHeadline(Faction faction) { - ItemStack item = new ItemStack(Material.RAIL); - ItemMeta meta = item.getItemMeta(); - meta.setDisplayName("§6Infrastructure"); - String line = HubEstimates.hasInfrastructureWorth(faction) - ? InfrastructureMenuCopy.headline(HubEstimates.infrastructureWorth(faction)) - : InfrastructureMenuCopy.headlineUnknown(); - meta.setLore(List.of(line)); - item.setItemMeta(meta); - return item; - } - // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void factionGuildsView(Player player, Faction f, Inventory i) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/HubProposalMenu.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/HubProposalMenu.java index 3d624eed..054f21a8 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/HubProposalMenu.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/HubProposalMenu.java @@ -1,9 +1,7 @@ package net.tfminecraft.simplefactions.managers.inventory; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; import org.bukkit.Material; import org.bukkit.NamespacedKey; @@ -26,11 +24,10 @@ import net.tfminecraft.simplefactions.guild.hub.HubAgreementService.PendingMatter; import net.tfminecraft.simplefactions.guild.hub.HubAgreementService.RateRange; import net.tfminecraft.simplefactions.guild.hub.HubEstimates; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Destination; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Group; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Terms; +import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Site; import net.tfminecraft.simplefactions.guild.hub.HubOffer; import net.tfminecraft.simplefactions.guild.hub.HubProposalCopy; +import net.tfminecraft.simplefactions.guild.hub.SupplyHubService; import net.tfminecraft.simplefactions.guild.hub.OfferSide; import net.tfminecraft.simplefactions.keys.Keys; import net.tfminecraft.simplefactions.managers.FactionManager; @@ -41,7 +38,6 @@ /** * The proposal list, the shared negotiation chest, and the host's offer list. - * Changing the rate or the fee only reruns {@link HubEstimates#applyTerms}. */ public final class HubProposalMenu { private static final String SEP = "\u001f"; @@ -51,6 +47,10 @@ private HubProposalMenu() { } public static void openProposals(Player player, Guild guild, int page) { + openProposals(player, guild, page, false); + } + + static void openProposals(Player player, Guild guild, int page, boolean showAll) { if (player == null || guild == null) { return; } @@ -58,31 +58,52 @@ public static void openProposals(Player player, Guild guild, int page) { player.sendMessage("§cOnly the guild leader can propose a hub"); return; } - List destinations = HubEstimates.destinations(guild); - int pages = Math.max(1, (destinations.size() + PAGE_SIZE - 1) / PAGE_SIZE); + List sites = HubEstimates.sites(guild); + boolean noHubs = guild.getSupplyHubs() == null || guild.getSupplyHubs().isEmpty(); + boolean focused = noHubs && !showAll; + List territory = noHubs ? HubEstimates.territory(sites) : sites; + List tradable = noHubs ? HubEstimates.withTrade(territory) : territory; + List listed = noHubs + ? (focused ? HubEstimates.bestTerritory(tradable) : tradable) + : HubEstimates.connected(sites); + listed = noHubs ? HubEstimates.byTradePower(listed) : HubEstimates.byArrival(listed); + int pages = Math.max(1, (listed.size() + PAGE_SIZE - 1) / PAGE_SIZE); int shown = Math.max(0, Math.min(page, pages - 1)); Inventory inventory = SimpleFactions.plugin.getServer().createInventory( - new SFInventoryHolder(guild.getId(), SFGUI.HUB_PROPOSAL_LIST, shown), - 54, "§7Propose a Hub"); - if (destinations.isEmpty()) { - inventory.setItem(22, SupplyHubCreator.item(Material.PAPER, "§7No estimates yet", List.of( - "§7Where a hub would pay is worked out once a day.", - "§7Check back after the next income pass."))); + new SFInventoryHolder(guild.getId(), SFGUI.HUB_PROPOSAL_LIST, shown, showAll && noHubs), + 54, noHubs ? "§7Main Hub" : "§7Propose a Hub"); + if (listed.isEmpty()) { + if (noHubs && territory.isEmpty()) { + inventory.setItem(22, SupplyHubCreator.item(Material.PAPER, "§7You have no installation", List.of( + "§7Build a port, airport, or train station in your territory."))); + } else if (noHubs) { + inventory.setItem(22, SupplyHubCreator.item(Material.PAPER, "§7No trade power in your territory", List.of( + "§7A main hub is placed where your guild already has trade power."))); + } else if (!sites.isEmpty()) { + inventory.setItem(22, SupplyHubCreator.item(Material.PAPER, "§7No connections can be made", List.of( + "§7You might need a rail"))); + } else { + inventory.setItem(22, SupplyHubCreator.item(Material.PAPER, "§7No installation can take a hub", List.of( + "§7Every open port, airport, and train station already has your hub, or has no free slot."))); + } } int start = shown * PAGE_SIZE; - for (int index = 0; index < PAGE_SIZE && start + index < destinations.size(); index++) { - Destination destination = destinations.get(start + index); + for (int index = 0; index < PAGE_SIZE && start + index < listed.size(); index++) { + Site destination = listed.get(start + index); Faction host = FactionManager.getByString(destination.hostFactionId()); List lore = new ArrayList<>(); lore.add("§7Host: §f" + (host == null ? destination.hostFactionId() : host.getName())); - lore.addAll(HubProposalCopy.destinationLore(destination)); - ItemStack item = SupplyHubCreator.item( - destination.group() == Group.READY ? Material.CHEST : Material.MAP, - "§e" + destination.label(), lore); + lore.addAll(HubProposalCopy.destinationLore(destination, !noHubs)); + if (focused) { + lore.add(listed.size() > 1 + ? "§7Tied for the most trade power in your territory" + : "§7The most trade power in your territory"); + } + ItemStack item = SupplyHubCreator.item(Material.CHEST, "§e" + destination.label(), lore); ItemMeta meta = item.getItemMeta(); String installation = destination.installationId() == null ? "" : destination.installationId(); meta.getPersistentDataContainer().set(Keys.STRING_KEY, PersistentDataType.STRING, - destination.group().name() + SEP + destination.hostFactionId() + SEP + installation + destination.hostFactionId() + SEP + installation + SEP + destination.provinceId() + SEP + (destination.ownRealm() ? "own" : "foreign")); item.setItemMeta(meta); inventory.setItem(index, item); @@ -93,6 +114,15 @@ public static void openProposals(Player player, Guild guild, int page) { if (shown + 1 < pages) { inventory.setItem(50, SupplyHubCreator.item(Material.ARROW, "§eNext page", List.of())); } + if (noHubs && !tradable.isEmpty() && (focused ? tradable.size() > listed.size() : true)) { + if (focused) { + inventory.setItem(49, action(Material.BOOK, "§eView all", "view:all", List.of( + "§7Every place in your territory with trade power"))); + } else { + inventory.setItem(49, action(Material.COMPASS, "§eBest in your territory", "view:best", List.of( + "§7Only the installations with the most trade power"))); + } + } inventory.setItem(53, backButton(SFGUI.HUB_PROPOSAL_LIST)); player.openInventory(inventory); } @@ -110,7 +140,7 @@ public static void openNegotiation( openNegotiation(player, guild, hostFactionId, installationId, rate, feeCents, rate, feeCents); } - private static void openNegotiation( + static void openNegotiation( Player player, Guild guild, String hostFactionId, String installationId, int rate, long feeCents, int baseRate, long baseFeeCents) { if (player == null || guild == null || hostFactionId == null || installationId == null) { @@ -128,8 +158,7 @@ private static void openNegotiation( long shownFee = HubProposalCopy.clampFeeCents(feeCents, Cache.supplyHubMaxFee); int shownBaseRate = HubProposalCopy.clampRate(baseRate, min, max); long shownBaseFee = HubProposalCopy.clampFeeCents(baseFeeCents, Cache.supplyHubMaxFee); - Destination destination = findDestination(guild, hostFactionId, installationId); - Terms terms = destination == null ? null : HubEstimates.applyTerms(destination, shownRate, shownFee); + Site destination = findDestination(guild, hostFactionId, installationId); HubOffer offer = HubAgreementService.offer(guild, hostFactionId, installationId); HubAgreement agreement = HubAgreementService.findAgreement(guild, hostFactionId, installationId); boolean leader = isLeader(guild, player); @@ -146,17 +175,19 @@ private static void openNegotiation( 54, "§7Hub Agreement"); String site = destination == null ? installationId : destination.label(); inventory.setItem(4, SupplyHubCreator.item(Material.PAPER, "§eYour side", - HubProposalCopy.operatorLines(destination, terms))); + HubProposalCopy.powerLines(destination))); List termsLore = new ArrayList<>(); termsLore.add("§7" + site); termsLore.add("§7Rate: §e" + shownRate + "% §7(§e" + min + "-" + max + "%§7)"); termsLore.add("§7Daily fee: §e" + Formatter.formatMoney(shownFee / 100.0)); termsLore.add("§7Term: §e" + Math.max(1, Cache.supplyHubAgreementDays) + " days"); - termsLore.add(HubProposalCopy.breakEvenLine(destination, shownFee, min, max)); + if (Cache.supplyHubAutoAccept) { + termsLore.add("§7Dev server: sending these terms accepts them."); + } if (offer != null && (offer.taxRatePercent() != shownRate || offer.feeCents() != shownFee)) { termsLore.add("§7Offered now: §e" + offer.taxRatePercent() + "%§7 and §e" + Formatter.formatMoney(offer.feeCents() / 100.0)); - termsLore.add("§7Your unsent terms stay on the buttons"); + termsLore.add("§7Your unsent terms stay on this screen"); } if (offer != null) { termsLore.add(yourTurn ? "§eWaiting on you" : "§7Waiting on the other side"); @@ -165,19 +196,17 @@ private static void openNegotiation( } inventory.setItem(13, SupplyHubCreator.item(Material.GOLD_INGOT, "§eTerms", termsLore)); inventory.setItem(22, SupplyHubCreator.item(Material.PAPER, "§eHost side", - HubProposalCopy.hostLines(destination, terms, guildNames(destination)))); + HubProposalCopy.hostLines(destination))); - inventory.setItem(28, action(Material.RED_DYE, "§cRate -5", "rate:-5")); - inventory.setItem(29, action(Material.RED_DYE, "§cRate -1", "rate:-1")); - inventory.setItem(31, action(Material.LIME_DYE, "§aRate +1", "rate:1")); - inventory.setItem(32, action(Material.LIME_DYE, "§aRate +5", "rate:5")); + inventory.setItem(29, action(Material.NAME_TAG, "§eSet rate", "type:rate", List.of( + "§7Now §e" + shownRate + "%", + "§7Click, then type the percent in chat"))); if (offer != null && (offer.taxRatePercent() != shownRate || offer.feeCents() != shownFee)) { inventory.setItem(30, action(Material.GOLD_NUGGET, "§eUse offered terms", "adopt")); } - inventory.setItem(37, action(Material.RED_DYE, "§cFee -10", "fee:-1000")); - inventory.setItem(38, action(Material.RED_DYE, "§cFee -1", "fee:-100")); - inventory.setItem(40, action(Material.LIME_DYE, "§aFee +1", "fee:100")); - inventory.setItem(41, action(Material.LIME_DYE, "§aFee +10", "fee:1000")); + inventory.setItem(31, action(Material.NAME_TAG, "§eSet fee", "type:fee", List.of( + "§7Now §e" + Formatter.formatMoney(shownFee / 100.0), + "§7Click, then type the fee in chat"))); if (offer != null && yourTurn) { inventory.setItem(45, action(Material.RED_CONCRETE, "§cDecline", "decline")); @@ -224,7 +253,7 @@ public static void refresh(Player player) { if (holder.getType() == SFGUI.HUB_PROPOSAL_LIST) { Guild guild = FactionManager.getGuildByString(holder.getId()); if (guild != null) { - openProposals(player, guild, holder.getPage()); + openProposals(player, guild, holder.getPage(), holder.getFlag()); } } else if (holder.getType() == SFGUI.HUB_OFFER_LIST) { String[] filter = offerFilter(holder.getSecondaryId()); @@ -256,35 +285,48 @@ private static void clickProposals( return; } if (event.getSlot() == 48) { - openProposals(player, guild, holder.getPage() - 1); + openProposals(player, guild, holder.getPage() - 1, holder.getFlag()); return; } if (event.getSlot() == 50) { - openProposals(player, guild, holder.getPage() + 1); + openProposals(player, guild, holder.getPage() + 1, holder.getFlag()); return; } String data = key(event.getCurrentItem()); if (data == null) { return; } + if (data.equals("view:all")) { + openProposals(player, guild, 0, true); + return; + } + if (data.equals("view:best")) { + openProposals(player, guild, 0, false); + return; + } String[] parts = data.split(SEP, -1); - if (parts.length < 4) { + if (parts.length < 4 || parts[1].isEmpty()) { return; } player.playSound(player, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); - if (Group.WORTH_BUILDING.name().equals(parts[0]) || parts[2].isEmpty()) { - player.sendMessage("§7A station has to be built there before a hub can be proposed."); + boolean noHubs = guild.getSupplyHubs() == null || guild.getSupplyHubs().isEmpty(); + boolean ownRealm = parts[3].equals("own"); + if (noHubs && !ownRealm) { + player.sendMessage("§cYour first hub has to be in your own territory"); return; } - Destination destination = findDestination(guild, parts[1], parts[2]); - boolean ownRealm = (parts.length >= 5 && parts[4].equals("own")) - || (destination != null && destination.ownRealm()); - if (ownRealm) { + if (SupplyHubService.hasHub(guild.getSupplyHubs(), parts[0], parts[1])) { + player.sendMessage("§cYour guild already has a supply hub there"); + openProposals(player, guild, holder.getPage(), holder.getFlag()); + return; + } + Site destination = findDestination(guild, parts[0], parts[1]); + if (ownRealm || (destination != null && destination.ownRealm())) { menus.confirmSupplyHub(player, guild.getFaction(), - "build|" + guild.getId() + "|" + parts[1] + "|" + parts[2]); + "build|" + guild.getId() + "|" + parts[0] + "|" + parts[1]); return; } - openFromExisting(player, guild, parts[1], parts[2]); + openFromExisting(player, guild, parts[0], parts[1]); } private static void clickNegotiation( @@ -307,21 +349,10 @@ private static void clickNegotiation( return; } player.playSound(player, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); - Faction host = FactionManager.getByString(draft.hostId); - RateRange range = HubAgreementService.allowedRateRange(host); - int min = range.minPercent(); - int max = range.maxPercent(); - if (action.startsWith("rate:")) { - int next = HubProposalCopy.clampRate(draft.rate + parseInt(action.substring(5)), min, max); - openNegotiation(player, guild, draft.hostId, draft.installationId, - next, draft.feeCents, draft.baseRate, draft.baseFeeCents); - return; - } - if (action.startsWith("fee:")) { - long next = HubProposalCopy.clampFeeCents( - draft.feeCents + parseInt(action.substring(4)), Cache.supplyHubMaxFee); - openNegotiation(player, guild, draft.hostId, draft.installationId, - draft.rate, next, draft.baseRate, draft.baseFeeCents); + if (action.equals("type:rate") || action.equals("type:fee")) { + HubTermsPrompt.ask(player, guild, draft.hostId, draft.installationId, + draft.rate, draft.feeCents, draft.baseRate, draft.baseFeeCents, + action.equals("type:rate")); return; } if (action.equals("adopt")) { @@ -333,7 +364,7 @@ private static void clickNegotiation( return; } if (action.equals("offer")) { - sendTerms(player, guild, draft); + sendTerms(player, guild, draft, menus); return; } if (action.equals("accept")) { @@ -408,7 +439,7 @@ private static void openFromExisting(Player player, Guild guild, String hostId, openNegotiation(player, guild, hostId, installationId, rate, fee); } - private static void sendTerms(Player player, Guild guild, Draft draft) { + private static void sendTerms(Player player, Guild guild, Draft draft, InventoryManager menus) { HubOffer offer = HubAgreementService.offer(guild, draft.hostId, draft.installationId); AgreementResult result; if (offer != null && !waitingOn(player, guild, offer)) { @@ -428,6 +459,17 @@ private static void sendTerms(Player player, Guild guild, Draft draft) { if (result.message() != null) { player.sendMessage(result.message()); } + if (result.succeeded() && Cache.supplyHubAutoAccept) { + AgreementResult accepted = HubAgreementService.acceptAutomatically( + guild, draft.hostId, draft.installationId); + if (accepted.message() != null) { + player.sendMessage(accepted.message()); + } + if (accepted.succeeded() && accepted.builtHub()) { + menus.supplyHubView.guildView(player, guild); + return; + } + } if (result.succeeded()) { openFromExisting(player, guild, draft.hostId, draft.installationId); } @@ -510,8 +552,8 @@ private static String[] offerFilter(String secondaryId) { return new String[] {parts[0], parts[1]}; } - private static Destination findDestination(Guild guild, String hostId, String installationId) { - for (Destination destination : HubEstimates.destinations(guild)) { + private static Site findDestination(Guild guild, String hostId, String installationId) { + for (Site destination : HubEstimates.sites(guild)) { if (destination.installationId() == null || destination.hostFactionId() == null) { continue; } @@ -523,20 +565,12 @@ private static Destination findDestination(Guild guild, String hostId, String in return null; } - private static Map guildNames(Destination destination) { - Map names = new LinkedHashMap<>(); - if (destination == null || destination.hostGuildGains() == null) { - return names; - } - for (String guildId : destination.hostGuildGains().keySet()) { - Guild guild = FactionManager.getGuildByString(guildId); - names.put(guildId, guild == null || guild.getName() == null ? guildId : guild.getName()); - } - return names; + private static ItemStack action(Material material, String name, String action) { + return action(material, name, action, List.of()); } - private static ItemStack action(Material material, String name, String action) { - ItemStack item = SupplyHubCreator.item(material, name, List.of()); + private static ItemStack action(Material material, String name, String action, List lore) { + ItemStack item = SupplyHubCreator.item(material, name, lore); ItemMeta meta = item.getItemMeta(); meta.getPersistentDataContainer().set(Keys.STRING_KEY, PersistentDataType.STRING, action); item.setItemMeta(meta); @@ -562,13 +596,6 @@ private static String key(ItemStack item) { return item.getItemMeta().getPersistentDataContainer().get(Keys.STRING_KEY, PersistentDataType.STRING); } - private static int parseInt(String text) { - try { - return Integer.parseInt(text); - } catch (NumberFormatException ex) { - return 0; - } - } private static boolean waitingOn(Player player, Guild guild, HubOffer offer) { if (offer == null || player == null) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/HubTermsPrompt.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/HubTermsPrompt.java new file mode 100644 index 00000000..73df083c --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/HubTermsPrompt.java @@ -0,0 +1,161 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.scheduler.BukkitRunnable; + +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.hub.HubAgreementService; +import net.tfminecraft.simplefactions.guild.hub.HubAgreementService.RateRange; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.utils.Formatter; + +/** Chat entry for a hub agreement's rate and fee. The chest stays closed until the number is typed. */ +public final class HubTermsPrompt implements Listener { + private record Pending( + String guildId, + String hostId, + String installationId, + int rate, + long feeCents, + int baseRate, + long baseFeeCents, + boolean rateField) { + } + + private static final Map WAITING = new ConcurrentHashMap<>(); + + public HubTermsPrompt() { + } + + public static void ask( + Player player, Guild guild, String hostId, String installationId, + int rate, long feeCents, int baseRate, long baseFeeCents, boolean rateField) { + if (player == null || guild == null || guild.getId() == null || hostId == null || installationId == null) { + return; + } + WAITING.put(player.getUniqueId(), new Pending( + guild.getId(), hostId, installationId, rate, feeCents, baseRate, baseFeeCents, rateField)); + player.closeInventory(); + if (rateField) { + Faction host = FactionManager.getByString(hostId); + RateRange range = HubAgreementService.allowedRateRange(host); + player.sendMessage("§eType the hub tax percent, from §f" + range.minPercent() + + "§e to §f" + range.maxPercent() + "§e. Type §ccancel§e to go back."); + return; + } + player.sendMessage("§eType the daily fee in denars. Type §ccancel§e to go back."); + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + if (event.getPlayer() != null) { + WAITING.remove(event.getPlayer().getUniqueId()); + } + } + + // Retain Bukkit chat-event ordering and String message semantics for existing integrations. + @SuppressWarnings("deprecation") + @EventHandler + public void onChat(AsyncPlayerChatEvent event) { + Player player = event.getPlayer(); + if (player == null) { + return; + } + Pending pending = WAITING.remove(player.getUniqueId()); + if (pending == null) { + return; + } + event.setCancelled(true); + String message = event.getMessage(); + if (SimpleFactions.plugin == null) { + return; + } + new BukkitRunnable() { + @Override + public void run() { + apply(player, pending, message); + } + }.runTask(SimpleFactions.plugin); + } + + private static void apply(Player player, Pending pending, String message) { + if (player == null || !player.isOnline() || pending == null) { + return; + } + String text = message == null ? "" : message.trim(); + if (text.equalsIgnoreCase("cancel")) { + player.sendMessage("§7Left the terms as they were."); + reopen(player, pending, pending.rate, pending.feeCents); + return; + } + if (pending.rateField) { + Integer typed = wholePercent(text); + Faction host = FactionManager.getByString(pending.hostId); + RateRange range = HubAgreementService.allowedRateRange(host); + if (typed == null || typed < range.minPercent() || typed > range.maxPercent()) { + WAITING.put(player.getUniqueId(), pending); + player.sendMessage("§cType a whole percent from §e" + range.minPercent() + + "§c to §e" + range.maxPercent() + "§c, or §ecancel§c."); + return; + } + reopen(player, pending, typed, pending.feeCents); + return; + } + Long cents = wholeCents(text); + long maxCents = Math.round(Math.max(0, Cache.supplyHubMaxFee) * 100.0); + if (cents == null || cents > maxCents) { + WAITING.put(player.getUniqueId(), pending); + player.sendMessage("§cThe daily fee must be at least zero, in whole cents, and at most §e" + + Formatter.formatMoney(Cache.supplyHubMaxFee) + "§c. Type §ecancel§c to go back."); + return; + } + reopen(player, pending, pending.rate, cents); + } + + private static void reopen(Player player, Pending pending, int rate, long feeCents) { + Guild guild = FactionManager.getGuildByString(pending.guildId); + if (guild == null) { + player.sendMessage("§cThat guild does not exist"); + return; + } + HubProposalMenu.openNegotiation( + player, guild, pending.hostId, pending.installationId, + rate, feeCents, pending.baseRate, pending.baseFeeCents); + } + + private static Integer wholePercent(String text) { + try { + return Integer.valueOf(text); + } catch (NumberFormatException ex) { + return null; + } + } + + private static Long wholeCents(String text) { + try { + double fee = Double.parseDouble(text); + 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; + } catch (NumberFormatException ex) { + return null; + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationBuildConfirm.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationBuildConfirm.java index d285040a..d371aa7d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationBuildConfirm.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationBuildConfirm.java @@ -18,8 +18,8 @@ import org.bukkit.persistence.PersistentDataType; import net.tfminecraft.simplefactions.SimpleFactions; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates; import net.tfminecraft.simplefactions.guild.hub.InfrastructureMenuCopy; +import net.tfminecraft.simplefactions.guild.hub.InfrastructurePreview; import net.tfminecraft.simplefactions.installation.InstallationKind; import net.tfminecraft.simplefactions.installation.handler.ConstructResult; import net.tfminecraft.simplefactions.managers.FactionManager; @@ -79,7 +79,7 @@ public static boolean open( Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { List lore; try { - HubEstimates.InstallationPreview preview = HubEstimates.previewInstallation( + InfrastructurePreview.InstallationPreview preview = InfrastructurePreview.previewInstallation( provinces, faction, province, kind); lore = InfrastructureMenuCopy.installationPreview(preview); } catch (RuntimeException ex) { 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 2ef815ae..dea096fc 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubView.java @@ -19,6 +19,7 @@ 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.HubEstimates; import net.tfminecraft.simplefactions.guild.hub.SupplyHubCommands; import net.tfminecraft.simplefactions.guild.hub.HubNetwork; import net.tfminecraft.simplefactions.guild.hub.HubTransport.Link; @@ -92,20 +93,56 @@ private void populateGuildView(Player player, Guild guild, Inventory inventory) inventory.setItem(index, item); } int limit = SupplyHubService.limit(guild); - inventory.setItem(49, SupplyHubCreator.item(Material.PAPER, "§eSupply Hub Information", List.of( + List information = new ArrayList<>(List.of( "§7Hubs work in pairs.", "§7Hubs: §e" + hubs.size() + "/" + limit, "§7Upkeep per hub: §e" + Formatter.formatDouble(SupplyHubService.upkeepPerHub(guild)) + "d/day", - "§7Daily upkeep: §e" + Formatter.formatDouble(SupplyHubService.dailyCost(guild)) + "d/day", - "§7Choose §ePropose a hub §7to see where one would pay."))); - if (isLeader(guild, player)) { - ItemStack propose = SupplyHubCreator.item(Material.EMERALD, "§aPropose a hub", List.of( - "§7Where a hub would pay, from today's estimates.", - "§eClick to choose a destination")); - ItemMeta proposeMeta = propose.getItemMeta(); - proposeMeta.getPersistentDataContainer().set(Keys.STRING_KEY, PersistentDataType.STRING, "propose"); - propose.setItemMeta(proposeMeta); - inventory.setItem(45, propose); + "§7Daily upkeep: §e" + Formatter.formatDouble(SupplyHubService.dailyCost(guild)) + "d/day")); + boolean noHubs = hubs.isEmpty(); + boolean noInstallation = false; + boolean noTrade = false; + if (noHubs) { + var territory = HubEstimates.territory(HubEstimates.sites(guild)); + noInstallation = territory.isEmpty(); + noTrade = !noInstallation && HubEstimates.withTrade(territory).isEmpty(); + } + if (noHubs && noInstallation) { + information.add("§7You have no installation for a hub."); + } else if (noHubs && noTrade) { + information.add("§7None of your installations have trade power."); + } else if (isLeader(guild, player)) { + if (noHubs) { + information.add("§7Choose §eCreate main hub §7to place the first one in your territory."); + } else { + information.add("§7Choose §ePropose a hub §7to see where one would pay."); + } + } + inventory.setItem(49, SupplyHubCreator.item(Material.PAPER, "§eSupply Hub Information", information)); + if (noHubs && noInstallation) { + inventory.setItem(22, SupplyHubCreator.item(Material.PAPER, "§7You have no installation", List.of( + "§7Build a port, airport, or train station in your territory."))); + } else if (noHubs && noTrade) { + inventory.setItem(22, SupplyHubCreator.item(Material.PAPER, "§7No trade power in your territory", List.of( + "§7A main hub is placed where your guild already has trade power."))); + } else if (isLeader(guild, player)) { + if (noHubs) { + ItemStack main = SupplyHubCreator.item(Material.EMERALD, "§aCreate main hub", List.of( + "§7The first hub has to be in your territory.", + "§7It starts where your trade power is highest.", + "§eClick to choose the site")); + ItemMeta mainMeta = main.getItemMeta(); + mainMeta.getPersistentDataContainer().set(Keys.STRING_KEY, PersistentDataType.STRING, "main"); + main.setItemMeta(mainMeta); + inventory.setItem(22, main); + } else { + ItemStack propose = SupplyHubCreator.item(Material.EMERALD, "§aPropose a hub", List.of( + "§7Where a hub would pay, from today's estimates.", + "§eClick to choose a destination")); + ItemMeta proposeMeta = propose.getItemMeta(); + proposeMeta.getPersistentDataContainer().set(Keys.STRING_KEY, PersistentDataType.STRING, "propose"); + propose.setItemMeta(proposeMeta); + inventory.setItem(45, propose); + } } inventory.setItem(53, inv.createBackButton(SFGUI.SUPPLY_HUB_VIEW)); } @@ -197,7 +234,7 @@ public void click(InventoryClickEvent event, Inventory inventory, Player player) if (data == null) { return; } - if (data.equals("propose")) { + if (data.equals("propose") || data.equals("main")) { HubProposalMenu.openProposals(player, guild, 0); player.playSound(player, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); return; diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 3c6b67fa..bd57df87 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -84,6 +84,9 @@ mercenary-default-breach-refund: 500.0 # smaller refund would make no-showing more profitable per head than fighting. supply-hubs: + # Dev servers only. When true, the player who sends hub terms also accepts them, + # so one person can finish an agreement. Leave this false on a live server. + auto-accept: false # 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. @@ -92,9 +95,6 @@ supply-hubs: offer-days: 7 # Days a hub agreement runs. agreement-days: 14 - # How many ready installations, and how many provinces worth building, are fully - # estimated per guild each day. The closest ones are kept. - estimate-candidates: 24 # A hub takes the guild's trade power and production in its province and delivers a share # to each of the guild's hubs it is connected to. Train track joins any two kinds; ports # also connect across the sea, and airports through the air. diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementServiceTest.java index 23eb25e5..6a38c43c 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubAgreementServiceTest.java @@ -50,6 +50,28 @@ class HubAgreementServiceTest { @AfterEach void restoreCap() { Cache.supplyHubMaxTax = 50; + Cache.supplyHubAutoAccept = false; + } + + @Test + void autoAcceptFinishesTheOfferOnlyWhileTheFlagIsOn() { + Facts facts = new Facts(); + Guild guild = guild(); + assertTrue(HubAgreementService.propose(guild, "Leader", "host", "port", 10, 1.0, facts, 0L).succeeded()); + + AgreementResult blocked = HubAgreementService.accept( + guild, HubAgreementService.DEV_ACTOR, "host", "port", facts, 1L); + assertFalse(blocked.succeeded()); + assertEquals(1, guild.getHubOffers().size()); + assertTrue(guild.getSupplyHubs().isEmpty()); + + Cache.supplyHubAutoAccept = true; + AgreementResult accepted = HubAgreementService.accept( + guild, HubAgreementService.DEV_ACTOR, "host", "port", facts, 2L); + assertTrue(accepted.succeeded()); + assertTrue(accepted.builtHub()); + assertTrue(guild.getHubOffers().isEmpty()); + assertNotNull(HubAgreementService.findAgreement(guild, "host", "port")); } @Test diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubEstimatesTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubEstimatesTest.java index 6d51046a..076b5a2b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubEstimatesTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubEstimatesTest.java @@ -2,402 +2,161 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.OptionalDouble; -import org.bukkit.configuration.file.YamlConfiguration; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import net.tfminecraft.simplefactions.Cache; -import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.enums.GuildModifier; import net.tfminecraft.simplefactions.enums.Rules; -import net.tfminecraft.simplefactions.enums.Terrain; import net.tfminecraft.simplefactions.guild.Guild; -import net.tfminecraft.simplefactions.guild.GuildModifierOverride; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Candidate; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Destination; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Group; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates.InstallationPreview; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Planned; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Result; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Terms; -import net.tfminecraft.simplefactions.guild.hub.HubTransport.Link; -import net.tfminecraft.simplefactions.guild.hub.HubTransport.Mode; -import net.tfminecraft.simplefactions.guild.income.IncomePreviewContext; -import net.tfminecraft.simplefactions.guild.income.Ledger; -import net.tfminecraft.simplefactions.guild.income.TradeBreakdown; +import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Site; import net.tfminecraft.simplefactions.installation.Installation; import net.tfminecraft.simplefactions.installation.InstallationKind; import net.tfminecraft.simplefactions.installation.handler.InstallationHandler; -import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.managers.ProvinceManager; -import net.tfminecraft.simplefactions.managers.RelationManager; -import net.tfminecraft.simplefactions.managers.TitleManager; +import net.tfminecraft.simplefactions.enums.Terrain; import net.tfminecraft.simplefactions.map.provinces.Province; -import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.map.provinces.ProvinceDataEntry; import net.tfminecraft.simplefactions.objects.Faction; class HubEstimatesTest { - @AfterEach - void reset() { - IncomePreviewContext.clear(); - GuildModifierOverride.clear(); - HubNetwork.setLinksForTests(null); - HubNetwork.setRailRoutesForTests(null); - HubEstimates.clearForTests(); - } @Test - void suppressedInfrastructureClearsAndExtraSourceSkipsSea() { - boolean enabled = Cache.provincesEnabled; - Cache.provincesEnabled = true; - Province land = new Province(1, "bog", 0); - Province sea = new Province(2, "sea", 0); - ProvinceManager manager = new ProvinceManager(); - manager.start(Map.of(1, land, 2, sea)); - try (MockedStatic factions = mockStatic(FactionManager.class)) { - factions.when(FactionManager::getAllGuilds).thenReturn(List.of()); - factions.when(FactionManager::getCopy).thenReturn(List.of()); - manager.setExtraInfrastructure(Map.of(1, 20.0, 2, 20.0)); - manager.recalculate(); - assertEquals(20, manager.get(1).getInfrastructure(), 1e-9); - assertEquals(0, manager.get(2).getInfrastructure(), 1e-9); - - manager.setInfrastructureSuppressed(true); - manager.recalculate(); - assertEquals(0, manager.get(1).getInfrastructure(), 1e-9); - assertEquals(0, manager.get(2).getInfrastructure(), 1e-9); - } finally { - Cache.provincesEnabled = enabled; - } - } - - @Test - void missingRailwayUsesAStraightLineAndTermsAreArithmetic() { - Installation from = new Installation("a", "Home", InstallationKind.TRAIN_STATION, 1, 0, 0, 0L); - Installation to = new Installation("b", "Away", InstallationKind.TRAIN_STATION, 2, 3000, 0, 0L); - HubNetwork.setRailRoutesForTests((left, right) -> OptionalDouble.empty()); - Planned planned = HubEstimates.plan(List.of(from), to, "host", new ProvinceManager()); - - assertTrue(planned.assumedRailway()); - assertEquals(3000, planned.assumedTrackBlocks(), 1e-6); - assertEquals(2, planned.added().size()); - assertEquals(Mode.RAIL, planned.added().get(0).mode()); - assertEquals(3000, planned.added().get(0).distance(), 1e-6); - - Destination foreign = destination(false, 80, -20, 100); - Terms terms = HubEstimates.applyTerms(foreign, 10, 500); - assertEquals(65, terms.operatorNet(), 1e-9); - assertEquals(-5, terms.hostNet(), 1e-9); - assertEquals(10, terms.tax(), 1e-9); - assertEquals(5, terms.fee(), 1e-9); - - Terms clamped = HubEstimates.applyTerms(foreign, 150, -20); - assertEquals(0, clamped.fee(), 1e-9); - assertEquals(100, clamped.tax(), 1e-9); - - Terms own = HubEstimates.applyTerms(destination(true, 80, 4, 100), 10, 500); - assertEquals(80, own.operatorNet(), 1e-9); - assertEquals(4, own.hostNet(), 1e-9); - assertEquals(0, own.tax(), 1e-9); - assertEquals(0, own.fee(), 1e-9); - } - - @Test - void aHubLinkRaisesTheOperatorsNetAndThatRiseIsTaxable() { - Road road = road(24); - Link link = new Link(1, 22, Mode.RAIL, 0, 0.4, 0.8); - try (MockedStatic factions = mockStatic(FactionManager.class); - MockedStatic titles = mockStatic(TitleManager.class)) { - factions.when(FactionManager::getAllGuilds).thenReturn(List.of(road.guild)); - factions.when(FactionManager::getCopy).thenReturn(List.of()); - titles.when(() -> TitleManager.getByProvince(anyInt())).thenReturn(null); - Result result = HubEstimates.compare(road.provinces, road.guild, road.faction, List.of(link), null); - assertTrue(result.operatorGain() > 0, "operator gain " + result.operatorGain()); - assertTrue(result.taxableIncome() > 0, "taxable " + result.taxableIncome()); - assertEquals(0, result.hostGain(), 1e-9); - } finally { - road.restore(); - } - } - - @Test - void shortlistKeepsFreeHubsAndTheClosestProvinces() { - Faction home = faction("home", true); - Faction foreign = faction("foreign", false); - Faction taxed = faction("taxed", true); - Guild guild = guild(home, 1, List.of(new SupplyHub("home", "mine", 1L))); + void arrivalUsesExistingTrackAndIgnoresAMissingRailway() { + HubTransport.resetConfig(); + Guild guild = guild(); Installation near = station("near", 1, 0, 0); - Installation mine = station("mine", 12, 50, 0); - Installation taken = station("taken", 3, 80, 0); - Installation far = station("far", 4, 10_000, 0); - Installation abroad = station("abroad", 5, 90, 0); - Installation fort = new Installation("fort", "Fort", InstallationKind.FORT, 2, 10, 0, 0L); - InstallationHandler homeSites = handler(near, mine, taken, fort, far); - InstallationHandler foreignSites = handler(abroad); - InstallationHandler taxedSites = handler(station("taxed", 11, 8_000, 0)); - when(home.getInstallationHandler()).thenReturn(homeSites); - when(foreign.getInstallationHandler()).thenReturn(foreignSites); - when(taxed.getInstallationHandler()).thenReturn(taxedSites); - - int slots = stationSlots(); - List guilds = new ArrayList<>(); - guilds.add(guild); - for (int i = 0; i < slots; i++) { - Guild occupant = mock(Guild.class); - when(occupant.getSupplyHubs()).thenReturn(List.of(new SupplyHub("home", "taken", i))); - guilds.add(occupant); - } - - ProvinceManager provinces = new ProvinceManager(); - Map map = new HashMap<>(); - map.put(1, new Province(1, Terrain.PLAINS.name(), 50, 0, 0)); - map.put(6, new Province(6, Terrain.PLAINS.name(), 50, 100, 0)); - map.put(7, new Province(7, Terrain.PLAINS.name(), 50, 5_000, 0)); - map.put(8, new Province(8, Terrain.SEA.name(), 50, 30, 0)); - map.put(9, new Province(9, Terrain.PLAINS.name(), 50, 40, 0)); - map.put(10, new Province(10, Terrain.PLAINS.name(), 50, 60, 0)); - map.put(11, new Province(11, Terrain.PLAINS.name(), 50, 8_000, 0)); - map.put(13, new Province(13, Terrain.PLAINS.name(), 50, 9_000, 0)); - provinces.start(map); - - try (MockedStatic titles = mockStatic(TitleManager.class); - MockedStatic relations = mockStatic(RelationManager.class)) { - titles.when(() -> TitleManager.getByProvince(anyInt())).thenReturn(null); - titles.when(() -> TitleManager.getByProvince(6)).thenReturn(home); - titles.when(() -> TitleManager.getByProvince(7)).thenReturn(home); - titles.when(() -> TitleManager.getByProvince(8)).thenReturn(home); - titles.when(() -> TitleManager.getByProvince(10)).thenReturn(foreign); - titles.when(() -> TitleManager.getByProvince(11)).thenReturn(taxed); - titles.when(() -> TitleManager.getByProvince(13)).thenReturn(taxed); - relations.when(() -> RelationManager.sameRealm(any(), any())).thenAnswer(invocation -> { - Faction left = invocation.getArgument(0); - Faction right = invocation.getArgument(1); - return left != null && right != null && left.getId().equalsIgnoreCase(right.getId()); - }); - - List listed = HubEstimates.shortlist( - guild, guilds, List.of(home, foreign, taxed), provinces, 10); - assertTrue(listed.stream().anyMatch(candidate -> "near".equals(candidate.installation().getId()))); - assertTrue(listed.stream().anyMatch(candidate -> "far".equals(candidate.installation().getId()))); - assertTrue(listed.stream().anyMatch(candidate -> candidate.installation().getProvince() == 6 - && candidate.group() == Group.WORTH_BUILDING)); - assertTrue(listed.stream().anyMatch(candidate -> "taxed".equals(candidate.installation().getId()) - && candidate.group() == Group.READY)); - assertTrue(listed.stream().anyMatch(candidate -> candidate.installation().getProvince() == 13 - && candidate.group() == Group.WORTH_BUILDING)); - assertFalse(listed.stream().anyMatch(candidate -> "mine".equals(candidate.installation().getId()) - && candidate.group() == Group.READY)); - assertFalse(listed.stream().anyMatch(candidate -> "taken".equals(candidate.installation().getId()))); - assertFalse(listed.stream().anyMatch(candidate -> "abroad".equals(candidate.installation().getId()))); - assertFalse(listed.stream().anyMatch(candidate -> "fort".equals(candidate.installation().getId()))); - assertFalse(listed.stream().anyMatch(candidate -> { - int province = candidate.installation().getProvince(); - return province == 8 || province == 9 || province == 10; - })); - - List capped = HubEstimates.shortlist( - guild, guilds, List.of(home, foreign, taxed), provinces, 1); - assertEquals(1, capped.stream().filter(candidate -> candidate.group() == Group.READY).count()); - assertEquals("near", capped.stream().filter(candidate -> candidate.group() == Group.READY).findFirst().orElseThrow().installation().getId()); - assertEquals(6, capped.stream().filter(candidate -> candidate.group() == Group.WORTH_BUILDING).findFirst().orElseThrow().installation().getProvince()); + Installation richer = station("richer", 3, 0, 0); + Installation target = station("target", 2, 1000, 0); + ProvinceManager provinces = provinces(guild, Map.of(1, 10.0, 2, 4.0, 3, 40.0)); + HubNetwork.setRailRoutesForTests((from, to) -> OptionalDouble.of(1000)); + try { + assertEquals(3.6, HubEstimates.arrives(guild, target, List.of(near), provinces), 0.01); + assertEquals(14.4, HubEstimates.arrives(guild, target, List.of(near, richer), provinces), 0.01); + assertEquals(0, HubEstimates.arrives(guild, target, List.of(), provinces), 0.01); + } finally { + HubNetwork.setRailRoutesForTests(null); } - } - @Test - void aFewHundredProvincesFinishWithinTenSeconds() { - Road road = road(300); - Link link = new Link(1, 200, Mode.RAIL, 0, 0.4, 0.8); - try (MockedStatic factions = mockStatic(FactionManager.class); - MockedStatic titles = mockStatic(TitleManager.class)) { - factions.when(FactionManager::getAllGuilds).thenReturn(List.of(road.guild)); - factions.when(FactionManager::getCopy).thenReturn(List.of()); - titles.when(() -> TitleManager.getByProvince(anyInt())).thenReturn(null); - long started = System.nanoTime(); - HubEstimates.compare(road.provinces, road.guild, road.faction, List.of(link), null); - long millis = (System.nanoTime() - started) / 1_000_000L; - assertTrue(millis < 10_000, "synthetic estimate took " + millis + " ms"); + HubNetwork.setRailRoutesForTests((from, to) -> OptionalDouble.empty()); + try { + assertEquals(0, HubEstimates.arrives(guild, target, List.of(near), provinces), 0.01); } finally { - road.restore(); + HubNetwork.setRailRoutesForTests(null); } } @Test - void infrastructureWorthAndANewStationArePositive() { - // A capital alone ignores terrain. The neighbour is what infrastructure changes. - boolean enabled = Cache.provincesEnabled; - Map carry = new HashMap<>(Cache.tradeCarry); - Cache.provincesEnabled = true; - Cache.tradeCarry.clear(); - Cache.tradeCarry.put(Terrain.BOG, 0.4); - Province capital = new Province(1, Terrain.BOG.name(), 50, 0, 0); - Province neighbour = new Province(2, Terrain.BOG.name(), 50, 100, 0); - capital.addNeighbour(2); - neighbour.addNeighbour(1); - ProvinceManager provinces = new ProvinceManager(); - provinces.start(Map.of(1, capital, 2, neighbour)); - Faction home = faction("home", true); - Guild operator = guild(home, 1, List.of()); - InstallationHandler sites = handler(station("station", 1, 0, 0)); - when(home.getInstallationHandler()).thenReturn(sites); - double previousStation = Cache.infrastructureStation; - double previousFull = Cache.infrastructureFull; - double previousTarget = Cache.infrastructureTarget; - Cache.infrastructureStation = 10; - Cache.infrastructureFull = 20; - Cache.infrastructureTarget = 0.75; - try (MockedStatic factions = mockStatic(FactionManager.class); - MockedStatic titles = mockStatic(TitleManager.class)) { - factions.when(FactionManager::getAllGuilds).thenReturn(List.of(operator)); - factions.when(FactionManager::getCopy).thenReturn(List.of(home)); - factions.when(() -> FactionManager.getByString("home")).thenReturn(home); - titles.when(() -> TitleManager.getByProvince(anyInt())).thenReturn(home); - when(home.hasFactionRule(Rules.SUPPLY_HUBS)).thenReturn(true); - HubEstimates.rebuild(provinces, List.of(operator), List.of(home)); - assertTrue(HubEstimates.infrastructureWorth(home) > 0, - "worth " + HubEstimates.infrastructureWorth(home)); - assertTrue(HubEstimates.hasInfrastructureWorth(home)); - assertFalse(HubEstimates.hasInfrastructureWorth("nobody")); - - InstallationPreview preview = HubEstimates.previewInstallation( - provinces, home, 2, InstallationKind.TRAIN_STATION); - assertEquals(10, preview.infrastructureHere(), 1e-9); - assertTrue(preview.realmPerDay() > 0, "realm " + preview.realmPerDay()); - assertTrue(preview.upkeep() > 0); - assertEquals(InstallationPreview.NONE, HubEstimates.previewInstallation( - provinces, home, 2, null)); - } finally { - Cache.infrastructureStation = previousStation; - Cache.infrastructureFull = previousFull; - Cache.infrastructureTarget = previousTarget; - Cache.provincesEnabled = enabled; - Cache.tradeCarry.clear(); - Cache.tradeCarry.putAll(carry); - } + void mainHubFiltersKeepOwnTradeTiesAndLaterHubsSortByArrival() { + Site high = site("a", true, 20, 1, true); + Site tied = site("b", true, 20, 0, false); + Site low = site("c", true, 5, 9, true); + Site empty = site("d", true, 0, 4, true); + Site foreign = site("e", false, 50, 8, true); + + assertEquals(List.of("a", "b", "c"), ids(HubEstimates.withTrade(HubEstimates.territory( + List.of(high, tied, low, empty, foreign))))); + assertEquals(List.of("a", "b"), ids(HubEstimates.bestTerritory(List.of(high, tied, low, foreign)))); + assertEquals(List.of("a", "b", "c"), ids(HubEstimates.byTradePower(List.of(low, high, tied)))); + assertEquals(List.of("c", "e", "d", "a", "b"), ids(HubEstimates.byArrival( + List.of(high, tied, low, empty, foreign)))); + assertEquals(List.of("c", "e", "d", "a"), ids(HubEstimates.byArrival(HubEstimates.connected( + List.of(high, tied, low, empty, foreign))))); } @Test - void scheduleDoesNothingWithoutAPluginAndCandidatesDefaultToTwentyFour() { - assertNull(SimpleFactions.getInstance()); - HubEstimates.scheduleDaily(); - int previous = Cache.supplyHubEstimateCandidates; - double previousTax = Cache.supplyHubMaxTax; - double previousFee = Cache.supplyHubMaxFee; - int previousOffer = Cache.supplyHubOfferDays; - int previousAgreement = Cache.supplyHubAgreementDays; + void sitesUseLiveInstallationsAndDeliverFromAnExistingHub() { + List previousFactions = new ArrayList<>(FactionManager.factions); + FactionManager.factions.clear(); + HubTransport.resetConfig(); try { - YamlConfiguration config = new YamlConfiguration(); - config.set("supply-hubs.estimate-candidates", 0); - SupplyHubService.loadConfig(config); - assertEquals(1, Cache.supplyHubEstimateCandidates); - SupplyHubService.loadConfig(null); - assertEquals(24, Cache.supplyHubEstimateCandidates); + + Faction home = faction("home"); + Faction foreign = faction("foreign"); + when(foreign.hasFactionRule(Rules.HUB_TAX)).thenReturn(false); + InstallationHandler homeSites = mock(InstallationHandler.class); + InstallationHandler foreignSites = mock(InstallationHandler.class); + Installation capital = station("capital", 1, 0, 0); + Installation next = station("next", 2, 1000, 0); + Installation quiet = station("quiet", 4, 250, 0); + Installation abroad = station("abroad", 3, 500, 0); + when(home.getInstallationHandler()).thenReturn(homeSites); + when(foreign.getInstallationHandler()).thenReturn(foreignSites); + when(homeSites.getAll()).thenReturn(List.of( + capital, next, quiet, station("fort", InstallationKind.FORT, 5, 0, 0))); + when(foreignSites.getAll()).thenReturn(List.of(abroad)); + FactionManager.factions.add(home); + FactionManager.factions.add(foreign); + + Guild guild = guild(); + when(guild.getFaction()).thenReturn(home); + when(guild.getSupplyHubs()).thenReturn(List.of(new SupplyHub("home", "capital", 1))); + when(guild.getModifier(any())).thenReturn(0.0); + when(guild.getModifier(GuildModifier.HUB_LIMIT)).thenReturn(1.0); + when(home.hasFactionRule(Rules.SUPPLY_HUBS)).thenReturn(true); + + ProvinceManager provinces = provinces(guild, Map.of(1, 10.0, 2, 4.0, 4, 0.0)); + List listed = HubEstimates.sites(guild, provinces, List.of(home, foreign), List.of(guild)); + + assertEquals(List.of("next", "quiet"), ids(listed)); + assertTrue(HubEstimates.connected(listed).isEmpty()); + Site delivered = listed.stream().filter(site -> site.installationId().equals("next")).findFirst().orElseThrow(); + assertEquals(0, delivered.arrivesHere(), 0.01); + assertFalse(delivered.connected()); } finally { - Cache.supplyHubEstimateCandidates = previous; - Cache.supplyHubMaxTax = previousTax; - Cache.supplyHubMaxFee = previousFee; - Cache.supplyHubOfferDays = previousOffer; - Cache.supplyHubAgreementDays = previousAgreement; + FactionManager.factions.clear(); + FactionManager.factions.addAll(previousFactions); + HubTransport.resetConfig(); } } - private static Destination destination(boolean ownRealm, double operator, double host, double taxable) { - return new Destination( - Group.READY, "host", "station", "Station", 2, InstallationKind.TRAIN_STATION, - ownRealm, true, 3000, operator, host, Map.of(), taxable); + private static List ids(List sites) { + return sites.stream().map(Site::installationId).toList(); } - private static int stationSlots() { - try { - return InstallationConfigLoader.getHubSlots(InstallationKind.TRAIN_STATION, 1); - } catch (IllegalStateException ex) { - return SupplyHubService.defaultHubSlots(InstallationKind.TRAIN_STATION, 1); - } - } - - private static Installation station(String id, int province, int x, int z) { - return new Installation(id, id, InstallationKind.TRAIN_STATION, province, x, z, 0L); + private static Site site(String id, boolean ownRealm, double tradeHere, double arrivesHere, boolean connected) { + return new Site("home", id, id, 1, InstallationKind.TRAIN_STATION, ownRealm, + tradeHere, arrivesHere, connected); } - private static InstallationHandler handler(Installation... installations) { - InstallationHandler handler = mock(InstallationHandler.class); - when(handler.getAll()).thenReturn(List.of(installations)); - return handler; + private static Guild guild() { + Guild guild = mock(Guild.class); + when(guild.getId()).thenReturn("bog"); + return guild; } - private static Faction faction(String id, boolean hubTax) { + private static Faction faction(String id) { Faction faction = mock(Faction.class); when(faction.getId()).thenReturn(id); - lenient().when(faction.hasFactionRule(Rules.HUB_TAX)).thenReturn(hubTax); - lenient().when(faction.hasFactionRule(Rules.SUPPLY_HUBS)).thenReturn(true); - lenient().when(faction.getModifiers(any(), any(), any())).thenReturn(List.of()); return faction; } - private static Guild guild(Faction faction, int capital, List hubs) { - Guild guild = mock(Guild.class); - when(guild.getId()).thenReturn("guild"); - when(guild.getFaction()).thenReturn(faction); - when(guild.hasCapital()).thenReturn(true); - when(guild.getCapital()).thenReturn(capital); - when(guild.getSupplyHubs()).thenReturn(hubs); - when(guild.isBase()).thenReturn(false); - when(guild.isBankrupt()).thenReturn(false); - when(guild.getBank()).thenReturn(mock(Bank.class)); - when(guild.getTradeBreakdown()).thenReturn(new TradeBreakdown()); - when(guild.getLedger()).thenReturn(new Ledger(guild)); - lenient().when(guild.getModifier(any())).thenReturn(0.0); - when(guild.getModifier(GuildModifier.TRADE_POWER)).thenReturn(20.0); - when(guild.getModifier(GuildModifier.TRADE_CARRY)).thenReturn(1.0); - when(guild.getModifier(GuildModifier.PRODUCTION)).thenReturn(10.0); - return guild; + private static Installation station(String id, int province, int x, int z) { + return station(id, InstallationKind.TRAIN_STATION, province, x, z); } - private static Road road(int count) { - boolean enabled = Cache.provincesEnabled; - Map carry = new HashMap<>(Cache.tradeCarry); - Cache.provincesEnabled = true; - Cache.tradeCarry.clear(); - Cache.tradeCarry.put(Terrain.PLAINS, 0.85); - Cache.tradeCarry.put(Terrain.BOG, 0.4); - ProvinceManager provinces = new ProvinceManager(); - Map map = new HashMap<>(); - for (int id = 1; id <= count; id++) { - String terrain = count == 1 ? Terrain.BOG.name() : Terrain.PLAINS.name(); - map.put(id, new Province(id, terrain, 50, id * 100, 0)); - if (id > 1) { - map.get(id).addNeighbour(id - 1); - map.get(id - 1).addNeighbour(id); - } - } - provinces.start(map); - Faction faction = faction("home", true); - Guild guild = guild(faction, 1, List.of()); - return new Road(provinces, faction, guild, enabled, carry); + private static Installation station(String id, InstallationKind kind, int province, int x, int z) { + return new Installation(id, id, kind, province, x, z, 0L); } - private record Road( - ProvinceManager provinces, Faction faction, Guild guild, boolean enabled, Map carry) { - void restore() { - Cache.provincesEnabled = enabled; - Cache.tradeCarry.clear(); - Cache.tradeCarry.putAll(carry); + private static ProvinceManager provinces(Guild guild, Map trade) { + Map map = new java.util.HashMap<>(); + for (Map.Entry entry : trade.entrySet()) { + Province province = new Province(entry.getKey(), Terrain.PLAINS.name(), 50, 0, 0); + province.setData(guild.getId(), new ProvinceDataEntry(guild, entry.getValue(), 0)); + map.put(entry.getKey(), province); } + ProvinceManager provinces = new ProvinceManager(); + provinces.start(map); + return provinces; } } diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubProposalCopyTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubProposalCopyTest.java index 236ec1af..a711e8bd 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubProposalCopyTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HubProposalCopyTest.java @@ -4,61 +4,48 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; -import java.util.Map; import org.junit.jupiter.api.Test; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Destination; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Group; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Terms; +import net.tfminecraft.simplefactions.guild.hub.HubEstimates.Site; import net.tfminecraft.simplefactions.installation.InstallationKind; class HubProposalCopyTest { @Test - void destinationLoreNamesBothSidesAndAnAssumedRailway() { - Destination ready = destination(Group.READY, "station", false, true, 1900, 88, -19); - List lore = HubProposalCopy.destinationLore(ready); + void laterHubLoreShowsWhatArrives() { + Site foreign = site(false, 12, 3.6); + List lore = HubProposalCopy.destinationLore(foreign, true); assertTrue(lore.get(0).contains("Ready now")); - assertTrue(lore.stream().anyMatch(line -> line.contains("§a+88.00") && line.contains("You"))); - assertTrue(lore.stream().anyMatch(line -> line.contains("§c-19.00") && line.contains("Host"))); - assertTrue(lore.stream().anyMatch(line -> line.contains("No railway yet") && line.contains("1900"))); + assertTrue(lore.stream().anyMatch(line -> line.contains("Trade power here") && line.contains("12"))); + assertTrue(lore.stream().anyMatch(line -> line.contains("Arrives here") && line.contains("3.6"))); + assertTrue(lore.stream().noneMatch(line -> line.contains("railway") || line.contains("track"))); assertTrue(lore.stream().anyMatch(line -> line.contains("Click to negotiate"))); - Destination own = destination(Group.READY, "station", true, false, 0, 40, 0); - assertTrue(HubProposalCopy.destinationLore(own).stream().anyMatch(line -> line.contains("Click to build"))); - - Destination imagined = destination(Group.WORTH_BUILDING, null, false, false, 0, 12, 3); - assertTrue(HubProposalCopy.destinationLore(imagined).stream() - .anyMatch(line -> line.contains("station has to be built"))); + Site own = site(true, 40, 8); + assertTrue(HubProposalCopy.destinationLore(own, true).stream() + .anyMatch(line -> line.contains("Click to build"))); } @Test - void negotiationLinesUseTheCachedDestinationAndTheFee() { - Destination destination = destination(Group.READY, "port", false, false, 0, 100, -10); - Terms terms = HubEstimates.applyTerms(destination, 10, 500); - List operator = HubProposalCopy.operatorLines(destination, terms); - assertTrue(operator.get(0).contains("before tax")); - assertTrue(operator.get(1).contains("10.00") && operator.get(1).contains("5.00")); - assertTrue(operator.get(2).contains("§a+85.00")); - - List host = HubProposalCopy.hostLines(destination, terms, Map.of("north", "North Guild")); - assertTrue(host.stream().anyMatch(line -> line.contains("Net") && line.contains("§a+5.00"))); - assertTrue(host.stream().anyMatch(line -> line.contains("North Guild") && line.contains("§c-4.00"))); + void aFirstHubShowsTradePowerWithoutAnArrival() { + Site own = site(true, 18, 0); + List lore = HubProposalCopy.destinationLore(own, false); + assertTrue(lore.stream().anyMatch(line -> line.contains("Trade power here") && line.contains("18"))); + assertTrue(lore.stream().noneMatch(line -> line.contains("Arrives here"))); } @Test - void breakEvenIsTheRateWhereTheHostNetsZero() { - Destination destination = destination(Group.READY, "port", false, false, 0, 100, -10); - assertEquals("§7Break-even is about §e10%", HubProposalCopy.breakEvenLine(destination, 0, 0, 20)); - assertEquals("§7Break-even is about §e5%§7 at this fee", - HubProposalCopy.breakEvenLine(destination, 500, 0, 20)); - assertEquals("§7The host gains even at 0%", - HubProposalCopy.breakEvenLine( - destination(Group.READY, "port", false, false, 0, 100, 5), 0, 0, 20)); - assertEquals("§7The host does not break even inside 0-5%", - HubProposalCopy.breakEvenLine(destination, 0, 0, 5)); - assertEquals("§7A hub in your own realm has no tax or fee", - HubProposalCopy.breakEvenLine(destination(Group.READY, "port", true, false, 0, 10, 0), 0, 0, 20)); + void negotiationShowsTradePowerHereAndWhatArrives() { + Site site = site(false, 10, 3.6); + List power = HubProposalCopy.powerLines(site); + assertTrue(power.get(0).contains("Trade power here") && power.get(0).contains("10")); + assertTrue(power.get(1).contains("Arrives here") && power.get(1).contains("3.6")); + assertEquals( + "§7The rate and the daily fee are what this realm charges.", + HubProposalCopy.hostLines(site).get(0)); + assertEquals( + "§7A hub in your own realm has no tax or fee", + HubProposalCopy.hostLines(site(true, 10, 0)).get(0)); } @Test @@ -70,11 +57,9 @@ void rateAndFeeStayInsideTheHostBracket() { assertEquals(0L, HubProposalCopy.clampFeeCents(-100, 500)); } - private static Destination destination( - Group group, String installationId, boolean ownRealm, boolean railway, double track, - double operator, double host) { - return new Destination( - group, "host", installationId, "North Port", 2, InstallationKind.PORT, ownRealm, - railway, track, operator, host, Map.of("north", -4.0), 100); + private static Site site(boolean ownRealm, double tradeHere, double arrivesHere) { + return new Site( + "host", "port", "North Port", 2, InstallationKind.PORT, ownRealm, + tradeHere, arrivesHere, true); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/hub/InfrastructureMenuCopyTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/hub/InfrastructureMenuCopyTest.java index b89b6dbb..38c3a91a 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/hub/InfrastructureMenuCopyTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/hub/InfrastructureMenuCopyTest.java @@ -7,7 +7,7 @@ import org.junit.jupiter.api.Test; -import net.tfminecraft.simplefactions.guild.hub.HubEstimates.InstallationPreview; +import net.tfminecraft.simplefactions.guild.hub.InfrastructurePreview.InstallationPreview; class InfrastructureMenuCopyTest { @Test 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 94e46bae..b5753b1a 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/hub/SupplyHubServiceTest.java @@ -45,6 +45,7 @@ import net.tfminecraft.simplefactions.guild.income.Ledger; import net.tfminecraft.simplefactions.guild.income.TradeBreakdown; import net.tfminecraft.simplefactions.guild.loans.LoanHandler; +import net.tfminecraft.simplefactions.installation.Installation; import net.tfminecraft.simplefactions.installation.InstallationKind; import net.tfminecraft.simplefactions.installation.handler.InstallationHandler; import net.tfminecraft.simplefactions.managers.FactionManager; @@ -82,6 +83,27 @@ void forbiddenEconomyRefusesBuildBeforeCheckingTheInstallation() { } } + @Test + void firstHubMustBeInOwnTerritory() { + Guild guild = mock(Guild.class); + Faction home = mock(Faction.class); + Player player = mock(Player.class); + Installation installation = mock(Installation.class); + when(player.getName()).thenReturn("leader"); + when(guild.getLeader()).thenReturn("leader"); + when(guild.getFaction()).thenReturn(home); + when(home.getId()).thenReturn("home"); + when(home.hasFactionRule(Rules.SUPPLY_HUBS)).thenReturn(true); + when(guild.getSupplyHubs()).thenReturn(new ArrayList<>()); + try (MockedStatic hubs = mockStatic(SupplyHubService.class, CALLS_REAL_METHODS); + MockedStatic factions = mockStatic(FactionManager.class)) { + hubs.when(() -> SupplyHubService.findInstallation("other", "port")).thenReturn(installation); + factions.when(() -> FactionManager.getByString("other")).thenReturn(null); + assertFalse(SupplyHubCommands.place(player, guild, "other", "port")); + verify(player).sendMessage("§cYour first hub has to be in your own territory"); + } + } + @Test void placeRefusesAForbiddenEconomyBeforeLookingUpTheInstallation() { Guild guild = mock(Guild.class); From abcc7bc58422b8ee49893a52c909d97470211242 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:19:03 +0000 Subject: [PATCH 2/3] Expire the hub terms chat prompt and update two menu lines A prompt older than 60 seconds, or one left by opening another menu, no longer swallows the player's next chat message. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../managers/inventory/HubTermsPrompt.java | 33 ++++++++++++++++--- .../managers/inventory/SupplyHubView.java | 4 +-- .../inventory/HubTermsPromptTest.java | 16 +++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 src/test/java/net/tfminecraft/simplefactions/managers/inventory/HubTermsPromptTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/HubTermsPrompt.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/HubTermsPrompt.java index 73df083c..47b6e772 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/HubTermsPrompt.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/HubTermsPrompt.java @@ -7,6 +7,7 @@ import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryOpenEvent; import org.bukkit.event.player.AsyncPlayerChatEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.scheduler.BukkitRunnable; @@ -22,6 +23,8 @@ /** Chat entry for a hub agreement's rate and fee. The chest stays closed until the number is typed. */ public final class HubTermsPrompt implements Listener { + private static final long PROMPT_TIMEOUT_MS = 60_000L; + private record Pending( String guildId, String hostId, @@ -30,7 +33,8 @@ private record Pending( long feeCents, int baseRate, long baseFeeCents, - boolean rateField) { + boolean rateField, + long askedAtMillis) { } private static final Map WAITING = new ConcurrentHashMap<>(); @@ -45,7 +49,8 @@ public static void ask( return; } WAITING.put(player.getUniqueId(), new Pending( - guild.getId(), hostId, installationId, rate, feeCents, baseRate, baseFeeCents, rateField)); + guild.getId(), hostId, installationId, rate, feeCents, baseRate, baseFeeCents, rateField, + System.currentTimeMillis())); player.closeInventory(); if (rateField) { Faction host = FactionManager.getByString(hostId); @@ -64,6 +69,13 @@ public void onQuit(PlayerQuitEvent event) { } } + @EventHandler + public void onInventoryOpen(InventoryOpenEvent event) { + if (event.getPlayer() != null) { + WAITING.remove(event.getPlayer().getUniqueId()); + } + } + // Retain Bukkit chat-event ordering and String message semantics for existing integrations. @SuppressWarnings("deprecation") @EventHandler @@ -76,6 +88,9 @@ public void onChat(AsyncPlayerChatEvent event) { if (pending == null) { return; } + if (isExpired(pending.askedAtMillis, System.currentTimeMillis())) { + return; + } event.setCancelled(true); String message = event.getMessage(); if (SimpleFactions.plugin == null) { @@ -104,7 +119,7 @@ private static void apply(Player player, Pending pending, String message) { Faction host = FactionManager.getByString(pending.hostId); RateRange range = HubAgreementService.allowedRateRange(host); if (typed == null || typed < range.minPercent() || typed > range.maxPercent()) { - WAITING.put(player.getUniqueId(), pending); + WAITING.put(player.getUniqueId(), askedAgain(pending)); player.sendMessage("§cType a whole percent from §e" + range.minPercent() + "§c to §e" + range.maxPercent() + "§c, or §ecancel§c."); return; @@ -115,7 +130,7 @@ private static void apply(Player player, Pending pending, String message) { Long cents = wholeCents(text); long maxCents = Math.round(Math.max(0, Cache.supplyHubMaxFee) * 100.0); if (cents == null || cents > maxCents) { - WAITING.put(player.getUniqueId(), pending); + WAITING.put(player.getUniqueId(), askedAgain(pending)); player.sendMessage("§cThe daily fee must be at least zero, in whole cents, and at most §e" + Formatter.formatMoney(Cache.supplyHubMaxFee) + "§c. Type §ecancel§c to go back."); return; @@ -158,4 +173,14 @@ private static Long wholeCents(String text) { return null; } } + + static boolean isExpired(long askedAtMillis, long nowMillis) { + return nowMillis - askedAtMillis > PROMPT_TIMEOUT_MS; + } + + private static Pending askedAgain(Pending pending) { + return new Pending(pending.guildId, pending.hostId, pending.installationId, + pending.rate, pending.feeCents, pending.baseRate, pending.baseFeeCents, + pending.rateField, System.currentTimeMillis()); + } } 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 dea096fc..a1cf8452 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/SupplyHubView.java @@ -114,7 +114,7 @@ private void populateGuildView(Player player, Guild guild, Inventory inventory) if (noHubs) { information.add("§7Choose §eCreate main hub §7to place the first one in your territory."); } else { - information.add("§7Choose §ePropose a hub §7to see where one would pay."); + information.add("§7Choose §ePropose a hub §7to see where your hubs can reach."); } } inventory.setItem(49, SupplyHubCreator.item(Material.PAPER, "§eSupply Hub Information", information)); @@ -136,7 +136,7 @@ private void populateGuildView(Player player, Guild guild, Inventory inventory) inventory.setItem(22, main); } else { ItemStack propose = SupplyHubCreator.item(Material.EMERALD, "§aPropose a hub", List.of( - "§7Where a hub would pay, from today's estimates.", + "§7Places your existing hubs can already reach.", "§eClick to choose a destination")); ItemMeta proposeMeta = propose.getItemMeta(); proposeMeta.getPersistentDataContainer().set(Keys.STRING_KEY, PersistentDataType.STRING, "propose"); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/HubTermsPromptTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/HubTermsPromptTest.java new file mode 100644 index 00000000..a22b5110 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/HubTermsPromptTest.java @@ -0,0 +1,16 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class HubTermsPromptTest { + @Test + void promptExpiresOnlyAfterSixtySeconds() { + long askedAtMillis = 1_000L; + + assertFalse(HubTermsPrompt.isExpired(askedAtMillis, askedAtMillis + 60_000L)); + assertTrue(HubTermsPrompt.isExpired(askedAtMillis, askedAtMillis + 60_001L)); + } +} From a62723acd38355bf0d374c78f5902dc8c550da42 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:44:46 +0000 Subject: [PATCH 3/3] Drop a hub terms answer when the prompt was cleared before it applied Co-Authored-By: Claude Opus 5.5 (1M context) --- .../simplefactions/managers/inventory/HubTermsPrompt.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/HubTermsPrompt.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/HubTermsPrompt.java index 47b6e772..660817b2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/HubTermsPrompt.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/HubTermsPrompt.java @@ -84,21 +84,27 @@ public void onChat(AsyncPlayerChatEvent event) { if (player == null) { return; } - Pending pending = WAITING.remove(player.getUniqueId()); + UUID playerId = player.getUniqueId(); + Pending pending = WAITING.get(playerId); if (pending == null) { return; } if (isExpired(pending.askedAtMillis, System.currentTimeMillis())) { + WAITING.remove(playerId, pending); return; } event.setCancelled(true); String message = event.getMessage(); if (SimpleFactions.plugin == null) { + WAITING.remove(playerId, pending); return; } new BukkitRunnable() { @Override public void run() { + if (!WAITING.remove(playerId, pending)) { + return; + } apply(player, pending, message); } }.runTask(SimpleFactions.plugin);