From 2dd76a4d88fa461c7a9e4cef9a218a0e741f1dbc Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:14:44 +0200 Subject: [PATCH 1/6] Give overlords an espionage edge and let Spymasters share with overlords and vassals Overlords gain a configurable margin bonus spying on vassals and guarding against them. Each Spymaster can share information with their overlord or their vassals up to a chosen tier; shared fields reach the partner exactly. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../espionage/EspionageCommands.java | 14 +- .../espionage/EspionageConfig.java | 8 + .../espionage/EspionageMath.java | 8 +- .../espionage/EspionageService.java | 80 +++++++- .../espionage/EspionageState.java | 17 ++ .../espionage/IntelligenceReport.java | 10 +- .../espionage/SharingPartner.java | 10 + .../managers/inventory/EspionageView.java | 40 +++- .../managers/inventory/ReportedMenus.java | 3 +- src/main/resources/special-positions.yml | 8 + .../espionage/VassalIntelligenceTest.java | 194 ++++++++++++++++++ 11 files changed, 376 insertions(+), 16 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/espionage/SharingPartner.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java index 8a00d05d..88ddb78a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java @@ -52,7 +52,14 @@ public static boolean handle(Player player, String[] args) { EspionageView.settings(player, faction, manager); } else if (args[0].equalsIgnoreCase("positions")) EspionageView.positions(player, faction, manager); else EspionageView.spymasterOffice(player, faction, manager); - } else if (args[0].equalsIgnoreCase("spymaster") && args.length >= 2 && !args[1].equalsIgnoreCase("sabotage")) { + } else if (args[0].equalsIgnoreCase("spymaster") && args.length == 4 && args[1].equalsIgnoreCase("share")) { + var partner = args[2].equalsIgnoreCase("overlord") ? SharingPartner.OVERLORD + : args[2].equalsIgnoreCase("vassals") ? SharingPartner.VASSALS : null; + var tier = args[3].equalsIgnoreCase("none") ? IntelligenceTier.UNKNOWN : IntelligenceTier.parse(args[3]); + if (partner == null || tier == IntelligenceTier.UNKNOWN && !args[3].equalsIgnoreCase("none")) usage(player); + else EspionageService.setSharing(player, faction, partner, tier); + } else if (args[0].equalsIgnoreCase("spymaster") && args.length >= 2 && !args[1].equalsIgnoreCase("sabotage") + && !args[1].equalsIgnoreCase("share")) { if (args[1].equalsIgnoreCase("remove")) { if (args.length == 2) EspionageService.remove(player, faction); else usage(player); @@ -75,6 +82,7 @@ public static boolean handle(Player player, String[] args) { private static void usage(Player player) { player.sendMessage("§7/faction positions §8| §7/faction espionage §8| §7/faction spymaster "); player.sendMessage("§7/faction spymaster sabotage <0|25|50|75|100>"); + player.sendMessage("§7/faction spymaster share "); } public static List complete(Player player, String[] args) { @@ -90,9 +98,13 @@ public static List complete(Player player, String[] args) { if (Bukkit.getPlayerExact(name) != null && EspionageService.eligible(faction, name)) choices.add(name); } if (isHolder) choices.add("sabotage"); + if (isHolder && EspionageConfig.sharingAllowed()) choices.add("share"); } else if (isHolder && args[1].equalsIgnoreCase("sabotage")) { if (args.length == 3) choices.addAll(List.of("offense", "defense")); if (args.length == 4) choices.addAll(List.of("0", "25", "50", "75", "100")); + } else if (isHolder && args[1].equalsIgnoreCase("share")) { + if (args.length == 3) choices.addAll(List.of("overlord", "vassals")); + if (args.length == 4) choices.addAll(List.of("none", "rumours", "broad", "reliable", "detailed")); } String prefix = args[args.length - 1]; choices.removeIf(choice -> !choice.regionMatches(true, 0, prefix, 0, prefix.length())); diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java index fdc07270..3efeac63 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java @@ -17,6 +17,8 @@ public final class EspionageConfig { private static String reloadPermission = "simplefactions.espionage.reload"; private static double base = 50, extraPositionPenalty = .25, rollMultiplier = 1.25; private static int center = 6, cap = 16, aptitudeSpread = 20, luckSpread = 75, luckDraws = 3, rosterLimit = 23; + private static int overlordOffenseBonus = 25, overlordDefenseBonus = 25; + private static boolean sharingAllowed = true; private static final Map tiers = new java.util.EnumMap<>(IntelligenceTier.class); private static final Map minimums = new LinkedHashMap<>(); private static final Map cashflows = new LinkedHashMap<>(); @@ -75,6 +77,9 @@ public static void load(ConfigurationSection config) { luckSpread = (int) bounded(config, "espionage.checks.luck-spread", 75, 0, 1000); luckDraws = (int) bounded(config, "espionage.checks.luck-draws", 3, 1, 20); rosterLimit = (int) bounded(config, "espionage.intelligence.maximum-roster-size", 23, 0, 1000); + overlordOffenseBonus = (int) bounded(config, "espionage.vassalage.overlord-offense-bonus", 25, 0, 1000); + overlordDefenseBonus = (int) bounded(config, "espionage.vassalage.overlord-defense-bonus", 25, 0, 1000); + sharingAllowed = config.getBoolean("espionage.vassalage.allow-sharing", true); tiers.clear(); int previous = -1; for (var tier : IntelligenceTier.values()) { @@ -145,6 +150,9 @@ public static boolean allows(IntelligenceTier tier, String metric) { public static int luckSpread() { return luckSpread; } public static int luckDraws() { return luckDraws; } public static int rosterLimit() { return rosterLimit; } + public static int overlordOffenseBonus() { return overlordOffenseBonus; } + public static int overlordDefenseBonus() { return overlordDefenseBonus; } + public static boolean sharingAllowed() { return sharingAllowed; } public static double stabilityPenalty() { return stabilityPenalty; } public static double penaltyDays() { return penaltyDays; } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageMath.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageMath.java index a43af048..9805f8a7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageMath.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageMath.java @@ -62,9 +62,15 @@ public static int clamp(int value, int min, int max) { } public record Estimate(long lower, long upper) { + /** Shared information: one rounded value instead of a range. */ + public static Estimate exact(double value) { + long rounded = Math.round(value); + return new Estimate(rounded, rounded); + } + public boolean isExact() { return lower == upper; } public double midpoint() { return lower / 2.0 + upper / 2.0; } public String display() { - return lower + " to " + upper; + return isExact() ? Long.toString(lower) : lower + " to " + upper; } } } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index 75bd6ef4..1bbf59b9 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -11,6 +11,7 @@ import net.tfminecraft.simplefactions.database.Database; import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.RelationManager; import net.tfminecraft.simplefactions.objects.Faction; public final class EspionageService { @@ -359,6 +360,56 @@ public static boolean setSabotage(Player actor, Faction faction, boolean offense return true; } + /** Only the Spymaster opens their faction's information to its overlord or vassals, tier by tier. */ + public static boolean setSharing(Player actor, Faction faction, SharingPartner partner, IntelligenceTier tier) { + SpecialPositionAssignment holder = spymaster(faction); + if (!isOwn(actor, faction) || holder == null || !holder.isHolder(actor.getUniqueId())) { + actor.sendMessage("§cOnly the Spymaster can choose what to share with allied courts."); + return false; + } + if (!EspionageConfig.sharingAllowed()) { + actor.sendMessage("§cSharing intelligence with overlords and vassals is disabled."); + return false; + } + var state = faction.getEspionage(); + IntelligenceTier previous = state.sharing(partner); + state.share(partner, tier); + if (!new Database().saveFactionChecked(faction)) { + state.share(partner, previous); + actor.sendMessage("§cYour choice could not be saved. Your previous sharing remains in effect."); + return false; + } + // Partners rebuild today's report on their next menu, under the same daily rolls. + for (Faction other : partners(faction, partner)) + if (other.getEspionage() != null && other.getEspionage().forgetReport(faction.getId())) new Database().saveFaction(other); + actor.sendMessage(tier == IntelligenceTier.UNKNOWN ? "§7Your court no longer shares information with " + partner.label() + "." + : "§7Your court now shares everything up to §e" + tier.label() + "§7 with " + partner.label() + + ". Those details reach them exactly."); + return true; + } + + static java.util.List partners(Faction faction, SharingPartner partner) { + if (partner == SharingPartner.VASSALS) return faction.getVassals(); + Faction overlord = faction.getOverlord(); + return overlord == null ? java.util.List.of() : java.util.List.of(overlord); + } + + /** The tier the target opened to the observer as its direct overlord or vassal. */ + public static IntelligenceTier sharedTier(Faction target, Faction observer) { + if (!EspionageConfig.sharingAllowed() || target == null || observer == null || target.getEspionage() == null) + return IntelligenceTier.UNKNOWN; + if (RelationManager.isOverlord(target, observer)) return target.getEspionage().sharing(SharingPartner.OVERLORD); + if (RelationManager.isOverlord(observer, target)) return target.getEspionage().sharing(SharingPartner.VASSALS); + return IntelligenceTier.UNKNOWN; + } + + /** Overlords spy better on every vassal below them and guard better against them. */ + static int vassalageBonus(Faction observer, Faction target) { + if (RelationManager.isOnOverlordPath(target, observer)) return EspionageConfig.overlordOffenseBonus(); + if (RelationManager.isOnOverlordPath(observer, target)) return -EspionageConfig.overlordDefenseBonus(); + return 0; + } + /** Reading or clicking menus never generates intelligence. */ public static IntelligenceReport report(Player viewer, Faction target) { if (viewer == null || canViewExact(viewer, target)) return null; @@ -401,8 +452,9 @@ private static IntelligenceReport generateReport(Faction observer, Faction targe IntelligenceReport report = observer.getEspionage().report(target.getId(), target.getFoundedAt(), day, () -> { created[0] = true; int margin = observer.getEspionage().rolls(day, effectiveAptitude(observer, attacker), random).offense() - - target.getEspionage().rolls(day, effectiveAptitude(target, defender), random).defense(); - IntelligenceReport generated = createReport(metrics(target), margin, random); + - target.getEspionage().rolls(day, effectiveAptitude(target, defender), random).defense() + + vassalageBonus(observer, target); + IntelligenceReport generated = createReport(metrics(target), margin, sharedTier(target, observer), random); captureMembers(generated, target, margin, random); captureOffices(generated, target, random); ReportDetails.capture(generated, target); @@ -416,20 +468,23 @@ private static IntelligenceReport generateReport(Faction observer, Faction targe } static java.util.List sample(java.util.List members, int margin, RandomGenerator random) { + var tier = EspionageConfig.tier(margin); + return sampleFraction(members, EspionageConfig.allows(tier, "roster") ? EspionageConfig.settings(tier).rosterFraction() : 0, random); + } + + static java.util.List sampleFraction(java.util.List members, double fraction, RandomGenerator random) { java.util.List shuffled = new java.util.ArrayList<>(members); for (int i = shuffled.size() - 1; i > 0; i--) { int other = random.nextInt(i + 1); java.util.Collections.swap(shuffled, i, other); } - var tier = EspionageConfig.tier(margin); - double fraction = EspionageConfig.allows(tier, "roster") ? EspionageConfig.settings(tier).rosterFraction() : 0; int count = (int) Math.floor(shuffled.size() * fraction); return shuffled.subList(0, Math.min(EspionageConfig.rosterLimit(), count)).stream().sorted(String.CASE_INSENSITIVE_ORDER).toList(); } static void captureMembers(IntelligenceReport report, Faction target, int margin, RandomGenerator random) { - var sample = sample(target.getMembers().stream().distinct() - .filter(name -> !target.isLeader(name)).toList(), margin, random); + var members = target.getMembers().stream().distinct().filter(name -> !target.isLeader(name)).toList(); + var sample = report.exact("roster") ? sampleFraction(members, 1, random) : sample(members, margin, random); report.members = sample.stream().map(name -> CharacterNames.forForeign(name) + " §7— " + net.tfminecraft.simplefactions.utils.Represents.represents(target, name)).toList(); for (var guild : target.getGuildHandler().getGuilds()) { @@ -461,9 +516,18 @@ public static Double visibleValue(Player viewer, Faction target, String metric, } static IntelligenceReport createReport(Map metrics, int margin, RandomGenerator random) { + return createReport(metrics, margin, IntelligenceTier.UNKNOWN, random); + } + + static IntelligenceReport createReport(Map metrics, int margin, IntelligenceTier shared, RandomGenerator random) { IntelligenceReport report = new IntelligenceReport(); report.quality = EspionageMath.quality(margin); + if (shared != IntelligenceTier.UNKNOWN) report.shared = shared.key(); metrics.forEach((key, value) -> { + if (report.exact(key)) { + if (Double.isFinite(value)) report.estimates.put(key, EspionageMath.Estimate.exact(value)); + return; + } if (!report.allows(key)) return; boolean signed = !IntelligenceRanges.nonnegative(key); var estimate = EspionageMath.estimate(value, margin, signed, random); @@ -482,6 +546,10 @@ static void captureOffices(IntelligenceReport report, Faction target, RandomGene if (!report.allows(key)) continue; double aptitude = office == SpecialPosition.SPYMASTER ? effectiveAptitude(target, holder) : holder == null ? 0 : holder.aptitude; + if (report.exact(key)) { + report.estimates.put(key, EspionageMath.Estimate.exact(aptitude)); + continue; + } var range = EspionageMath.estimate(aptitude, EspionageConfig.settings(report.tier()).minimumMargin(), false, random); range = IntelligenceRanges.reasonable(key, range, report.tier()); if (range != null) report.estimates.put(key, range); diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java index 26d56c31..b8aaec85 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java @@ -19,6 +19,20 @@ public class EspionageState { // Last deliberate appointment per office; starts the wait before the next one. private Map lastAppointments = new EnumMap<>(SpecialPosition.class); + // Chosen by the Spymaster. Unknown shares nothing; older saves have no entry. + private Map sharing = new EnumMap<>(SharingPartner.class); + + public IntelligenceTier sharing(SharingPartner partner) { + var tier = sharing == null ? null : sharing.get(partner); + return tier == null ? IntelligenceTier.UNKNOWN : tier; + } + + void share(SharingPartner partner, IntelligenceTier tier) { + if (sharing == null) sharing = new EnumMap<>(SharingPartner.class); + if (tier == null || tier == IntelligenceTier.UNKNOWN) sharing.remove(partner); + else sharing.put(partner, tier); + } + void pendingFounder(SpecialPosition office) { pendingFounders.add(office); } void pendingFounder(SpecialPosition office, String characterId) { pendingFounder(office); @@ -141,6 +155,9 @@ public IntelligenceReport cachedReport(String targetId, long foundedAt, long day return report != null && report.day == day && report.targetFoundedAt == foundedAt ? report : null; } + /** Drops today's report on one target so the next menu rebuilds it under the same daily rolls. */ + boolean forgetReport(String targetId) { return reports.remove(targetId) != null; } + public void resetReportsAndRolls() { reports.clear(); dailyRolls = null; diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java index 6d8fcd68..637b26da 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java @@ -10,6 +10,8 @@ public class IntelligenceReport { public long day; public long targetFoundedAt; public String quality; + /** Highest tier the target's Spymaster opened to this observer; fields at or below it are exact. */ + public String shared; public Map estimates = new LinkedHashMap<>(); public java.util.List members = new java.util.ArrayList<>(); public Map> guildMembers = new LinkedHashMap<>(); @@ -36,8 +38,10 @@ public String display(String metric) { } public EspionageMath.Estimate estimate(String metric) { - if (!EspionageConfig.allows(tier(), metric)) return null; var raw = estimates == null ? null : estimates.get(metric); + // A field that left the shared tiers after a reload falls back to the range rules, which reject exact values. + if (exact(metric) && raw != null && raw.isExact()) return raw; + if (!EspionageConfig.allows(tier(), metric)) return null; Long maximum = maximums == null ? null : maximums.get(metric); if (raw != null && maximum != null) { raw = new EspionageMath.Estimate(Math.max(0, raw.lower()), Math.min(maximum, raw.upper())); @@ -51,7 +55,9 @@ public String qualityLabel() { } public IntelligenceTier tier() { return IntelligenceTier.parse(quality); } - public boolean allows(String field) { return EspionageConfig.allows(tier(), field); } + public IntelligenceTier sharedTier() { return shared == null ? IntelligenceTier.UNKNOWN : IntelligenceTier.parse(shared); } + public boolean exact(String field) { return EspionageConfig.allows(sharedTier(), field); } + public boolean allows(String field) { return EspionageConfig.allows(tier(), field) || exact(field); } public String officeHolder(SpecialPosition office) { return !allows("office-holder") || officeHolders == null ? UNKNOWN : officeHolders.getOrDefault(office, UNKNOWN); } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/SharingPartner.java b/src/main/java/net/tfminecraft/simplefactions/espionage/SharingPartner.java new file mode 100644 index 00000000..fa5dda73 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/SharingPartner.java @@ -0,0 +1,10 @@ +package net.tfminecraft.simplefactions.espionage; + +/** The allied courts a Spymaster can open their faction's information to. */ +public enum SharingPartner { + OVERLORD("your overlord"), VASSALS("your vassals"); + + private final String label; + SharingPartner(String label) { this.label = label; } + public String label() { return label; } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java index 8d4fc2b8..c394a788 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java @@ -5,6 +5,8 @@ import net.tfminecraft.simplefactions.espionage.IntelligenceLedger; import net.tfminecraft.simplefactions.espionage.SpecialPosition; import net.tfminecraft.simplefactions.espionage.EspionageConfig; +import net.tfminecraft.simplefactions.espionage.IntelligenceTier; +import net.tfminecraft.simplefactions.espionage.SharingPartner; import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.guild.income.Cashflow; import java.util.ArrayList; @@ -72,10 +74,12 @@ private static ItemStack reportHeader(Player viewer, IntelligenceReport report) boolean missingSpymaster = !EspionageService.hasSpymaster(observer); String last = missingSpymaster ? "\u00a77The Spymaster's office stands vacant; no findings reach your court." : "\u00a77These are today\u2019s findings, delivered by your spymaster, " + CharacterNames.of(spy.playerName) + "."; - return item(Material.SPYGLASS, "Foreign intelligence", - "\u00a77Report quality: \u00a7e" + (missingSpymaster ? "Absent" - : report == null ? net.tfminecraft.simplefactions.espionage.IntelligenceTier.UNKNOWN.label() : report.qualityLabel()), - report == null ? "\u00a77No dated account has yet reached your court." : "\u00a77Dated " + report.loreDate(), last); + List lore = new ArrayList<>(List.of("\u00a77Report quality: \u00a7e" + (missingSpymaster ? "Absent" + : report == null ? IntelligenceTier.UNKNOWN.label() : report.qualityLabel()), + report == null ? "\u00a77No dated account has yet reached your court." : "\u00a77Dated " + report.loreDate(), last)); + if (!missingSpymaster && report != null && report.sharedTier() != IntelligenceTier.UNKNOWN) + lore.add("\u00a7aTheir Spymaster shares everything up to " + report.sharedTier().label() + " exactly."); + return item(Material.SPYGLASS, "Foreign intelligence", lore.toArray(String[]::new)); } public static ItemStack ledgerItem(IntelligenceReport report, Guild guild) { @@ -306,9 +310,17 @@ public static void settings(Player viewer, Faction faction, InventoryManager man buildUpLine(holder, System.currentTimeMillis()), "§7Sabotage is voluntary and disabled on appointment.", "§7Only you can see or change these choices.", - "§7Changes affect your next daily rolls.", "§7Existing daily rolls and reports never reroll.")); + "§7Sabotage changes affect your next daily rolls.", "§7Existing daily rolls and reports never reroll.")); inventory.setItem(11, conduct("Offensive sabotage", holder.offenseReduction)); inventory.setItem(15, conduct("Defensive sabotage", holder.defenseReduction)); + if (EspionageConfig.sharingAllowed()) { + var overlord = faction.getOverlord(); + inventory.setItem(SHARE_OVERLORD_SLOT, sharing("Share with your overlord", faction.getEspionage().sharing(SharingPartner.OVERLORD), + overlord == null ? "\u00a78Your faction has no overlord." : "\u00a77Overlord: \u00a7f" + overlord.getName())); + int vassals = faction.getVassals().size(); + inventory.setItem(SHARE_VASSALS_SLOT, sharing("Share with your vassals", faction.getEspionage().sharing(SharingPartner.VASSALS), + vassals == 0 ? "\u00a78Your faction has no vassals." : "\u00a77Vassals: \u00a7f" + vassals)); + } inventory.setItem(26, manager.createBackButton(SFGUI.SPYMASTER_SETTINGS)); viewer.openInventory(inventory); } @@ -325,6 +337,20 @@ private static String buildUpTerms() { + "% aptitude, reaching full over " + EspionageService.duration(Math.round(EspionageConfig.buildUpDays() * 86_400_000)) + "."; } + private static final int SHARE_OVERLORD_SLOT = 21, SHARE_VASSALS_SLOT = 23; + + private static ItemStack sharing(String title, IntelligenceTier tier, String partner) { + return item(tier == IntelligenceTier.UNKNOWN ? Material.BOOK : Material.WRITABLE_BOOK, title, partner, + tier == IntelligenceTier.UNKNOWN ? "\u00a7cSharing nothing" : "\u00a7aSharing up to " + tier.label(), + "\u00a77Information shown at this tier or lower", "\u00a77reaches them exactly, not as ranges.", + "\u00a77Click to cycle: nothing, rumours, broad, reliable, detailed."); + } + + static IntelligenceTier nextSharing(IntelligenceTier tier) { + var tiers = IntelligenceTier.values(); + return tiers[(tier.ordinal() + 1) % tiers.length]; + } + private static ItemStack conduct(String title, int reduction) { return item(reduction == 0 ? Material.LIME_DYE : Material.RED_DYE, title, reduction == 0 ? "§aDisabled" : "§cRoll reduced by " + reduction, @@ -411,6 +437,10 @@ else if (slot == 11 || slot == 15) { if (EspionageService.setSabotage(viewer, faction, slot == 11, (current + 25) % 125)) { settings(viewer, faction, manager); } + } else if (slot == SHARE_OVERLORD_SLOT || slot == SHARE_VASSALS_SLOT) { + var partner = slot == SHARE_OVERLORD_SLOT ? SharingPartner.OVERLORD : SharingPartner.VASSALS; + if (EspionageService.setSharing(viewer, faction, partner, nextSharing(faction.getEspionage().sharing(partner)))) + settings(viewer, faction, manager); } } else { if (!faction.isLeader(viewer.getName())) { viewer.closeInventory(); return; } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java index ec0c37ba..3ec6111b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java @@ -65,7 +65,8 @@ public static ItemStack branch(Player viewer, Guild guild, net.tfminecraft.simpl if (levels != null) { double first = modifier.getCurrent((int) Math.min(Integer.MAX_VALUE, levels.lower())); double second = modifier.getCurrent((int) Math.min(Integer.MAX_VALUE, levels.upper())); - estimate = (key.isPositive() ? "#4fd945" : "#cf493a") + Math.floor(Math.min(first, second)) + " to " + Math.ceil(Math.max(first, second)); + estimate = (key.isPositive() ? "#4fd945" : "#cf493a") + (levels.isExact() ? first + : Math.floor(Math.min(first, second)) + " to " + Math.ceil(Math.max(first, second))); } lore.add("\u00a7f - " + key.getName() + ": " + estimate); } diff --git a/src/main/resources/special-positions.yml b/src/main/resources/special-positions.yml index fdd77143..c775ff34 100644 --- a/src/main/resources/special-positions.yml +++ b/src/main/resources/special-positions.yml @@ -45,6 +45,14 @@ espionage: aptitude-multiplier: 1.25 luck-spread: 75 luck-draws: 3 + vassalage: + # Added to an overlord's margin when its network spies on a vassal (direct or further down). + overlord-offense-bonus: 25 + # Taken from a vassal's margin when its network spies on any overlord above it. + overlord-defense-bonus: 25 + # Lets each Spymaster share information with their overlord and with their vassals, chosen by tier. + # Every field visible at the shared tier or below then reaches that partner exactly instead of as a range. + allow-sharing: true intelligence: # Margin = observing faction's offense minus target's defense. Rumours is the minimum report quality. # Negative margins return Rumours; individual fields stay Unknown below their required tier. diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java new file mode 100644 index 00000000..28f97187 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java @@ -0,0 +1,194 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.UUID; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.managers.RelationManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.utils.Represents; + +class VassalIntelligenceTest { + @AfterEach void restoreDefaults() { EspionageConfig.load(new YamlConfiguration()); } + + @Test void sharedTiersAreExactWhileHigherTiersKeepTheRolledQuality() { + Map values = Map.of("Wealth", 1234.4, "Prosperity", 61.6, "Administrative power", 40.0); + var report = EspionageService.createReport(values, -50, IntelligenceTier.BROAD, new Random(1)); + assertEquals(IntelligenceTier.RUMOURS, report.tier()); + assertEquals("1234", report.display("Wealth")); + assertEquals("62", report.display("Prosperity")); + assertEquals(IntelligenceReport.UNKNOWN, report.display("Administrative power")); + assertTrue(report.allows("levies")); + assertFalse(report.allows("office-holder")); + var restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(report), IntelligenceReport.class); + assertEquals(IntelligenceTier.BROAD, restored.sharedTier()); + assertEquals("1234", restored.display("Wealth")); + + var unshared = EspionageService.createReport(values, -50, new Random(1)); + assertEquals(IntelligenceTier.UNKNOWN, unshared.sharedTier()); + assertNotEquals("1234", unshared.display("Wealth")); + assertEquals(IntelligenceReport.UNKNOWN, unshared.display("Prosperity")); + } + + @Test void sharingIgnoresNonFiniteValuesAndFallsBackWhenAFieldLeavesTheSharedTiers() { + var report = EspionageService.createReport(Map.of("Wealth", Double.NaN, "Prosperity", 50.0), 0, IntelligenceTier.BROAD, new Random(1)); + assertNull(report.estimate("Wealth")); + var config = new YamlConfiguration(); + config.set("espionage.intelligence.minimum-tiers.prosperity", "reliable"); + EspionageConfig.load(config); + assertNull(report.estimate("Prosperity"), "An exact value is never shown through the range rules"); + } + + @Test void sharedRosterAndOfficesAreComplete() { + var target = mock(Faction.class, RETURNS_DEEP_STUBS); + var members = java.util.stream.IntStream.range(0, 10).mapToObj(index -> "Account" + index).toList(); + when(target.getMembers()).thenReturn(members); + when(target.getGuildHandler().getGuilds()).thenReturn(List.of()); + var state = new EspionageState(); + var holder = new SpecialPositionAssignment(); + holder.playerName = "Account1"; + state.appoint(holder, 70); + when(target.getEspionage()).thenReturn(state); + when(target.isMemberIgnoreCase(anyString())).thenReturn(true); + var report = EspionageService.createReport(Map.of(), -50, IntelligenceTier.DETAILED, new Random(2)); + try (var names = mockStatic(CharacterNames.class); var represents = mockStatic(Represents.class)) { + names.when(() -> CharacterNames.forForeign(anyString())).thenAnswer(call -> call.getArgument(0)); + represents.when(() -> Represents.represents(eq(target), anyString())).thenReturn("Guild"); + EspionageService.captureMembers(report, target, -50, new Random(2)); + EspionageService.captureOffices(report, target, new Random(2)); + } + assertEquals(10, report.members.size()); + assertEquals("Account1", report.officeHolder(SpecialPosition.SPYMASTER)); + assertEquals("70", report.display(IntelligenceReport.officeAptitudeKey(SpecialPosition.SPYMASTER))); + } + + @Test void onlyDirectPartnersReceiveTheirChosenTier() { + Faction vassal = mock(Faction.class), overlord = mock(Faction.class), stranger = mock(Faction.class); + var vassalState = new EspionageState(); + vassalState.share(SharingPartner.OVERLORD, IntelligenceTier.RELIABLE); + var overlordState = new EspionageState(); + overlordState.share(SharingPartner.VASSALS, IntelligenceTier.RUMOURS); + when(vassal.getEspionage()).thenReturn(vassalState); + when(overlord.getEspionage()).thenReturn(overlordState); + when(stranger.getEspionage()).thenReturn(new EspionageState()); + try (var relations = mockStatic(RelationManager.class)) { + relations.when(() -> RelationManager.isOverlord(vassal, overlord)).thenReturn(true); + assertEquals(IntelligenceTier.RELIABLE, EspionageService.sharedTier(vassal, overlord)); + assertEquals(IntelligenceTier.RUMOURS, EspionageService.sharedTier(overlord, vassal)); + assertEquals(IntelligenceTier.UNKNOWN, EspionageService.sharedTier(vassal, stranger)); + assertEquals(IntelligenceTier.UNKNOWN, EspionageService.sharedTier(null, stranger)); + var config = new YamlConfiguration(); + config.set("espionage.vassalage.allow-sharing", false); + EspionageConfig.load(config); + assertEquals(IntelligenceTier.UNKNOWN, EspionageService.sharedTier(vassal, overlord)); + } + } + + @Test void overlordsRollBetterAgainstEveryVassalBelowThem() { + Faction vassal = mock(Faction.class), overlord = mock(Faction.class), stranger = mock(Faction.class); + var config = new YamlConfiguration(); + config.set("espionage.vassalage.overlord-offense-bonus", 30); + config.set("espionage.vassalage.overlord-defense-bonus", 10); + EspionageConfig.load(config); + try (var relations = mockStatic(RelationManager.class)) { + relations.when(() -> RelationManager.isOnOverlordPath(vassal, overlord)).thenReturn(true); + assertEquals(30, EspionageService.vassalageBonus(overlord, vassal)); + assertEquals(-10, EspionageService.vassalageBonus(vassal, overlord)); + assertEquals(0, EspionageService.vassalageBonus(stranger, vassal)); + } + EspionageConfig.load(new YamlConfiguration()); + assertEquals(25, EspionageConfig.overlordOffenseBonus()); + assertEquals(25, EspionageConfig.overlordDefenseBonus()); + assertTrue(EspionageConfig.sharingAllowed()); + } + + @Test void onlyTheSpymasterSharesAndPartnersRebuildTodaysReport() { + Faction faction = mock(Faction.class), overlord = mock(Faction.class), vassal = mock(Faction.class); + Player spy = mock(Player.class), member = mock(Player.class); + var state = new EspionageState(); + var holder = new SpecialPositionAssignment(); + holder.playerName = "Spy"; + holder.playerId = UUID.randomUUID(); + state.appoint(holder, 60); + when(faction.getId()).thenReturn("faction"); + when(faction.getEspionage()).thenReturn(state); + when(faction.isMemberIgnoreCase(anyString())).thenReturn(true); + when(faction.getOverlord()).thenReturn(overlord); + when(faction.getVassals()).thenReturn(List.of(vassal)); + when(spy.getName()).thenReturn("Spy"); + when(spy.getUniqueId()).thenReturn(holder.playerId); + when(member.getName()).thenReturn("Member"); + when(member.getUniqueId()).thenReturn(UUID.randomUUID()); + var overlordState = new EspionageState(); + overlordState.report("faction", 0, 1, IntelligenceReport::new); + when(overlord.getEspionage()).thenReturn(overlordState); + when(vassal.getEspionage()).thenReturn(new EspionageState()); + try (var databases = mockConstruction(Database.class, (database, context) -> + when(database.saveFactionChecked(faction)).thenReturn(true))) { + assertFalse(EspionageService.setSharing(member, faction, SharingPartner.OVERLORD, IntelligenceTier.BROAD)); + assertEquals(IntelligenceTier.UNKNOWN, state.sharing(SharingPartner.OVERLORD)); + assertTrue(EspionageService.setSharing(spy, faction, SharingPartner.OVERLORD, IntelligenceTier.BROAD)); + assertEquals(IntelligenceTier.BROAD, state.sharing(SharingPartner.OVERLORD)); + assertNull(overlordState.cachedReport("faction", 0, 1)); + verify(databases.constructed().get(1)).saveFaction(overlord); + assertTrue(EspionageService.setSharing(spy, faction, SharingPartner.VASSALS, IntelligenceTier.DETAILED)); + assertEquals(3, databases.constructed().size(), "A partner without a cached report needs no save"); + assertTrue(EspionageService.setSharing(spy, faction, SharingPartner.OVERLORD, IntelligenceTier.UNKNOWN)); + assertEquals(IntelligenceTier.UNKNOWN, state.sharing(SharingPartner.OVERLORD)); + var config = new YamlConfiguration(); + config.set("espionage.vassalage.allow-sharing", false); + EspionageConfig.load(config); + assertFalse(EspionageService.setSharing(spy, faction, SharingPartner.VASSALS, IntelligenceTier.UNKNOWN)); + assertEquals(IntelligenceTier.DETAILED, state.sharing(SharingPartner.VASSALS)); + } + } + + @Test void failedSaveKeepsThePreviousSharing() { + Faction faction = mock(Faction.class); + Player spy = mock(Player.class); + var state = new EspionageState(); + var holder = new SpecialPositionAssignment(); + holder.playerName = "Spy"; + holder.playerId = UUID.randomUUID(); + state.appoint(holder, 60); + state.share(SharingPartner.VASSALS, IntelligenceTier.RUMOURS); + when(faction.getEspionage()).thenReturn(state); + when(faction.isMemberIgnoreCase(anyString())).thenReturn(true); + when(spy.getName()).thenReturn("Spy"); + when(spy.getUniqueId()).thenReturn(holder.playerId); + try (var databases = mockConstruction(Database.class)) { + assertFalse(EspionageService.setSharing(spy, faction, SharingPartner.VASSALS, IntelligenceTier.DETAILED)); + assertEquals(IntelligenceTier.RUMOURS, state.sharing(SharingPartner.VASSALS)); + verify(faction, never()).getVassals(); + } + } + + @Test void sharingPersistsAndOlderSavesShareNothing() { + var state = new EspionageState(); + state.share(SharingPartner.VASSALS, IntelligenceTier.RELIABLE); + var restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(state), EspionageState.class); + assertEquals(IntelligenceTier.RELIABLE, restored.sharing(SharingPartner.VASSALS)); + assertEquals(IntelligenceTier.UNKNOWN, restored.sharing(SharingPartner.OVERLORD)); + var old = JsonUtil.GSON.fromJson("{\"sharing\":null}", EspionageState.class); + assertEquals(IntelligenceTier.UNKNOWN, old.sharing(SharingPartner.OVERLORD)); + old.share(SharingPartner.OVERLORD, IntelligenceTier.BROAD); + assertEquals(IntelligenceTier.BROAD, old.sharing(SharingPartner.OVERLORD)); + } + + @Test void exactEstimatesDisplayOneValue() { + assertEquals("12", EspionageMath.Estimate.exact(11.6).display()); + assertTrue(EspionageMath.Estimate.exact(-3).isExact()); + assertEquals("3 to 5", new EspionageMath.Estimate(3, 5).display()); + } +} From 6e226346dca6f4f512ad636ecaf31a3ba7d89864 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:28:08 +0200 Subject: [PATCH 2/6] Forget reports when vassalage ends and honour allow-sharing in cached reports Co-Authored-By: Claude Opus 5.5 (1M context) --- .../espionage/EspionageService.java | 11 +++++++ .../espionage/IntelligenceReport.java | 5 ++- .../managers/RelationManager.java | 1 + .../espionage/VassalIntelligenceTest.java | 32 +++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index 1bbf59b9..b2223275 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -394,6 +394,17 @@ static java.util.List partners(Faction faction, SharingPartner partner) return overlord == null ? java.util.List.of() : java.util.List.of(overlord); } + /** Former partners lose today's reports on each other, including anything shared. */ + public static void forgetReports(Faction first, Faction second) { + forgetReport(first, second); + forgetReport(second, first); + } + + private static void forgetReport(Faction observer, Faction target) { + if (observer == null || target == null || observer.getEspionage() == null) return; + if (observer.getEspionage().forgetReport(target.getId())) new Database().saveFaction(observer); + } + /** The tier the target opened to the observer as its direct overlord or vassal. */ public static IntelligenceTier sharedTier(Faction target, Faction observer) { if (!EspionageConfig.sharingAllowed() || target == null || observer == null || target.getEspionage() == null) diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java index 637b26da..06cd37cd 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java @@ -56,7 +56,10 @@ public String qualityLabel() { public IntelligenceTier tier() { return IntelligenceTier.parse(quality); } public IntelligenceTier sharedTier() { return shared == null ? IntelligenceTier.UNKNOWN : IntelligenceTier.parse(shared); } - public boolean exact(String field) { return EspionageConfig.allows(sharedTier(), field); } + /** Turning sharing off hides shared values in today's reports too. */ + public boolean exact(String field) { + return EspionageConfig.sharingAllowed() && EspionageConfig.allows(sharedTier(), field); + } public boolean allows(String field) { return EspionageConfig.allows(tier(), field) || exact(field); } public String officeHolder(SpecialPosition office) { return !allows("office-holder") || officeHolders == null ? UNKNOWN : officeHolders.getOrDefault(office, UNKNOWN); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java index b48153fb..d8f3ac1d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java @@ -51,6 +51,7 @@ public static boolean endVassalage(Faction origin, Faction target, boolean hosti if(isOverlord(origin, target) || isOverlord(target, origin)) { net.tfminecraft.simplefactions.war.commitment.WarCommitmentService.onVassalageEnded(origin, target); reset(origin, target, hostile); + net.tfminecraft.simplefactions.espionage.EspionageService.forgetReports(origin, target); MercenaryLoyaltyWatcher.onRelationChanged(origin, target); return true; } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java index 28f97187..5add0655 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java @@ -50,6 +50,38 @@ class VassalIntelligenceTest { assertNull(report.estimate("Prosperity"), "An exact value is never shown through the range rules"); } + @Test void disablingSharingHidesSharedValuesInCachedReports() { + var report = EspionageService.createReport(Map.of("Wealth", 500.0, "Prosperity", 50.0), -50, IntelligenceTier.BROAD, new Random(1)); + assertEquals("50", report.display("Prosperity")); + var config = new YamlConfiguration(); + config.set("espionage.vassalage.allow-sharing", false); + EspionageConfig.load(config); + assertFalse(report.allows("prosperity")); + assertNull(report.estimate("Prosperity")); + assertNull(report.estimate("Wealth")); + } + + @Test void endingVassalageForgetsTodaysReportsInBothDirections() { + Faction overlord = mock(Faction.class), vassal = mock(Faction.class), stranger = mock(Faction.class); + var overlordState = new EspionageState(); + var vassalState = new EspionageState(); + when(overlord.getId()).thenReturn("overlord"); + when(vassal.getId()).thenReturn("vassal"); + when(overlord.getEspionage()).thenReturn(overlordState); + when(vassal.getEspionage()).thenReturn(vassalState); + overlordState.report("vassal", 0, 1, IntelligenceReport::new); + overlordState.report("other", 0, 1, IntelligenceReport::new); + try (var databases = mockConstruction(Database.class)) { + EspionageService.forgetReports(overlord, vassal); + assertNull(overlordState.cachedReport("vassal", 0, 1)); + assertNotNull(overlordState.cachedReport("other", 0, 1)); + assertEquals(1, databases.constructed().size(), "Only the faction that lost a report is saved"); + verify(databases.constructed().getFirst()).saveFaction(overlord); + EspionageService.forgetReports(stranger, null); + assertEquals(1, databases.constructed().size()); + } + } + @Test void sharedRosterAndOfficesAreComplete() { var target = mock(Faction.class, RETURNS_DEEP_STUBS); var members = java.util.stream.IntStream.range(0, 10).mapToObj(index -> "Account" + index).toList(); From 29ca329f9b806358f405f97a0a23c786b6572ef0 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:40:23 +0200 Subject: [PATCH 3/6] Keep the rolled range behind shared values for when sharing is turned off Co-Authored-By: Claude Opus 5.5 (1M context) --- .../espionage/EspionageService.java | 23 ++++++++++--------- .../espionage/IntelligenceReport.java | 9 ++++++-- .../espionage/VassalIntelligenceTest.java | 9 ++++++-- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index b2223275..2644a6fb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -535,19 +535,23 @@ static IntelligenceReport createReport(Map metrics, int margin, report.quality = EspionageMath.quality(margin); if (shared != IntelligenceTier.UNKNOWN) report.shared = shared.key(); metrics.forEach((key, value) -> { - if (report.exact(key)) { - if (Double.isFinite(value)) report.estimates.put(key, EspionageMath.Estimate.exact(value)); - return; - } - if (!report.allows(key)) return; + if (!EspionageConfig.allows(report.tier(), key) && !report.exact(key)) return; boolean signed = !IntelligenceRanges.nonnegative(key); var estimate = EspionageMath.estimate(value, margin, signed, random); estimate = IntelligenceRanges.reasonable(key, estimate, report.tier()); - if (estimate != null) report.estimates.put(key, estimate); + if (report.exact(key)) share(report, key, value, estimate); + else if (estimate != null) report.estimates.put(key, estimate); }); return report; } + /** Keeps the rolled range too, for when sharing is switched off during the day. */ + private static void share(IntelligenceReport report, String key, double value, EspionageMath.Estimate rolled) { + if (!Double.isFinite(value)) return; + report.estimates.put(key, EspionageMath.Estimate.exact(value)); + if (rolled != null && EspionageConfig.allows(report.tier(), key)) report.unshared.put(key, rolled); + } + static void captureOffices(IntelligenceReport report, Faction target, RandomGenerator random) { for (var office : SpecialPosition.values()) { var holder = target.getEspionage().holder(office); @@ -557,13 +561,10 @@ static void captureOffices(IntelligenceReport report, Faction target, RandomGene if (!report.allows(key)) continue; double aptitude = office == SpecialPosition.SPYMASTER ? effectiveAptitude(target, holder) : holder == null ? 0 : holder.aptitude; - if (report.exact(key)) { - report.estimates.put(key, EspionageMath.Estimate.exact(aptitude)); - continue; - } var range = EspionageMath.estimate(aptitude, EspionageConfig.settings(report.tier()).minimumMargin(), false, random); range = IntelligenceRanges.reasonable(key, range, report.tier()); - if (range != null) report.estimates.put(key, range); + if (report.exact(key)) share(report, key, aptitude, range); + else if (range != null) report.estimates.put(key, range); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java index 06cd37cd..e0cccf4b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/IntelligenceReport.java @@ -13,6 +13,8 @@ public class IntelligenceReport { /** Highest tier the target's Spymaster opened to this observer; fields at or below it are exact. */ public String shared; public Map estimates = new LinkedHashMap<>(); + /** The rolled ranges behind shared exact values. */ + public Map unshared = new LinkedHashMap<>(); public java.util.List members = new java.util.ArrayList<>(); public Map> guildMembers = new LinkedHashMap<>(); public java.util.List roster = new java.util.ArrayList<>(); @@ -39,8 +41,11 @@ public String display(String metric) { public EspionageMath.Estimate estimate(String metric) { var raw = estimates == null ? null : estimates.get(metric); - // A field that left the shared tiers after a reload falls back to the range rules, which reject exact values. - if (exact(metric) && raw != null && raw.isExact()) return raw; + if (raw != null && raw.isExact()) { + if (exact(metric)) return raw; + // No longer shared after a reload: fall back to the rolled range, never the exact value. + raw = unshared == null ? null : unshared.get(metric); + } if (!EspionageConfig.allows(tier(), metric)) return null; Long maximum = maximums == null ? null : maximums.get(metric); if (raw != null && maximum != null) { diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java index 5add0655..11ee4873 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java @@ -51,14 +51,19 @@ class VassalIntelligenceTest { } @Test void disablingSharingHidesSharedValuesInCachedReports() { - var report = EspionageService.createReport(Map.of("Wealth", 500.0, "Prosperity", 50.0), -50, IntelligenceTier.BROAD, new Random(1)); + var report = EspionageService.createReport(Map.of("Members", 500.0, "Prosperity", 50.0), -50, IntelligenceTier.BROAD, new Random(1)); assertEquals("50", report.display("Prosperity")); var config = new YamlConfiguration(); config.set("espionage.vassalage.allow-sharing", false); EspionageConfig.load(config); assertFalse(report.allows("prosperity")); assertNull(report.estimate("Prosperity")); - assertNull(report.estimate("Wealth")); + var wealth = report.estimate("Members"); + assertNotNull(wealth, "Fields the rolled tier allows fall back to their range"); + assertFalse(wealth.isExact()); + assertTrue(wealth.lower() <= 500 && wealth.upper() >= 500); + var restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(report), IntelligenceReport.class); + assertEquals(wealth, restored.estimate("Members")); } @Test void endingVassalageForgetsTodaysReportsInBothDirections() { From 82cadf5085dd722c0cb6b933d79730c0e920f52a Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:00:36 +0200 Subject: [PATCH 4/6] Clear shared reports on admin relation changes and keep rolled rosters Co-Authored-By: Claude Opus 5.5 (1M context) --- .../espionage/EspionageService.java | 21 ++++++++++++++++--- .../simplefactions/espionage/RosterLore.java | 2 +- .../managers/RelationManager.java | 4 ++++ .../managers/inventory/EspionageView.java | 2 +- .../espionage/VassalIntelligenceTest.java | 21 +++++++++++++++++++ 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index 2644a6fb..e44cfeb5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -479,23 +479,38 @@ private static IntelligenceReport generateReport(Faction observer, Faction targe } static java.util.List sample(java.util.List members, int margin, RandomGenerator random) { + return sampleFraction(members, rosterFraction(margin), random); + } + + private static double rosterFraction(int margin) { var tier = EspionageConfig.tier(margin); - return sampleFraction(members, EspionageConfig.allows(tier, "roster") ? EspionageConfig.settings(tier).rosterFraction() : 0, random); + return EspionageConfig.allows(tier, "roster") ? EspionageConfig.settings(tier).rosterFraction() : 0; } static java.util.List sampleFraction(java.util.List members, double fraction, RandomGenerator random) { + return prefix(shuffle(members, random), fraction); + } + + private static java.util.List shuffle(java.util.List members, RandomGenerator random) { java.util.List shuffled = new java.util.ArrayList<>(members); for (int i = shuffled.size() - 1; i > 0; i--) { int other = random.nextInt(i + 1); java.util.Collections.swap(shuffled, i, other); } + return shuffled; + } + + private static java.util.List prefix(java.util.List shuffled, double fraction) { int count = (int) Math.floor(shuffled.size() * fraction); return shuffled.subList(0, Math.min(EspionageConfig.rosterLimit(), count)).stream().sorted(String.CASE_INSENSITIVE_ORDER).toList(); } static void captureMembers(IntelligenceReport report, Faction target, int margin, RandomGenerator random) { var members = target.getMembers().stream().distinct().filter(name -> !target.isLeader(name)).toList(); - var sample = report.exact("roster") ? sampleFraction(members, 1, random) : sample(members, margin, random); + var shuffled = shuffle(members, random); + // The rolled sample is a prefix of the shared roster, so it still holds if sharing is switched off. + var rolled = prefix(shuffled, rosterFraction(margin)); + var sample = report.exact("roster") ? prefix(shuffled, 1) : rolled; report.members = sample.stream().map(name -> CharacterNames.forForeign(name) + " §7— " + net.tfminecraft.simplefactions.utils.Represents.represents(target, name)).toList(); for (var guild : target.getGuildHandler().getGuilds()) { @@ -513,7 +528,7 @@ static void captureMembers(IntelligenceReport report, Faction target, int margin boolean knownLeader = guild.isLeader(name) && report.allows("guild-leader"); boolean knownOffice = !offices.isEmpty() && report.allows("office-holder"); if (!knownLeader && !knownOffice && (!report.allows("guild-members") || !sample.contains(name))) continue; - report.roster.add(new IntelligenceReport.RosterMember(CharacterNames.forForeign(name), guild.getId(), guild.getName(), sample.contains(name), + report.roster.add(new IntelligenceReport.RosterMember(CharacterNames.forForeign(name), guild.getId(), guild.getName(), rolled.contains(name), knownLeader, knownOffice ? offices : java.util.List.of())); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java b/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java index 505c5b9a..19592d6c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/RosterLore.java @@ -41,7 +41,7 @@ private static List build(Player viewer, Faction faction, Guild only) { }).toList())).toList(); else entries = report == null || report.roster == null ? List.of() : report.roster.stream() .filter(entry -> entry.guildId().equals(guild.getId())) - .filter(entry -> entry.sampled() && report.allows("guild-members") && report.allows("roster") + .filter(entry -> (entry.sampled() || report.exact("roster")) && report.allows("guild-members") && report.allows("roster") || entry.guildLeader() && report.allows("guild-leader") || !entry.offices().isEmpty() && report.allows("office-holder")).toList(); lore.add(""); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java index d8f3ac1d..1c79c7ec 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java @@ -275,6 +275,7 @@ public static boolean setRelation( FactionManager.getMap().enqueue("nation", target.getRGB()); } } + boolean wasVassalage = isOverlord(origin, target) || isOverlord(target, origin); relation.setType(r); origin.setRelation(target, relation); if(reverseChange) { @@ -288,6 +289,9 @@ public static boolean setRelation( reverse.setType(r.getLink()); target.setRelation(origin, reverse); } + // Former partners must not keep what they shared today + if(wasVassalage && !isOverlord(origin, target) && !isOverlord(target, origin)) + net.tfminecraft.simplefactions.espionage.EspionageService.forgetReports(origin, target); if(p != null) p.sendMessage(StringFormatter.formatHex("#a89977Set relation to "+r.getName())); //An alliance or vassalage can make a signed mercenary contract treachery MercenaryLoyaltyWatcher.onRelationChanged(origin, target); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java index c394a788..3d65c9d3 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java @@ -77,7 +77,7 @@ private static ItemStack reportHeader(Player viewer, IntelligenceReport report) List lore = new ArrayList<>(List.of("\u00a77Report quality: \u00a7e" + (missingSpymaster ? "Absent" : report == null ? IntelligenceTier.UNKNOWN.label() : report.qualityLabel()), report == null ? "\u00a77No dated account has yet reached your court." : "\u00a77Dated " + report.loreDate(), last)); - if (!missingSpymaster && report != null && report.sharedTier() != IntelligenceTier.UNKNOWN) + if (!missingSpymaster && report != null && EspionageConfig.sharingAllowed() && report.sharedTier() != IntelligenceTier.UNKNOWN) lore.add("\u00a7aTheir Spymaster shares everything up to " + report.sharedTier().label() + " exactly."); return item(Material.SPYGLASS, "Foreign intelligence", lore.toArray(String[]::new)); } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java index 11ee4873..cba12464 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java @@ -110,6 +110,27 @@ class VassalIntelligenceTest { assertEquals("70", report.display(IntelligenceReport.officeAptitudeKey(SpecialPosition.SPYMASTER))); } + @Test void sharedRostersKeepTheRolledSampleForWhenSharingStops() { + var target = mock(Faction.class, RETURNS_DEEP_STUBS); + var guild = mock(net.tfminecraft.simplefactions.guild.Guild.class); + var members = java.util.stream.IntStream.range(0, 10).mapToObj(index -> "Account" + index).toList(); + when(target.getMembers()).thenReturn(members); + when(target.getGuildHandler().getGuilds()).thenReturn(List.of(guild)); + when(target.getEspionage()).thenReturn(new EspionageState()); + when(guild.getId()).thenReturn("guild"); + when(guild.getMembers()).thenReturn(members); + when(guild.isMember(anyString())).thenReturn(true); + var report = EspionageService.createReport(Map.of(), -50, IntelligenceTier.RUMOURS, new Random(3)); + try (var names = mockStatic(CharacterNames.class); var represents = mockStatic(Represents.class)) { + names.when(() -> CharacterNames.forForeign(anyString())).thenAnswer(call -> call.getArgument(0)); + represents.when(() -> Represents.represents(eq(target), anyString())).thenReturn("Guild"); + EspionageService.captureMembers(report, target, -50, new Random(3)); + } + assertEquals(10, report.roster.size(), "The shared roster lists everyone"); + assertEquals(2, report.roster.stream().filter(IntelligenceReport.RosterMember::sampled).count(), + "Only the rolled Rumours sample (20%) stays once sharing stops"); + } + @Test void onlyDirectPartnersReceiveTheirChosenTier() { Faction vassal = mock(Faction.class), overlord = mock(Faction.class), stranger = mock(Faction.class); var vassalState = new EspionageState(); From 67bce8b094807a0ba26424510d500c39d0ca9995 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:17:09 +0200 Subject: [PATCH 5/6] Save partner report invalidations before committing a sharing change Co-Authored-By: Claude Opus 5.5 (1M context) --- .../espionage/EspionageService.java | 12 +++++-- .../espionage/VassalIntelligenceTest.java | 32 +++++++++++++++++-- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index e44cfeb5..08e48640 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -373,15 +373,21 @@ public static boolean setSharing(Player actor, Faction faction, SharingPartner p } var state = faction.getEspionage(); IntelligenceTier previous = state.sharing(partner); + // Partners rebuild today's report on their next menu, under the same daily rolls. Their saves come + // first, so a restart can never bring back a report built under the old choice. + for (Faction other : partners(faction, partner)) { + if (other.getEspionage() != null && other.getEspionage().forgetReport(faction.getId()) + && !new Database().saveFactionChecked(other)) { + actor.sendMessage("§cYour choice could not be saved. Your previous sharing remains in effect."); + return false; + } + } state.share(partner, tier); if (!new Database().saveFactionChecked(faction)) { state.share(partner, previous); actor.sendMessage("§cYour choice could not be saved. Your previous sharing remains in effect."); return false; } - // Partners rebuild today's report on their next menu, under the same daily rolls. - for (Faction other : partners(faction, partner)) - if (other.getEspionage() != null && other.getEspionage().forgetReport(faction.getId())) new Database().saveFaction(other); actor.sendMessage(tier == IntelligenceTier.UNKNOWN ? "§7Your court no longer shares information with " + partner.label() + "." : "§7Your court now shares everything up to §e" + tier.label() + "§7 with " + partner.label() + ". Those details reach them exactly."); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java index cba12464..b909e47a 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/VassalIntelligenceTest.java @@ -193,13 +193,14 @@ class VassalIntelligenceTest { when(overlord.getEspionage()).thenReturn(overlordState); when(vassal.getEspionage()).thenReturn(new EspionageState()); try (var databases = mockConstruction(Database.class, (database, context) -> - when(database.saveFactionChecked(faction)).thenReturn(true))) { + when(database.saveFactionChecked(any())).thenReturn(true))) { assertFalse(EspionageService.setSharing(member, faction, SharingPartner.OVERLORD, IntelligenceTier.BROAD)); assertEquals(IntelligenceTier.UNKNOWN, state.sharing(SharingPartner.OVERLORD)); assertTrue(EspionageService.setSharing(spy, faction, SharingPartner.OVERLORD, IntelligenceTier.BROAD)); assertEquals(IntelligenceTier.BROAD, state.sharing(SharingPartner.OVERLORD)); assertNull(overlordState.cachedReport("faction", 0, 1)); - verify(databases.constructed().get(1)).saveFaction(overlord); + verify(databases.constructed().get(0)).saveFactionChecked(overlord); + verify(databases.constructed().get(1)).saveFactionChecked(faction); assertTrue(EspionageService.setSharing(spy, faction, SharingPartner.VASSALS, IntelligenceTier.DETAILED)); assertEquals(3, databases.constructed().size(), "A partner without a cached report needs no save"); assertTrue(EspionageService.setSharing(spy, faction, SharingPartner.OVERLORD, IntelligenceTier.UNKNOWN)); @@ -228,7 +229,32 @@ class VassalIntelligenceTest { try (var databases = mockConstruction(Database.class)) { assertFalse(EspionageService.setSharing(spy, faction, SharingPartner.VASSALS, IntelligenceTier.DETAILED)); assertEquals(IntelligenceTier.RUMOURS, state.sharing(SharingPartner.VASSALS)); - verify(faction, never()).getVassals(); + } + } + + @Test void failedPartnerSaveCancelsTheChange() { + Faction faction = mock(Faction.class), vassal = mock(Faction.class); + Player spy = mock(Player.class); + var state = new EspionageState(); + var holder = new SpecialPositionAssignment(); + holder.playerName = "Spy"; + holder.playerId = UUID.randomUUID(); + state.appoint(holder, 60); + when(faction.getId()).thenReturn("faction"); + when(faction.getEspionage()).thenReturn(state); + when(faction.isMemberIgnoreCase(anyString())).thenReturn(true); + when(faction.getVassals()).thenReturn(List.of(vassal)); + var vassalState = new EspionageState(); + vassalState.report("faction", 0, 1, IntelligenceReport::new); + when(vassal.getEspionage()).thenReturn(vassalState); + when(spy.getName()).thenReturn("Spy"); + when(spy.getUniqueId()).thenReturn(holder.playerId); + try (var databases = mockConstruction(Database.class, (database, context) -> + when(database.saveFactionChecked(faction)).thenReturn(true))) { + assertFalse(EspionageService.setSharing(spy, faction, SharingPartner.VASSALS, IntelligenceTier.DETAILED)); + assertEquals(IntelligenceTier.UNKNOWN, state.sharing(SharingPartner.VASSALS)); + assertEquals(1, databases.constructed().size(), "The faction is not saved once a partner save fails"); + verify(spy).sendMessage(contains("could not be saved")); } } From 193f7dfa9a34ad7706479ee809de025619cfd18c Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:47:02 +0200 Subject: [PATCH 6/6] Cover the sharing command, menu buttons and report header Co-Authored-By: Claude Opus 5.5 (1M context) --- .../espionage/EspionageMenusCoverageTest.java | 31 +++++++++++++++++++ .../EspionageOperationsCoverageTest.java | 30 ++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java index c08e91a8..4b350986 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java @@ -309,6 +309,37 @@ void privateConductCyclesEachSettingAndPreservesReportsAndTheOtherSetting() { assertEquals(Material.LIME_DYE, top(spy).getItem(11).getType()); } + @Test + void sharingCyclesEachPartnerAndReportsNameTheSharedTierWhileSharingIsAllowed() { + EspionageView.settings(spy, home, manager); + assertTrue(text(top(spy).getItem(21)).contains("Sharing nothing")); + for (IntelligenceTier expected : + new IntelligenceTier[] { + IntelligenceTier.RUMOURS, + IntelligenceTier.BROAD, + IntelligenceTier.RELIABLE, + IntelligenceTier.DETAILED, + IntelligenceTier.UNKNOWN + }) { + click(spy, 21); + assertEquals(expected, home.getEspionage().sharing(SharingPartner.OVERLORD)); + } + click(spy, 23); + assertEquals(IntelligenceTier.RUMOURS, home.getEspionage().sharing(SharingPartner.VASSALS)); + assertTrue(text(top(spy).getItem(23)).contains("Sharing up to Rumours")); + report.shared = "broad"; + EspionageView.foreignPositions(leader, target, manager); + assertTrue( + text(top(leader).getItem(4)).contains("shares everything up to Broad estimates exactly")); + YamlConfiguration config = new YamlConfiguration(); + config.set("espionage.vassalage.allow-sharing", false); + EspionageConfig.load(config); + EspionageView.foreignPositions(leader, target, manager); + assertFalse(text(top(leader).getItem(4)).contains("shares everything")); + EspionageView.settings(spy, home, manager); + assertNull(top(spy).getItem(21)); + } + @Test void staleOfficeOwnershipAndFactionMembershipClosePrivateMenus() { EspionageView.settings(spy, home, manager); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageOperationsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageOperationsCoverageTest.java index 0ce68c55..df0f4b8b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageOperationsCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageOperationsCoverageTest.java @@ -482,6 +482,36 @@ void completionListsOnlyAuthorizedOnlineAppointmentsAndPrivateConductChoices() t EspionageCommands.complete(leader, new String[] {"spymaster", "sabotage", "defense", "5"})); } + @Test + void spymastersShareByCommandAndInvalidSharingArgumentsChangeNothing() throws Exception { + offices(); + assertTrue( + EspionageCommands.handle(leader, new String[] {"spymaster", "share", "vassals", "broad"})); + assertEquals(IntelligenceTier.BROAD, home.getEspionage().sharing(SharingPartner.VASSALS)); + assertTrue( + EspionageCommands.handle( + leader, new String[] {"spymaster", "share", "overlord", "reliable"})); + assertEquals(IntelligenceTier.RELIABLE, home.getEspionage().sharing(SharingPartner.OVERLORD)); + assertTrue( + EspionageCommands.handle(leader, new String[] {"spymaster", "share", "vassals", "none"})); + assertEquals(IntelligenceTier.UNKNOWN, home.getEspionage().sharing(SharingPartner.VASSALS)); + clearInvocations(leader); + assertTrue( + EspionageCommands.handle(leader, new String[] {"spymaster", "share", "allies", "broad"})); + assertTrue( + EspionageCommands.handle( + leader, new String[] {"spymaster", "share", "overlord", "everything"})); + assertEquals(IntelligenceTier.RELIABLE, home.getEspionage().sharing(SharingPartner.OVERLORD)); + verify(leader, times(2)).sendMessage(contains("")); + assertTrue(EspionageCommands.complete(leader, new String[] {"spymaster", ""}).contains("share")); + assertEquals( + List.of("overlord", "vassals"), + EspionageCommands.complete(leader, new String[] {"spymaster", "share", ""})); + assertEquals( + List.of("rumours", "reliable"), + EspionageCommands.complete(leader, new String[] {"spymaster", "share", "vassals", "r"})); + } + @ParameterizedTest @ValueSource( strings = {"null", "offline", "foreign", "no-character", "already-holder", "cooldown"})