diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java index 126bc30f..678b4578 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java @@ -15,7 +15,7 @@ public final class EspionageConfig { private static double stabilityPenalty = 10; private static double penaltyDays = 7; private static String reloadPermission = "simplefactions.espionage.reload"; - private static double base = 50, soloMultiplier = .25, rollMultiplier = 1.25; + 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 final Map tiers = new java.util.EnumMap<>(IntelligenceTier.class); private static final Map minimums = new LinkedHashMap<>(); @@ -68,7 +68,7 @@ public static void load(ConfigurationSection config) { cap = (int) bounded(config, "espionage.aptitude.attribute-cap", 16, 1, 1000); center = (int) bounded(config, "espionage.aptitude.attribute-center", 6, 0, cap); aptitudeSpread = (int) bounded(config, "espionage.aptitude.random-spread", 20, 0, 100); - soloMultiplier = bounded(config, "espionage.aptitude.solo-leader-multiplier", .25, 0, 1); + extraPositionPenalty = bounded(config, "espionage.aptitude.extra-position-penalty", .25, 0, 1); rollMultiplier = bounded(config, "espionage.checks.aptitude-multiplier", 1.25, .01, 10); luckSpread = (int) bounded(config, "espionage.checks.luck-spread", 75, 0, 1000); luckDraws = (int) bounded(config, "espionage.checks.luck-draws", 3, 1, 20); @@ -138,7 +138,7 @@ public static boolean allows(IntelligenceTier tier, String metric) { public static int center() { return center; } public static int cap() { return cap; } public static int aptitudeSpread() { return aptitudeSpread; } - public static double soloMultiplier() { return soloMultiplier; } + public static double extraPositionPenalty() { return extraPositionPenalty; } public static double rollMultiplier() { return rollMultiplier; } public static int luckSpread() { return luckSpread; } public static int luckDraws() { return luckDraws; } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index 94204bb7..77e360d6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -41,23 +41,28 @@ public static void loadAptitudes(java.nio.file.Path file) throws java.io.IOExcep } public static boolean eligible(Faction faction, String playerName) { - if (!faction.isMemberIgnoreCase(playerName) && !faction.isLeader(playerName)) return false; - return !faction.isLeader(playerName) || faction.getMembers().stream() - .allMatch(name -> name.equalsIgnoreCase(playerName)); + return faction.isMemberIgnoreCase(playerName) || faction.isLeader(playerName); } - /** Only new factions pass through addFaction; loading saves never reassigns offices. */ + /** The leader's default holding follows the leadership; deliberate appointments stay. */ + private static boolean validHolder(Faction faction, SpecialPositionAssignment holder) { + return holder != null && eligible(faction, holder.playerName) && !deadCharacter(holder) + && !(holder.automatic && !faction.isLeader(holder.playerName)); + } + + /** New factions and every later vacancy fall to the leader; it waits for their active character. */ public static void initializeFounder(Faction faction) { initializeFounder(faction, null); } private static void initializeFounder(Faction faction, java.util.Set dirty) { - if (faction.getEspionage() == null || org.bukkit.Bukkit.getServer() == null) return; + if (faction.getEspionage() == null || faction.getLeader() == null || org.bukkit.Bukkit.getServer() == null) return; var founder = org.bukkit.Bukkit.getPlayerExact(faction.getLeader()); String characterId = OfficeCharacters.activeCharacterId(founder); + // A dead character would be revoked again on the next check. + if (characterId != null && founder != null && OfficeCharacters.isDead(founder.getUniqueId(), characterId)) characterId = null; for (SpecialPosition office : SpecialPosition.values()) { if (faction.getEspionage().holder(office) != null) continue; - if (office == SpecialPosition.SPYMASTER && !eligible(faction, faction.getLeader())) continue; if (characterId == null) { faction.getEspionage().pendingFounder(office); continue; @@ -124,7 +129,23 @@ static boolean completeAndSaveAppointment(Faction faction, SpecialPositionAssign public static int effectiveAptitude(Faction faction, SpecialPositionAssignment holder) { if (holder == null || !eligible(faction, holder.playerName)) return 0; - return faction.isLeader(holder.playerName) ? (int) Math.floor(holder.aptitude * EspionageConfig.soloMultiplier()) : holder.aptitude; + // The epsilon keeps 1 - 0.1 * 3 from flooring a whole result one point low. + return (int) Math.floor(holder.aptitude * positionMultiplier(positionsHeld(faction, holder)) + 1e-9); + } + + /** Offices held by the same person; an assignment not saved yet still counts as one. */ + public static int positionsHeld(Faction faction, SpecialPositionAssignment holder) { + if (holder == null || holder.playerName == null || faction.getEspionage() == null) return 1; + int held = 0; + for (SpecialPosition office : SpecialPosition.values()) { + var other = faction.getEspionage().holder(office); + if (other != null && holder.playerName.equalsIgnoreCase(other.playerName)) held++; + } + return Math.max(1, held); + } + + public static double positionMultiplier(int held) { + return Math.max(0, 1 - EspionageConfig.extraPositionPenalty() * Math.max(0, held - 1)); } public static boolean isOwn(Player viewer, Faction target) { @@ -145,8 +166,7 @@ public static boolean canViewExact(Player viewer, Faction 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); + return validHolder(faction, faction.getEspionage().getSpymaster()); } public static SpecialPositionAssignment spymaster(Faction faction) { @@ -159,17 +179,24 @@ private static void saveOrMark(Faction faction, java.util.Set dirty) { } private static SpecialPositionAssignment spymaster(Faction faction, java.util.Set dirty) { - if (faction.getEspionage().hasPendingFounder()) { - initializeFounder(faction, dirty); - if (!faction.getEspionage().hasPendingFounder()) saveOrMark(faction, dirty); + var state = faction.getEspionage(); + boolean changed = false; + SpecialPositionAssignment holder = state.getSpymaster(); + if (holder != null && !validHolder(faction, holder)) { + state.removeSpymaster(); + changed = true; } - SpecialPositionAssignment holder = faction.getEspionage().getSpymaster(); - if (holder != null && (!eligible(faction, holder.playerName) || deadCharacter(holder))) { - faction.getEspionage().removeSpymaster(); - saveOrMark(faction, dirty); - return null; + // Whatever emptied the office, it falls back to the current leader. + if (state.getSpymaster() == null && !state.isPendingFounder(SpecialPosition.SPYMASTER) && faction.getLeader() != null) { + state.pendingFounder(SpecialPosition.SPYMASTER); + changed = true; + } + if (state.hasPendingFounder()) { + initializeFounder(faction, dirty); + if (!state.hasPendingFounder()) changed = true; } - return holder; + if (changed) saveOrMark(faction, dirty); + return state.getSpymaster(); } private static boolean deadCharacter(SpecialPositionAssignment holder) { @@ -190,7 +217,7 @@ public static void characterDied(Player owner, String characterId, String charac state.removeSpymaster(); new Database().saveFaction(faction); if (owner != null) owner.sendMessage("\u00a78\u00a7oWith the passing of " + characterName - + ", the keys to the Spymaster's office return to the faction. The office awaits a successor."); + + ", the keys to the Spymaster's office return to the faction leader until a successor is appointed."); } } @@ -198,8 +225,7 @@ public static java.util.List(faction.getEspionage().unrestModifiers(now)); for (SpecialPosition office : SpecialPosition.values()) { var holder = faction.getEspionage().holder(office); - boolean occupied = holder != null && (office != SpecialPosition.SPYMASTER - || eligible(faction, holder.playerName) && !deadCharacter(holder)); + boolean occupied = office != SpecialPosition.SPYMASTER ? holder != null : validHolder(faction, holder); double penalty = EspionageConfig.vacancyPenalty(office); if (!occupied && penalty > 0) result.add(new net.tfminecraft.simplefactions.government.StabilityModifier( "Vacant " + office.label(), -penalty, 0)); @@ -216,18 +242,13 @@ public static boolean appoint(Player actor, Faction faction, Player candidate) { actor.sendMessage("§cChoose an online member of your own faction."); return false; } - if (!eligible(faction, candidate.getName())) { - actor.sendMessage("§cThe faction leader cannot be Spymaster unless the faction has only one member."); - return false; - } String characterId = OfficeCharacters.activeCharacterId(candidate); if (characterId == null) { actor.sendMessage("§cThat member needs an active roleplay character."); return false; } SpecialPositionAssignment current = spymaster(faction); - if (current != null && !current.automatic && current.isHolder(candidate.getUniqueId()) - && characterId.equals(current.characterId)) { + if (current != null && current.isHolder(candidate.getUniqueId()) && characterId.equals(current.characterId)) { actor.sendMessage("§7That member is already your Spymaster."); return false; } @@ -259,8 +280,9 @@ public static boolean appoint(Player actor, Faction faction, Player candidate) { + "§8§o's unseen network are now yours. You have been appointed Spymaster."); candidate.sendMessage("§7Your aptitude for this office is §e" + effectiveAptitude(faction, assignment) + "/100§7. Inspect Special Positions in the faction menu, or use §a/faction espionage§7."); - if (faction.isLeader(candidate.getName())) - candidate.sendMessage("§7Leading a one-person faction retains " + Math.round(EspionageConfig.soloMultiplier() * 100) + "% aptitude (base: " + assignment.aptitude + ")."); + int held = positionsHeld(faction, assignment); + if (held > 1) candidate.sendMessage("§7Holding " + held + " offices leaves you " + Math.round(positionMultiplier(held) * 100) + + "% of your aptitude in each (base: " + assignment.aptitude + ")."); if (!actor.getUniqueId().equals(candidate.getUniqueId())) actor.sendMessage("§aSpymaster appointed."); if (repeat) actor.sendMessage("\u00a77Appointment: " + cost + "d from the treasury. The change of office brings " + EspionageConfig.stabilityPenalty() + " points of unrest, fading over " + EspionageConfig.penaltyDays() + " days."); @@ -278,6 +300,11 @@ public static boolean remove(Player actor, Faction faction) { actor.sendMessage("§cOnly your faction leader can remove the Spymaster."); return false; } + var current = spymaster(faction); + if (current == null || current.automatic) { + actor.sendMessage("§7No Spymaster has been appointed. The office already rests with you as faction leader."); + return false; + } var previous = faction.getEspionage().snapshotOffices(); faction.getEspionage().removeSpymaster(); if (!new Database().saveFactionChecked(faction)) { @@ -285,7 +312,8 @@ public static boolean remove(Player actor, Faction faction) { actor.sendMessage("\u00a7cThe office removal could not be saved. The Spymaster remains appointed."); return false; } - actor.sendMessage("§aThe Spymaster office is now vacant."); + actor.sendMessage("§aThe Spymaster has been dismissed. The office returns to you as faction leader."); + spymaster(faction); return true; } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFile.java b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFile.java index 83e5152a..8544e3ce 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFile.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFile.java @@ -12,6 +12,8 @@ /** Migrates the old espionage section once; existing dedicated settings always win. */ public final class SpecialPositionsConfigFile { + // Settings whose rule no longer exists; their comments would mislead staff. + private static final java.util.List RETIRED_KEYS = java.util.List.of("espionage.aptitude.solo-leader-multiplier"); private SpecialPositionsConfigFile() {} public static void load(JavaPlugin plugin) { @@ -54,6 +56,12 @@ else if (legacy.exists()) { } boolean changed = !file.exists(); if (changed) settings.options().setHeader(defaults.options().getHeader()); + for (String retired : RETIRED_KEYS) { + if (settings.contains(retired)) { + settings.set(retired, null); + changed = true; + } + } for (var entry : defaults.getValues(true).entrySet()) { if (!(entry.getValue() instanceof org.bukkit.configuration.ConfigurationSection) && !settings.contains(entry.getKey())) { settings.set(entry.getKey(), entry.getValue()); 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 a52477ea..db18fdf6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java @@ -259,10 +259,14 @@ public static void spymasterOffice(Player viewer, Faction faction, InventoryMana Inventory inventory = Bukkit.createInventory(new SFInventoryHolder(faction.getId(), SFGUI.SPYMASTER_VIEW), 27, "\u00a77Spymaster's Office"); SpecialPositionAssignment holder = EspionageService.spymaster(faction); + int held = EspionageService.positionsHeld(faction, holder); ItemStack head = item(Material.PLAYER_HEAD, "Spymaster: " + (holder == null ? "Vacant" : CharacterNames.display(viewer, holder.playerName)), "§7Aptitude: §e" + EspionageService.effectiveAptitude(faction, holder) + "/100", - 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.", + holder == null ? "§7Falls to the faction leader once they have an active character." + : held > 1 ? "§7Holds " + held + " offices: " + Math.round(EspionageService.positionMultiplier(held) * 100) + + "% aptitude in each (base " + holder.aptitude + ")." + : holder.automatic ? "§7Held by the faction leader until a member is appointed." + : "§7Appointed by the faction leader.", "§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.", @@ -283,7 +287,7 @@ public static void spymasterOffice(Player viewer, Faction faction, InventoryMana ? "\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", - "\u00a7cMakes all faction and guild information public.")); + "\u00a77Dismisses the appointee. The office returns to you.")); } inventory.setItem(26, manager.createBackButton(SFGUI.SPYMASTER_VIEW)); viewer.openInventory(inventory); diff --git a/src/main/resources/special-positions.yml b/src/main/resources/special-positions.yml index b5315218..9ad8d3ea 100644 --- a/src/main/resources/special-positions.yml +++ b/src/main/resources/special-positions.yml @@ -21,8 +21,10 @@ espionage: attribute-center: 6 attribute-cap: 16 random-spread: 20 - # A leader can only hold this office in a one-person faction; they retain 25% aptitude. - solo-leader-multiplier: 0.25 + # The faction leader holds every vacant office by default. Each office a character holds + # beyond their first costs this fraction of their aptitude in all of them: + # 1 office 100%, 2 offices 75%, 3 offices 50%... Never below 0%. Zero disables this penalty. + extra-position-penalty: 0.25 attribute-weights: intelligence: 3.0 wisdom: 2.5 diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java index 30220dd0..c206c8af 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionagePermissionsTest.java @@ -155,40 +155,104 @@ void departedSpymasterLosesOfficeAndCannotSabotage() { } @Test - void leaderIsIneligibleUnlessAloneAndSoloAptitudeIsReducedBySeventyFivePercent() { + void leaderIsAlwaysEligibleAndOneOfficeKeepsFullAptitude() { Faction faction = mock(Faction.class); when(faction.isLeader("Leader")).thenReturn(true); when(faction.isMemberIgnoreCase(anyString())).thenReturn(true); when(faction.getMembers()).thenReturn(java.util.List.of("Leader", "Spy")); - assertFalse(EspionageService.eligible(faction, "Leader")); + assertTrue(EspionageService.eligible(faction, "Leader")); assertTrue(EspionageService.eligible(faction, "Spy")); + var state = new EspionageState(); + when(faction.getEspionage()).thenReturn(state); var holder = new SpecialPositionAssignment(); holder.playerName = "Leader"; - holder.aptitude = 100; - assertEquals(0, EspionageService.effectiveAptitude(faction, holder)); - when(faction.getMembers()).thenReturn(java.util.List.of("Leader")); - assertTrue(EspionageService.eligible(faction, "Leader")); - assertEquals(25, EspionageService.effectiveAptitude(faction, holder)); - assertEquals(100, holder.aptitude, "Permanent base aptitude must remain unchanged"); - holder.aptitude = 79; - assertEquals(19, EspionageService.effectiveAptitude(faction, holder)); + state.assignFounder(SpecialPosition.SPYMASTER, holder, 79); + assertEquals(1, EspionageService.positionsHeld(faction, holder)); + assertEquals(79, EspionageService.effectiveAptitude(faction, holder)); + assertEquals(79, holder.aptitude, "Permanent base aptitude must remain unchanged"); + } + + @Test + void eachExtraOfficeCostsTheConfiguredShareOfAptitude() { + try { + assertEquals(1.0, EspionageService.positionMultiplier(1)); + assertEquals(0.75, EspionageService.positionMultiplier(2)); + assertEquals(0.5, EspionageService.positionMultiplier(3)); + assertEquals(0.0, EspionageService.positionMultiplier(5)); + assertEquals(0.0, EspionageService.positionMultiplier(9), "Aptitude never goes negative"); + var config = new org.bukkit.configuration.file.YamlConfiguration(); + config.set("espionage.aptitude.extra-position-penalty", 0.1); + EspionageConfig.load(config); + assertEquals(0.7, EspionageService.positionMultiplier(4), 1e-9); + config.set("espionage.aptitude.extra-position-penalty", 0); + EspionageConfig.load(config); + assertEquals(1.0, EspionageService.positionMultiplier(4)); + config.set("espionage.aptitude.extra-position-penalty", 2); + EspionageConfig.load(config); + assertEquals(0.75, EspionageService.positionMultiplier(2), "Out-of-range values fall back to the default"); + } finally { EspionageConfig.load(new org.bukkit.configuration.file.YamlConfiguration()); } } @Test - void soloLeaderLosesOfficeWhenAnotherMemberJoins() { + void leaderKeepsOfficeWhenAnotherMemberJoins() { Faction faction = mock(Faction.class); when(faction.isLeader("Leader")).thenReturn(true); + when(faction.getLeader()).thenReturn("Leader"); when(faction.getMembers()).thenReturn(java.util.List.of("Leader")); var state = new EspionageState(); var holder = new SpecialPositionAssignment(); holder.playerName = "Leader"; - state.appoint(holder, 100); + state.assignFounder(SpecialPosition.SPYMASTER, holder, 100); when(faction.getEspionage()).thenReturn(state); - assertSame(holder, EspionageService.spymaster(faction)); when(faction.getMembers()).thenReturn(java.util.List.of("Leader", "GuildMember")); try (var databases = mockConstruction(Database.class)) { + assertSame(holder, EspionageService.spymaster(faction)); + assertTrue(EspionageService.hasSpymaster(faction)); + assertEquals(100, EspionageService.effectiveAptitude(faction, holder)); + assertTrue(databases.constructed().isEmpty()); + } + } + + @Test + void defaultHoldingFollowsANewLeaderButAppointmentsStay() { + Faction faction = mock(Faction.class); + when(faction.getLeader()).thenReturn("NewLeader"); + when(faction.isLeader("NewLeader")).thenReturn(true); + when(faction.isMemberIgnoreCase("OldLeader")).thenReturn(true); + var state = new EspionageState(); + when(faction.getEspionage()).thenReturn(state); + var appointed = new SpecialPositionAssignment(); + appointed.playerName = "OldLeader"; + state.appoint(appointed, 80); + try (var databases = mockConstruction(Database.class)) { + assertSame(appointed, EspionageService.spymaster(faction), "A deliberate appointment survives a change of leader"); + assertTrue(databases.constructed().isEmpty()); + } + state.removeSpymaster(); + var automatic = new SpecialPositionAssignment(); + automatic.playerName = "OldLeader"; + state.assignFounder(SpecialPosition.SPYMASTER, automatic, 80); + assertFalse(EspionageService.hasSpymaster(faction)); + try (var databases = mockConstruction(Database.class)) { + // Without a server the new leader's character is unknown, so the office waits for them. assertNull(EspionageService.spymaster(faction)); - assertNull(state.getSpymaster()); + assertTrue(state.isPendingFounder(SpecialPosition.SPYMASTER)); + assertEquals(1, databases.constructed().size()); + verify(databases.constructed().getFirst()).saveFaction(faction); + assertNull(EspionageService.spymaster(faction)); + assertEquals(1, databases.constructed().size(), "A waiting office is not saved again"); + } + } + + @Test + void anyVacancyFallsBackToTheLeader() { + Faction faction = mock(Faction.class); + when(faction.getLeader()).thenReturn("Leader"); + var state = new EspionageState(); + when(faction.getEspionage()).thenReturn(state); + try (var databases = mockConstruction(Database.class)) { + assertNull(EspionageService.spymaster(faction)); + assertTrue(state.isPendingFounder(SpecialPosition.SPYMASTER)); verify(databases.constructed().getFirst()).saveFaction(faction); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java index bbfecf50..dd5f5dac 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java @@ -10,7 +10,7 @@ class OfficeAppointmentsTest { @Test - void newSoloFactionWaitsForACharacterWithoutUsingTheFreeAppointment() { + void newFactionWaitsForTheLeadersCharacterWithoutUsingTheFreeAppointment() { var faction = mock(Faction.class); var state = new EspionageState(); when(faction.getEspionage()).thenReturn(state); @@ -31,9 +31,45 @@ void newSoloFactionWaitsForACharacterWithoutUsingTheFreeAppointment() { when(faction.getMembers()).thenReturn(java.util.List.of("Founder", "Member")); EspionageService.initializeFounder(faction); assertNull(state.getSpymaster()); + assertTrue(state.hasPendingFounder(), "The leader still takes the office once others have joined"); } } + @Test + void vacantOfficeInALargerFactionFallsToTheLeadersCharacterAtFullAptitude() throws Exception { + var faction = mock(Faction.class); + var leader = mock(org.bukkit.entity.Player.class); + var state = new EspionageState(); + when(faction.getEspionage()).thenReturn(state); + when(faction.getLeader()).thenReturn("Leader"); + when(faction.isLeader("Leader")).thenReturn(true); + when(faction.getMembers()).thenReturn(java.util.List.of("Leader", "Member", "Other")); + when(leader.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); + var registry = mock(CharacterAptitudes.class); + when(registry.aptitude(eq("leader-character"), any())).thenReturn(72); + var field = EspionageService.class.getDeclaredField("characterAptitudes"); + field.setAccessible(true); + var previous = field.get(null); + try (var bukkit = mockStatic(org.bukkit.Bukkit.class); + var characters = mockStatic(OfficeCharacters.class); + var databases = mockConstruction(net.tfminecraft.simplefactions.database.Database.class)) { + field.set(null, registry); + bukkit.when(org.bukkit.Bukkit::getServer).thenReturn(mock(org.bukkit.Server.class)); + bukkit.when(() -> org.bukkit.Bukkit.getPlayerExact("Leader")).thenReturn(leader); + characters.when(() -> OfficeCharacters.activeCharacterId(leader)).thenReturn("leader-character"); + var holder = EspionageService.spymaster(faction); + assertNotNull(holder); + assertEquals("Leader", holder.playerName); + assertEquals("leader-character", holder.characterId); + assertTrue(holder.automatic); + assertEquals(72, EspionageService.effectiveAptitude(faction, holder)); + assertEquals(0, state.appointmentCount(SpecialPosition.SPYMASTER), "The default holding keeps the free appointment"); + assertFalse(state.hasPendingFounder()); + verify(databases.constructed().getFirst()).saveFaction(faction); + verify(leader).sendMessage(contains("you hold the keys")); + } finally { field.set(null, previous); } + } + private SpecialPositionAssignment assignment(String name) { var assignment = new SpecialPositionAssignment(); assignment.playerName = name; diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java index 7a49a363..0b3baf34 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java @@ -22,17 +22,37 @@ class OfficePersistenceTest { when(faction.getEspionage()).thenReturn(state); when(actor.getName()).thenReturn("Leader"); when(faction.isLeader("Leader")).thenReturn(true); + when(faction.isMemberIgnoreCase("Spy")).thenReturn(true); try (var databases = mockConstruction(Database.class)) { assertFalse(EspionageService.remove(actor, faction)); assertSame(holder, state.getSpymaster()); assertEquals(1, state.appointmentCount(SpecialPosition.SPYMASTER)); verify(actor).sendMessage(contains("could not be saved")); - verify(actor, never()).sendMessage(contains("now vacant")); + verify(actor, never()).sendMessage(contains("dismissed")); } try (var databases = mockConstruction(Database.class, (database, context) -> when(database.saveFactionChecked(faction)).thenReturn(true))) { assertTrue(EspionageService.remove(actor, faction)); assertNull(state.getSpymaster()); assertEquals(1, state.appointmentCount(SpecialPosition.SPYMASTER)); + verify(actor).sendMessage(contains("returns to you")); + } + } + + @Test void leaderCannotDismissTheirOwnDefaultHolding() { + var faction = mock(Faction.class); + var actor = mock(Player.class); + var state = new EspionageState(); + var leader = holder("Leader"); + state.assignFounder(SpecialPosition.SPYMASTER, leader, 80); + when(faction.getEspionage()).thenReturn(state); + when(faction.getLeader()).thenReturn("Leader"); + when(actor.getName()).thenReturn("Leader"); + when(faction.isLeader("Leader")).thenReturn(true); + try (var databases = mockConstruction(Database.class)) { + assertFalse(EspionageService.remove(actor, faction)); + assertSame(leader, state.getSpymaster()); + assertTrue(databases.constructed().isEmpty()); + verify(actor).sendMessage(contains("already rests with you")); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFileTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFileTest.java index bda1eb0d..d3328e01 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFileTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFileTest.java @@ -52,6 +52,19 @@ class SpecialPositionsConfigFileTest { assertEquals(original, Files.readString(legacy)); } + @Test void retiredSoloLeaderSettingIsRemovedAndExtraPositionPenaltyAdded() throws Exception { + var file = directory.resolve("special-positions.yml"); + Files.writeString(file, "espionage:\n aptitude:\n # A leader can only hold this office in a one-person faction.\n" + + " solo-leader-multiplier: 0.25\n base: 40.0\n"); + var prepared = SpecialPositionsConfigFile.prepare(file.toFile(), directory.resolve("config.yml").toFile(), defaults()); + assertFalse(prepared.contains("espionage.aptitude.solo-leader-multiplier")); + assertEquals(0.25, prepared.getDouble("espionage.aptitude.extra-position-penalty")); + assertEquals(40, prepared.getDouble("espionage.aptitude.base")); + String saved = Files.readString(file); + assertFalse(saved.contains("solo-leader") || saved.contains("one-person"), saved); + assertTrue(saved.contains("extra-position-penalty"), saved); + } + @Test void malformedDedicatedYamlIsRejectedWithoutOverwritingEitherFile() throws Exception { var file = directory.resolve("special-positions.yml"); Files.writeString(file, "espionage: [invalid\n"); diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/UnguardedFactionTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/UnguardedFactionTest.java index c0fc5754..f9f57320 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/UnguardedFactionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/UnguardedFactionTest.java @@ -95,7 +95,7 @@ private SpecialPositionAssignment appoint(Faction faction, String name, int apti } } - @Test void soloFounderProtectsUntilAnotherMemberJoinsAndPendingFounderIsUnguarded() { + @Test void leaderKeepsProtectingAfterOthersJoinAndPendingFounderIsUnguarded() { var target = faction("target"); target.getEspionage().pendingFounder(SpecialPosition.SPYMASTER); assertTrue(EspionageService.canViewExact(viewer, target)); @@ -104,7 +104,7 @@ private SpecialPositionAssignment appoint(Faction faction, String name, int apti 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)); + assertFalse(EspionageService.canViewExact(viewer, target)); } @Test void publicMenusNeverGrantMembershipOfficeManagementOrSabotage() {