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..660817b2 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/HubTermsPrompt.java @@ -0,0 +1,192 @@ +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.inventory.InventoryOpenEvent; +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 static final long PROMPT_TIMEOUT_MS = 60_000L; + + private record Pending( + String guildId, + String hostId, + String installationId, + int rate, + long feeCents, + int baseRate, + long baseFeeCents, + boolean rateField, + long askedAtMillis) { + } + + 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, + System.currentTimeMillis())); + 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()); + } + } + + @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 + public void onChat(AsyncPlayerChatEvent event) { + Player player = event.getPlayer(); + if (player == null) { + return; + } + 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); + } + + 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(), askedAgain(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(), 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; + } + 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; + } + } + + 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/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..a1cf8452 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 your hubs can reach."); + } + } + 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( + "§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"); + 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); 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)); + } +}