From e5e43f0fd77a67882e84ad3dc8736f83ccaa7d58 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:01:55 +0200 Subject: [PATCH] Expose unguarded factions and require a Spymaster for daily reports --- ESPIONAGE.md | 28 ++- .../espionage/EspionageService.java | 26 ++- .../managers/inventory/EspionageView.java | 11 +- .../CharacterNameResolutionTest.java | 1 + .../espionage/EspionageBypassTest.java | 11 ++ .../espionage/EspionagePermissionsTest.java | 6 +- .../espionage/EspionageTestFixtures.java | 17 ++ .../espionage/IntelligenceBrowsingTest.java | 4 + .../espionage/ReportPresentationTest.java | 1 + .../espionage/UnguardedFactionTest.java | 181 ++++++++++++++++++ 10 files changed, 269 insertions(+), 17 deletions(-) create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/EspionageTestFixtures.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/espionage/UnguardedFactionTest.java diff --git a/ESPIONAGE.md b/ESPIONAGE.md index 43859128..aa29baf7 100644 --- a/ESPIONAGE.md +++ b/ESPIONAGE.md @@ -7,6 +7,21 @@ uses exact values. Other figures are hidden or shown as intelligence ranges. Members retain exact information about their own faction and guilds. This covers in-game menus; website exports and staff administration commands are unchanged. +A faction without an eligible, living Spymaster reveals exact information to +everyone, including its guild menus, complete rosters, ledgers and wealth rankings. +No daily estimates are generated for an unguarded faction. This takes effect when +menus are reopened, including after removal, permanent character death or a solo +leader becoming ineligible. Appointing an eligible Spymaster restores the usual +intelligence checks, even if their aptitude is 0. Public viewing does not grant +management authority or access to private sabotage settings. Staff alone retain +the additional Minecraft account names provided by their bypass permission. + +Without an eligible Spymaster, a faction also receives no daily reports about +protected foreign factions. Previously cached reports become unreadable immediately; +private fields remain Unknown and the menu says "Report quality: Absent" with +"The Spymaster's office stands vacant; no findings reach your court." +Unguarded foreign factions and normally public information remain visible. + ## Staff viewing permission Set the permission node in `plugins/SimpleFactions/special-positions.yml`: @@ -31,8 +46,8 @@ For the staff track (`staff_player` → `staff_inactive` → `staff`), grant the explicitly to `staff` and deny it for `staff_inactive`; explicit denials inherited from lower ranks override a generic `*` grant. On dev the active grant uses `server=dev` so it does not change permissions on other servers. -Test promotion/demotion on a foreign faction: own faction information stays -exact at every rank. Reopen the menu after a rank change. +Test promotion/demotion on a foreign faction with an eligible Spymaster: own and +unguarded faction information stays exact at every rank. Reopen the menu after a rank change. ## Spymaster appointment @@ -120,13 +135,14 @@ existing permanent aptitudes are retained. ## Daily intelligence and rankings Any `/faction` or `/guild` command that opens a GUI requests the faction's -reports for every foreign faction, including list, menu, positions, espionage +reports for protected foreign factions when your own Spymaster is eligible, including list, menu, positions, espionage and Spymaster settings commands. The first member to open a GUI by command that UTC day triggers the checks; all faction members share the reports. Faction/guild clicks, sorting and periodic GUI refreshes only read cached reports. They never roll or send intelligence messages. Commands that do not open a menu never gather intelligence. -Uncached information stays hidden until a faction member opens a GUI by command. +Protected uncached information stays hidden until a faction member with an eligible +Spymaster opens a GUI by command. Unguarded faction information is always exact. One roleplay notice goes to the requesting player when new reports are created. Days run from midnight to midnight **UTC**. Each faction rolls offense and defense @@ -140,7 +156,9 @@ margin = observer.offense - target.defense Averaged luck makes a full 0-versus-100 upset exceptionally rare (roughly 0.002% with defaults), while smaller aptitude gaps can still be overcome. Luck spread, draw count and aptitude multiplier are configurable. Each ordered observer/target pair has one snapshot per day. Reopening, replacing Spymasters, -sabotage changes and restarts do not reroll the day's intelligence. Changes affect +sabotage changes and restarts do not reroll the day's intelligence. A vacant office +immediately disables its faction's reports and exposes its own information; +refilling it restores access to any still-current cached reports. Other changes affect the next uncached rolls. A recreated target faction receives a fresh report. | Margin | Intelligence | Approximate range width | Known non-leader members | diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index fad63862..94204bb7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -138,7 +138,15 @@ public static boolean bypasses(Player viewer) { /** Viewing permission does not grant membership or authority over faction offices. */ public static boolean canViewExact(Player viewer, Faction target) { - return target != null && (bypasses(viewer) || isOwn(viewer, target)); + return viewer != null && target != null + && (bypasses(viewer) || isOwn(viewer, target) || !hasSpymaster(target)); + } + + /** A vacant, ineligible or deceased holder leaves every guild's information unguarded. */ + public static boolean hasSpymaster(Faction faction) { + if (faction == null || faction.getEspionage() == null) return false; + var holder = faction.getEspionage().getSpymaster(); + return holder != null && eligible(faction, holder.playerName) && !deadCharacter(holder); } public static SpecialPositionAssignment spymaster(Faction faction) { @@ -310,7 +318,7 @@ public static boolean setSabotage(Player actor, Faction faction, boolean offense public static IntelligenceReport report(Player viewer, Faction target) { if (viewer == null || canViewExact(viewer, target)) return null; Faction observer = FactionManager.getByMember(viewer.getName()); - if (observer == null) return null; + if (!hasSpymaster(observer)) return null; return observer.getEspionage().cachedReport(target.getId(), target.getFoundedAt(), day()); } @@ -323,11 +331,14 @@ public static void refreshReports(Player viewer) { if (observer == null) return; boolean updated = false; java.util.Set dirty = new java.util.LinkedHashSet<>(); + if (spymaster(observer, dirty) == null) { + for (Faction faction : dirty) new Database().saveFaction(faction); + return; + } for (Faction target : FactionManager.getCopy()) { if (target == observer || target.getId().equals(observer.getId())) continue; if (observer.getEspionage().cachedReport(target.getId(), target.getFoundedAt(), day()) == null) { - generateReport(observer, target, dirty); - updated = true; + if (generateReport(observer, target, dirty) != null) updated = true; } } for (Faction faction : dirty) new Database().saveFaction(faction); @@ -336,8 +347,10 @@ public static void refreshReports(Player viewer) { private static IntelligenceReport generateReport(Faction observer, Faction target, java.util.Set dirty) { long day = LocalDate.now(ZoneOffset.UTC).toEpochDay(); - var attacker = spymaster(observer, dirty); var defender = spymaster(target, dirty); + if (defender == null) return null; // Exact public information needs no daily estimate. + var attacker = spymaster(observer, dirty); + if (attacker == null) return null; // A vacant office cannot deliver foreign findings. boolean[] created = {false}; RandomGenerator random = ThreadLocalRandom.current(); IntelligenceReport report = observer.getEspionage().report(target.getId(), target.getFoundedAt(), day, () -> { @@ -438,8 +451,7 @@ public static int regenerateReports() { java.util.Set dirty = new java.util.LinkedHashSet<>(factions); for (var observer : factions) { for (var target : factions) if (!observer.getId().equals(target.getId())) { - generateReport(observer, target, dirty); - count++; + if (generateReport(observer, target, dirty) != null) count++; } } // Persist every reset/report once, including a lone faction with no foreign targets. 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 34678dfd..9bdb75a8 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java @@ -69,10 +69,12 @@ public static void foreign(Inventory inventory, Player viewer, Faction target, I private static ItemStack reportHeader(Player viewer, IntelligenceReport report) { var observer = FactionManager.getByMember(viewer.getName()); var spy = observer == null ? null : observer.getEspionage().getSpymaster(); - String last = spy == null || !EspionageService.eligible(observer, spy.playerName) ? "\u00a77Without a Spymaster, scattered whispers reach your court." + 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" + (report == null ? net.tfminecraft.simplefactions.espionage.IntelligenceTier.UNKNOWN.label() : report.qualityLabel()), + "\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); } @@ -267,6 +269,8 @@ public static void spymasterOffice(Player viewer, Faction faction, InventoryMana holder != null && faction.isLeader(holder.playerName) ? "§7Solo leader: " + Math.round(EspionageConfig.soloMultiplier() * 100) + "% aptitude retained (base " + holder.aptitude + ")." : "§7Faction leaders are ineligible while other members belong.", "§7Gathers foreign intelligence and guards your secrets.", + holder == null ? "\u00a7cWithout a Spymaster, all faction and guild information is public." + : "\u00a77An eligible Spymaster protects your faction and guild information.", holder != null && holder.isHolder(viewer.getUniqueId()) ? "§aClick your own head to inspect your private conduct." : "§8Private conduct is known only to the office holder."); if (holder != null) { @@ -283,7 +287,8 @@ public static void spymasterOffice(Player viewer, Faction faction, InventoryMana faction.getEspionage().appointmentCount(SpecialPosition.SPYMASTER) == 0 ? "\u00a77The founder's assignment does not use your free appointment." : "\u00a77Unrest: -" + EspionageConfig.stabilityPenalty() + " points, fading over " + EspionageConfig.penaltyDays() + " days.")); - inventory.setItem(15, item(Material.REDSTONE, "Remove Spymaster", "§7Leaves this office with 0 aptitude.")); + inventory.setItem(15, item(Material.REDSTONE, "Remove Spymaster", + "\u00a7cMakes all faction and guild information public.")); } inventory.setItem(26, manager.createBackButton(SFGUI.SPYMASTER_VIEW)); viewer.openInventory(inventory); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java index bfd089cc..d0d851d2 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterNameResolutionTest.java @@ -13,6 +13,7 @@ class CharacterNameResolutionTest { var account = "NoActiveCharacterAccount"; var viewer = player("ForeignViewer"); var faction = mock(net.tfminecraft.simplefactions.objects.Faction.class); + EspionageTestFixtures.protect(faction); var plugins = mock(org.bukkit.plugin.PluginManager.class); var roleplay = mock(org.bukkit.plugin.Plugin.class); var offline = mock(org.bukkit.OfflinePlayer.class); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageBypassTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageBypassTest.java index 3c346765..f1697a43 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageBypassTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageBypassTest.java @@ -16,12 +16,22 @@ import net.tfminecraft.simplefactions.utils.FactionRanker; class EspionageBypassTest { + private void protect(Faction faction) { + var state = new EspionageState(); + var holder = new SpecialPositionAssignment(); + holder.playerName = "Spy"; + state.appoint(holder, 50); + when(faction.getEspionage()).thenReturn(state); + when(faction.isMemberIgnoreCase("Spy")).thenReturn(true); + } + @Test void configuredPermissionChangesOnReloadAndOwnViewsRemainExact() { Player viewer = mock(Player.class); when(viewer.getName()).thenReturn("Viewer"); when(viewer.hasPermission(EspionageService.BYPASS_PERMISSION)).thenReturn(true); Faction own = mock(Faction.class), foreign = mock(Faction.class); + protect(foreign); when(own.isMemberIgnoreCase("Viewer")).thenReturn(true); var config = new org.bukkit.configuration.file.YamlConfiguration(); try { @@ -75,6 +85,7 @@ void revocationClosesPrivateInformationMenusAndBypassDoesNotGrantOfficeAuthority when(staff.getName()).thenReturn("Staff"); when(staff.hasPermission(EspionageService.BYPASS_PERMISSION)).thenReturn(true); Faction faction = mock(Faction.class); + protect(faction); try (var factions = mockStatic(FactionManager.class)) { factions.when(() -> FactionManager.getByString("target")).thenReturn(faction); assertTrue(EspionageService.canViewExact(staff, faction)); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java index c7da95f8..30220dd0 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java @@ -26,6 +26,7 @@ class EspionagePermissionsTest { void outsidersOpenMaskedSubmenusWithoutBuildingExactInformation() { Player outsider = mock(Player.class); Faction faction = mock(Faction.class); + EspionageTestFixtures.protect(faction); Guild guild = mock(Guild.class); when(guild.getFaction()).thenReturn(faction); InventoryManager manager = mock(InventoryManager.class); @@ -104,10 +105,11 @@ void onlyFactionLeaderCanAppointOrRemove() { void unaffiliatedAndOwnMembersDoNotConsumeForeignRolls() { Player viewer = mock(Player.class); Faction target = mock(Faction.class); + EspionageTestFixtures.protect(target); when(viewer.getName()).thenReturn("Visitor"); try (var factions = mockStatic(FactionManager.class)) { assertNull(EspionageService.report(viewer, target)); - verify(target, never()).getEspionage(); + assertNull(target.getEspionage().cachedReport("unused", 0, 0)); when(target.isMemberIgnoreCase("Visitor")).thenReturn(true); assertNull(EspionageService.report(viewer, target)); factions.verify(() -> FactionManager.getByMember("Visitor"), times(1)); @@ -120,7 +122,7 @@ void expiredMembershipAndReplacedOfficeCloseExistingPrivateScreens() { Faction faction = mock(Faction.class); when(member.getName()).thenReturn("FormerSpy"); when(member.getUniqueId()).thenReturn(UUID.randomUUID()); - when(faction.getEspionage()).thenReturn(new EspionageState()); + EspionageTestFixtures.protect(faction); try (var factions = mockStatic(FactionManager.class)) { factions.when(() -> FactionManager.getByString("faction")).thenReturn(faction); assertTrue(EspionageAccess.denied(member, new SFInventoryHolder("faction", SFGUI.MILITARY_VIEW))); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageTestFixtures.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageTestFixtures.java new file mode 100644 index 00000000..9a576e59 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageTestFixtures.java @@ -0,0 +1,17 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.mockito.Mockito.when; +import net.tfminecraft.simplefactions.objects.Faction; + +final class EspionageTestFixtures { + private EspionageTestFixtures() {} + + static void protect(Faction faction) { + var state = new EspionageState(); + var holder = new SpecialPositionAssignment(); + holder.playerName = "Spy"; + state.appoint(holder, 50); + when(faction.getEspionage()).thenReturn(state); + when(faction.isMemberIgnoreCase("Spy")).thenReturn(true); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java index f85c4192..2c37456d 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/IntelligenceBrowsingTest.java @@ -22,6 +22,10 @@ private Faction faction(String id) { when(faction.getId()).thenReturn(id); when(faction.getFoundedAt()).thenReturn(1L); when(faction.getEspionage()).thenReturn(new EspionageState()); + var holder = new SpecialPositionAssignment(); + holder.playerName = "Spy"; + faction.getEspionage().appoint(holder, 50); + when(faction.isMemberIgnoreCase("Spy")).thenReturn(true); when(faction.getMembers()).thenReturn(List.of()); when(faction.getGuildHandler().getGuilds()).thenReturn(List.of()); when(faction.getMilitary().getRegiments()).thenReturn(List.of()); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java index 07b13913..8d7439c0 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/ReportPresentationTest.java @@ -37,6 +37,7 @@ class ReportPresentationTest { @Test void onlyMaskedHoldersAllowForeignAccessToPreviouslyPrivateMenus() { var outsider = mock(Player.class); var faction = mock(Faction.class); + EspionageTestFixtures.protect(faction); try (var factions = mockStatic(net.tfminecraft.simplefactions.managers.FactionManager.class)) { factions.when(() -> net.tfminecraft.simplefactions.managers.FactionManager.getByString("f")).thenReturn(faction); var holder = new net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder("f", net.tfminecraft.simplefactions.enums.SFGUI.MILITARY_VIEW); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/UnguardedFactionTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/UnguardedFactionTest.java new file mode 100644 index 00000000..c0fc5754 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/UnguardedFactionTest.java @@ -0,0 +1,181 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.enums.RankType; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.utils.FactionRanker; + +class UnguardedFactionTest { + private final Player viewer = mock(Player.class); + + private Faction faction(String id) { + var faction = mock(Faction.class); + when(faction.getId()).thenReturn(id); + when(faction.getFoundedAt()).thenReturn(1L); + when(faction.getEspionage()).thenReturn(new EspionageState()); + when(viewer.getName()).thenReturn("ForeignViewer"); + return faction; + } + + private SpecialPositionAssignment appoint(Faction faction, String name, int aptitude) { + var holder = new SpecialPositionAssignment(); + holder.playerName = name; + holder.playerId = UUID.randomUUID(); + holder.characterId = "character"; + when(faction.isMemberIgnoreCase(name)).thenReturn(true); + faction.getEspionage().appoint(holder, aptitude); + return holder; + } + + @Test void vacancyExposesLiveFactionAndGuildWealthInsteadOfOldDailyEstimates() { + var target = faction("target"); + var observer = faction("observer"); + when(target.getWealth()).thenReturn(1234.0); + var guild = mock(Guild.class); + when(guild.getFaction()).thenReturn(target); + when(guild.getWealth()).thenReturn(321.0); + long day = LocalDate.now(ZoneOffset.UTC).toEpochDay(); + observer.getEspionage().report("target", 1, day, () -> { + var stale = new IntelligenceReport(); + stale.quality = "Rumours"; + return stale; + }); + try (var factions = mockStatic(FactionManager.class)) { + factions.when(() -> FactionManager.getByMember("ForeignViewer")).thenReturn(observer); + assertTrue(EspionageService.canViewExact(viewer, target)); + assertFalse(EspionageService.bypasses(viewer)); + assertFalse(EspionageService.isOwn(viewer, target)); + assertNull(EspionageService.report(viewer, target)); + var ranker = new FactionRanker(); + assertEquals(1234.0, ranker.visibleValue(viewer, target, RankType.WEALTH)); + assertEquals(321.0, ranker.visibleGuildValue(viewer, guild, RankType.WEALTH)); + } + } + + @Test void zeroAptitudeAndSabotageStillProtectWhileRemovalAndAppointmentSwitchVisibility() { + var target = faction("target"); + assertTrue(EspionageService.canViewExact(viewer, target)); + var holder = appoint(target, "Spy", 0); + holder.defenseReduction = 100; + assertTrue(EspionageService.hasSpymaster(target)); + assertFalse(EspionageService.canViewExact(viewer, target)); + target.getEspionage().removeSpymaster(); + assertTrue(EspionageService.canViewExact(viewer, target)); + appoint(target, "Spy", 100); + assertFalse(EspionageService.canViewExact(viewer, target)); + assertFalse(EspionageService.canViewExact(null, target)); + assertFalse(EspionageService.canViewExact(viewer, null)); + } + + @Test void departedOrDeadHoldersLeaveInformationPublicWithoutReadTimeSaves() { + var target = faction("target"); + var holder = appoint(target, "Spy", 50); + try (var characters = mockStatic(OfficeCharacters.class); var databases = mockConstruction(Database.class)) { + when(target.isMemberIgnoreCase("Spy")).thenReturn(false); + assertTrue(EspionageService.canViewExact(viewer, target)); + when(target.isMemberIgnoreCase("Spy")).thenReturn(true); + characters.when(() -> OfficeCharacters.isDead(holder.playerId, holder.characterId)).thenReturn(true); + assertTrue(EspionageService.canViewExact(viewer, target)); + assertSame(holder, target.getEspionage().getSpymaster()); + assertTrue(databases.constructed().isEmpty()); + } + } + + @Test void soloFounderProtectsUntilAnotherMemberJoinsAndPendingFounderIsUnguarded() { + var target = faction("target"); + target.getEspionage().pendingFounder(SpecialPosition.SPYMASTER); + assertTrue(EspionageService.canViewExact(viewer, target)); + appoint(target, "Leader", 50); + when(target.isLeader("Leader")).thenReturn(true); + when(target.getMembers()).thenReturn(List.of("Leader")); + assertFalse(EspionageService.canViewExact(viewer, target)); + when(target.getMembers()).thenReturn(List.of("Leader", "Member")); + assertTrue(EspionageService.canViewExact(viewer, target)); + } + + @Test void publicMenusNeverGrantMembershipOfficeManagementOrSabotage() { + var target = faction("target"); + try (var factions = mockStatic(FactionManager.class)) { + factions.when(() -> FactionManager.getByString("target")).thenReturn(target); + for (var type : new SFGUI[]{SFGUI.GOVERNMENT_VIEW, SFGUI.LEDGER_VIEW, SFGUI.MILITARY_VIEW}) + assertFalse(EspionageAccess.denied(viewer, new SFInventoryHolder("target", type))); + for (var type : new SFGUI[]{SFGUI.SPECIAL_POSITIONS, SFGUI.SPYMASTER_SELECT, SFGUI.SPYMASTER_SETTINGS}) + assertTrue(EspionageAccess.denied(viewer, new SFInventoryHolder("target", type))); + assertFalse(EspionageService.appoint(viewer, target, mock(Player.class))); + assertFalse(EspionageService.remove(viewer, target)); + assertFalse(EspionageService.setSabotage(viewer, target, false, 100)); + } + } + + @Test void ordinaryAndStaffRefreshSkipUnguardedTargetsWithoutCreatingReportsOrRolls() { + var first = faction("first"); + var second = faction("second"); + long day = LocalDate.now(ZoneOffset.UTC).toEpochDay(); + try (var factions = mockStatic(FactionManager.class); var databases = mockConstruction(Database.class)) { + factions.when(() -> FactionManager.getByMember("ForeignViewer")).thenReturn(first); + factions.when(FactionManager::getCopy).thenAnswer(ignored -> new ArrayList<>(List.of(first, second))); + EspionageService.refreshReports(viewer); + assertNull(first.getEspionage().cachedReport("second", 1, day)); + assertTrue(databases.constructed().isEmpty()); + verify(viewer, never()).sendMessage(anyString()); + verify(second, never()).getWealth(); + assertEquals(0, EspionageService.regenerateReports()); + assertNull(first.getEspionage().cachedReport("second", 1, day)); + assertNull(second.getEspionage().cachedReport("first", 1, day)); + assertEquals(2, databases.constructed().size(), "Staff refresh still persists cleared stale reports"); + } + } + + @Test void missingObserverSpymasterBlocksOldReportsAndNewGatheringUntilAppointed() { + var observer = faction("observer"); + var target = faction("target"); + appoint(target, "TargetSpy", 50); + var holder = appoint(observer, "ObserverSpy", 0); + long day = LocalDate.now(ZoneOffset.UTC).toEpochDay(); + var cached = observer.getEspionage().report("target", 1, day, () -> { + var report = new IntelligenceReport(); + report.quality = "Detailed estimates"; + report.estimates.put("Wealth", new EspionageMath.Estimate(100, 120)); + return report; + }); + try (var factions = mockStatic(FactionManager.class); var databases = mockConstruction(Database.class)) { + factions.when(() -> FactionManager.getByMember("ForeignViewer")).thenReturn(observer); + factions.when(FactionManager::getCopy).thenAnswer(ignored -> new ArrayList<>(List.of(observer, target))); + assertSame(cached, EspionageService.report(viewer, target)); + when(observer.isMemberIgnoreCase("ObserverSpy")).thenReturn(false); + assertNull(EspionageService.report(viewer, target), "Ineligible observers cannot read cached findings"); + when(observer.isMemberIgnoreCase("ObserverSpy")).thenReturn(true); + try (var characters = mockStatic(OfficeCharacters.class)) { + characters.when(() -> OfficeCharacters.isDead(holder.playerId, holder.characterId)).thenReturn(true); + assertNull(EspionageService.report(viewer, target), "Dead observers cannot read cached findings"); + } + observer.getEspionage().removeSpymaster(); + assertNull(EspionageService.report(viewer, target)); + assertNull(EspionageService.visibleValue(viewer, target, "Wealth", () -> fail("No exact wealth for a protected target"))); + EspionageService.refreshReports(viewer); + assertTrue(databases.constructed().isEmpty()); + verify(viewer, never()).sendMessage(anyString()); + assertSame(cached, observer.getEspionage().cachedReport("target", 1, day), "Office changes cannot reroll the day"); + appoint(observer, "ObserverSpy", 0); + assertSame(cached, EspionageService.report(viewer, target)); + observer.getEspionage().removeSpymaster(); + assertEquals(0, EspionageService.regenerateReports()); + assertNull(observer.getEspionage().cachedReport("target", 1, day)); + assertNull(target.getEspionage().cachedReport("observer", 1, day)); + } + } +}