diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java index 678b4578..fdc07270 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java @@ -11,9 +11,9 @@ public final class EspionageConfig { "intelligence", 3.0, "wisdom", 2.5, "charisma", 1.5, "dexterity", 1.0, "constitution", -2.0, "strength", -2.0); private static Map weights = DEFAULT_WEIGHTS; - private static double repeatCost = 250; private static double stabilityPenalty = 10; private static double penaltyDays = 7; + private static double buildUpDays = 7, startingAptitude = .25, changeCooldownDays = 2; 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; @@ -54,9 +54,11 @@ public static void load(ConfigurationSection config) { loaded.put(attribute, Double.isFinite(value) && Math.abs(value) <= 1000 ? value : fallback); }); weights = Map.copyOf(loaded); - repeatCost = nonnegative(config, "espionage.appointments.repeat-cost", 250); stabilityPenalty = Math.min(100, nonnegative(config, "espionage.appointments.stability-penalty", 10)); penaltyDays = Math.min(3650, nonnegative(config, "espionage.appointments.penalty-days", 7)); + buildUpDays = bounded(config, "espionage.appointments.build-up-days", 7, 0, 3650); + startingAptitude = bounded(config, "espionage.appointments.starting-aptitude", .25, 0, 1); + changeCooldownDays = bounded(config, "espionage.appointments.change-cooldown-days", 2, 0, 3650); vacancyPenalties.clear(); for (SpecialPosition office : SpecialPosition.values()) vacancyPenalties.put(office, Math.min(100, nonnegative(config, "positions." + office.name().toLowerCase(java.util.Locale.ROOT) @@ -144,9 +146,13 @@ public static boolean allows(IntelligenceTier tier, String metric) { public static int luckDraws() { return luckDraws; } public static int rosterLimit() { return rosterLimit; } - public static double repeatCost() { return repeatCost; } public static double stabilityPenalty() { return stabilityPenalty; } public static double penaltyDays() { return penaltyDays; } + public static double buildUpDays() { return buildUpDays; } + public static double startingAptitude() { return startingAptitude; } + public static double changeCooldownDays() { return changeCooldownDays; } + /** False when a new Spymaster serves at full aptitude from the start. */ + public static boolean buildsUp() { return buildUpDays > 0 && startingAptitude < 1; } public static double vacancyPenalty(SpecialPosition office) { return vacancyPenalties.getOrDefault(office, 10.0); } public static Map weights() { return weights; } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageMath.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageMath.java index e41c70a1..a43af048 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageMath.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageMath.java @@ -22,6 +22,16 @@ private static int centered(Map attributes, String key) { return clamp(attributes.getOrDefault(key, 0), 0, EspionageConfig.cap()) - EspionageConfig.center(); } + /** Share of aptitude a holder has built up, rising linearly from the starting share to full. */ + public static double buildUp(long appointedAt, long now) { + double days = EspionageConfig.buildUpDays(); + if (appointedAt <= 0 || days <= 0) return 1; + double progress = (now - appointedAt) / (days * 86_400_000); + if (progress >= 1) return 1; + double start = EspionageConfig.startingAptitude(); + return start + (1 - start) * Math.max(0, progress); + } + public static int dailyRoll(int aptitude, int reduction, RandomGenerator random) { // Several independent draws concentrate luck around zero while retaining rare extremes. int luck = 0; diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index 77e360d6..bfea57c8 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -96,41 +96,40 @@ private static void initializeFounder(Faction faction, java.util.Set di } } - public static double appointmentCost(Faction faction) { - return faction.getEspionage().appointmentCount(SpecialPosition.SPYMASTER) == 0 ? 0 : EspionageConfig.repeatCost(); + /** Epoch millis from which the leader may appoint another Spymaster; zero means no wait. */ + public static long nextAppointmentAt(Faction faction) { + long last = faction.getEspionage().lastAppointedAt(SpecialPosition.SPYMASTER); + double days = EspionageConfig.changeCooldownDays(); + return last <= 0 || days <= 0 ? 0 : last + Math.round(days * 86_400_000); } - static boolean canAffordAppointment(Faction faction) { - double cost = appointmentCost(faction); - if (cost == 0) return true; - return faction.getBank() != null && Double.isFinite(faction.getBank().getWealth()) && faction.getBank().getWealth() >= cost; - } - - static boolean completeAppointment(Faction faction, SpecialPositionAssignment assignment, int aptitude) { - if (!canAffordAppointment(faction)) return false; + static boolean completeAppointment(Faction faction, SpecialPositionAssignment assignment, int aptitude, long now) { + if (now < nextAppointmentAt(faction)) return false; boolean repeat = faction.getEspionage().appointmentCount(SpecialPosition.SPYMASTER) > 0; - double cost = appointmentCost(faction); - if (cost > 0) faction.getBank().withdraw(cost); - faction.getEspionage().appoint(assignment, aptitude); + faction.getEspionage().appoint(assignment, aptitude, now); if (repeat) faction.getEspionage().addUnrest(SpecialPosition.SPYMASTER, - EspionageConfig.stabilityPenalty(), EspionageConfig.penaltyDays(), System.currentTimeMillis()); + EspionageConfig.stabilityPenalty(), EspionageConfig.penaltyDays(), now); return true; } - static boolean completeAndSaveAppointment(Faction faction, SpecialPositionAssignment assignment, int aptitude) { + static boolean completeAndSaveAppointment(Faction faction, SpecialPositionAssignment assignment, int aptitude, long now) { var previous = faction.getEspionage().snapshotOffices(); - double cost = appointmentCost(faction); - if (!completeAppointment(faction, assignment, aptitude)) return false; + if (!completeAppointment(faction, assignment, aptitude, now)) return false; if (new Database().saveFactionChecked(faction)) return true; faction.getEspionage().restoreOffices(previous); - if (cost > 0) faction.getBank().deposit(cost); return false; } public static int effectiveAptitude(Faction faction, SpecialPositionAssignment holder) { + return effectiveAptitude(faction, holder, System.currentTimeMillis()); + } + + /** A new holder builds up to their permanent aptitude; a solo leader keeps only part of it. */ + static int effectiveAptitude(Faction faction, SpecialPositionAssignment holder, long now) { if (holder == null || !eligible(faction, holder.playerName)) return 0; // 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); + return (int) Math.floor(holder.aptitude * EspionageMath.buildUp(holder.appointedAt, now) + * positionMultiplier(positionsHeld(faction, holder)) + 1e-9); } /** Offices held by the same person; an assignment not saved yet still counts as one. */ @@ -148,6 +147,17 @@ public static double positionMultiplier(int held) { return Math.max(0, 1 - EspionageConfig.extraPositionPenalty() * Math.max(0, held - 1)); } + /** Millis until the holder reaches full aptitude; zero once built up. */ + public static long buildUpRemaining(SpecialPositionAssignment holder, long now) { + if (holder == null || holder.appointedAt <= 0 || !EspionageConfig.buildsUp()) return 0; + return Math.max(0, holder.appointedAt + Math.round(EspionageConfig.buildUpDays() * 86_400_000) - now); + } + + public static String duration(long millis) { + long minutes = Math.max(1, (millis + 59_999) / 60_000); + return net.tfminecraft.tlibs.utils.TimeFormatter.formatTime((int) Math.min(Integer.MAX_VALUE / 60, minutes) * 60); + } + public static boolean isOwn(Player viewer, Faction target) { return viewer != null && target != null && (target.isMemberIgnoreCase(viewer.getName()) || target.isLeader(viewer.getName())); @@ -183,7 +193,9 @@ private static SpecialPositionAssignment spymaster(Faction faction, java.util.Se boolean changed = false; SpecialPositionAssignment holder = state.getSpymaster(); if (holder != null && !validHolder(faction, holder)) { + boolean died = eligible(faction, holder.playerName) && deadCharacter(holder); state.removeSpymaster(); + if (died) state.waiveAppointmentWait(SpecialPosition.SPYMASTER); changed = true; } // Whatever emptied the office, it falls back to the current leader. @@ -215,6 +227,7 @@ public static void characterDied(Player owner, String characterId, String charac && characterId.equals(state.pendingFounderCharacter(SpecialPosition.SPYMASTER)); if (!matching && !pending) continue; state.removeSpymaster(); + state.waiveAppointmentWait(SpecialPosition.SPYMASTER); 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 leader until a successor is appointed."); @@ -252,11 +265,11 @@ public static boolean appoint(Player actor, Faction faction, Player candidate) { actor.sendMessage("§7That member is already your Spymaster."); return false; } - if (!canAffordAppointment(faction)) { - actor.sendMessage("\u00a7cThe faction treasury needs " + appointmentCost(faction) + "d for this appointment."); + long now = System.currentTimeMillis(); + if (now < nextAppointmentAt(faction)) { + actor.sendMessage("\u00a7cA new Spymaster can be appointed in " + duration(nextAppointmentAt(faction) - now) + "."); return false; } - double cost = appointmentCost(faction); boolean repeat = faction.getEspionage().appointmentCount(SpecialPosition.SPYMASTER) > 0; SpecialPositionAssignment assignment = new SpecialPositionAssignment(); assignment.playerId = candidate.getUniqueId(); @@ -265,8 +278,8 @@ public static boolean appoint(Player actor, Faction faction, Player candidate) { try { if (characterAptitudes == null) throw new java.io.IOException("Character aptitude registry is unavailable"); int aptitude = characterAptitude(candidate, characterId); - if (!completeAndSaveAppointment(faction, assignment, aptitude)) { - actor.sendMessage("\u00a7cThe appointment could not be saved. The office and treasury are unchanged."); + if (!completeAndSaveAppointment(faction, assignment, aptitude, now)) { + actor.sendMessage("\u00a7cThe appointment could not be saved. The office is unchanged."); return false; } } catch (java.io.IOException exception) { @@ -278,14 +291,18 @@ public static boolean appoint(Player actor, Faction faction, Player candidate) { // Address only the appointee. Sabotage preferences are never broadcast. candidate.sendMessage("§8§oA sealed letter reaches your hands. The keys to " + faction.getName() + "§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) + candidate.sendMessage("§7Your aptitude for this office is §e" + effectiveAptitude(faction, assignment, now) + "/100§7. Inspect Special Positions in the faction menu, or use §a/faction espionage§7."); + if (buildUpRemaining(assignment, now) > 0) + candidate.sendMessage("§7Your network is still taking shape. Full aptitude (§e" + assignment.aptitude + + "§7) in §e" + duration(buildUpRemaining(assignment, now)) + "§7."); 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."); + if (repeat && EspionageConfig.stabilityPenalty() > 0 && EspionageConfig.penaltyDays() > 0) + actor.sendMessage("\u00a77The change of office brings " + EspionageConfig.stabilityPenalty() + + " points of unrest, fading over " + EspionageConfig.penaltyDays() + " days."); return true; } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java index 74477166..26d56c31 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageState.java @@ -16,6 +16,8 @@ public class EspionageState { private java.util.List unrest = new java.util.ArrayList<>(); private java.util.Set pendingFounders = java.util.EnumSet.noneOf(SpecialPosition.class); private Map pendingFounderCharacters = new EnumMap<>(SpecialPosition.class); + // Last deliberate appointment per office; starts the wait before the next one. + private Map lastAppointments = new EnumMap<>(SpecialPosition.class); void pendingFounder(SpecialPosition office) { pendingFounders.add(office); } void pendingFounder(SpecialPosition office, String characterId) { @@ -28,7 +30,8 @@ void pendingFounder(SpecialPosition office, String characterId) { record OfficeSnapshot(Map positions, Map appointments, java.util.List unrest, - java.util.Set pendingFounders, Map pendingFounderCharacters) {} + java.util.Set pendingFounders, Map pendingFounderCharacters, + Map lastAppointments) {} OfficeSnapshot snapshotOffices() { var savedPositions = new EnumMap(SpecialPosition.class); @@ -36,7 +39,8 @@ OfficeSnapshot snapshotOffices() { var savedAppointments = new EnumMap(SpecialPosition.class); savedAppointments.putAll(appointments); return new OfficeSnapshot(savedPositions, savedAppointments, - new java.util.ArrayList<>(unrest), new java.util.HashSet<>(pendingFounders), new HashMap<>(pendingFounderCharacters)); + new java.util.ArrayList<>(unrest), new java.util.HashSet<>(pendingFounders), new HashMap<>(pendingFounderCharacters), + new HashMap<>(lastAppointments)); } void restoreOffices(OfficeSnapshot snapshot) { @@ -45,6 +49,7 @@ void restoreOffices(OfficeSnapshot snapshot) { unrest.clear(); unrest.addAll(snapshot.unrest()); pendingFounders.clear(); pendingFounders.addAll(snapshot.pendingFounders()); pendingFounderCharacters.clear(); pendingFounderCharacters.putAll(snapshot.pendingFounderCharacters()); + lastAppointments.clear(); lastAppointments.putAll(snapshot.lastAppointments()); } public SpecialPositionAssignment holder(SpecialPosition office) { return positions.get(office); } @@ -105,7 +110,15 @@ void appoint(SpecialPositionAssignment assignment, Supplier aptitudeRol appoint(assignment, assignment.aptitude); } + /** Appoints without a build-up or change wait, as for offices saved before either existed. */ public void appoint(SpecialPositionAssignment assignment, int permanentAptitude) { + appoint(assignment, permanentAptitude, 0); + } + + /** A positive time starts the holder's build-up and the wait before the next appointment. */ + public void appoint(SpecialPositionAssignment assignment, int permanentAptitude, long now) { + if (now > 0) lastAppointments.put(SpecialPosition.SPYMASTER, now); + assignment.appointedAt = Math.max(0, now); pendingFounders.remove(SpecialPosition.SPYMASTER); pendingFounderCharacters.remove(SpecialPosition.SPYMASTER); appointments.put(SpecialPosition.SPYMASTER, appointmentCount(SpecialPosition.SPYMASTER) + 1); @@ -114,6 +127,13 @@ public void appoint(SpecialPositionAssignment assignment, int permanentAptitude) positions.put(SpecialPosition.SPYMASTER, assignment); } + public long lastAppointedAt(SpecialPosition office) { + return lastAppointments.getOrDefault(office, 0L); + } + + /** Losing a holder to character death is not a leader's choice, so the next appointment need not wait. */ + void waiveAppointmentWait(SpecialPosition office) { lastAppointments.remove(office); } + public Map legacyAptitudes() { return Map.copyOf(appointmentAptitudes); } public IntelligenceReport cachedReport(String targetId, long foundedAt, long day) { diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionAssignment.java b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionAssignment.java index f7716b7b..1c63ac47 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionAssignment.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionAssignment.java @@ -8,6 +8,8 @@ public class SpecialPositionAssignment { public String characterId; public int aptitude; public boolean automatic; + // Epoch millis of a deliberate appointment; zero (founders, older saves) means fully established. + public long appointedAt; // Private, voluntary penalties. Zero means sabotage is disabled. public int offenseReduction; public int defenseReduction; diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFile.java b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFile.java index 8544e3ce..cdd99ecf 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFile.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFile.java @@ -13,7 +13,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 static final java.util.List RETIRED_KEYS = java.util.List.of( + "espionage.aptitude.solo-leader-multiplier", "espionage.appointments.repeat-cost"); private SpecialPositionsConfigFile() {} public static void load(JavaPlugin plugin) { 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 db18fdf6..4f7b5863 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java @@ -259,9 +259,11 @@ 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); + long now = System.currentTimeMillis(); 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", + buildUpLine(holder, now), 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 + ")." @@ -279,13 +281,16 @@ public static void spymasterOffice(Player viewer, Faction faction, InventoryMana } inventory.setItem(13, head); if (faction.isLeader(viewer.getName())) { - double cost = EspionageService.appointmentCost(faction); + long next = EspionageService.nextAppointmentAt(faction); inventory.setItem(11, item(Material.NAME_TAG, "Appoint Spymaster", "§7Choose a member of your faction.", + buildUpTerms(), faction.getEspionage().appointmentCount(SpecialPosition.SPYMASTER) == 0 - ? "\u00a7aFirst appointment: free" : "\u00a77Treasury cost: \u00a7e" + cost + "d", - 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.")); + ? "\u00a77The first appointment brings no unrest." + : EspionageConfig.stabilityPenalty() <= 0 || EspionageConfig.penaltyDays() <= 0 + ? "\u00a77Changing Spymaster brings no unrest." + : "\u00a77Unrest: -" + EspionageConfig.stabilityPenalty() + " points, fading over " + EspionageConfig.penaltyDays() + " days.", + now < next ? "\u00a7cNext appointment in " + EspionageService.duration(next - now) + "." + : "\u00a7aAn appointment can be made now.")); inventory.setItem(15, item(Material.REDSTONE, "Remove Spymaster", "\u00a77Dismisses the appointee. The office returns to you.")); } @@ -300,6 +305,7 @@ public static void settings(Player viewer, Faction faction, InventoryManager man 27, "§7Spymaster's Private Conduct"); inventory.setItem(4, item(Material.PAPER, "Your sealed instructions", "§7Aptitude: §e" + EspionageService.effectiveAptitude(faction, holder) + "/100", + 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.")); @@ -309,6 +315,18 @@ public static void settings(Player viewer, Faction faction, InventoryManager man viewer.openInventory(inventory); } + private static String buildUpLine(SpecialPositionAssignment holder, long now) { + long remaining = EspionageService.buildUpRemaining(holder, now); + return remaining <= 0 ? "\u00a77Network: \u00a7aestablished" + : "\u00a77Network: \u00a7ebuilding\u00a77, full aptitude in " + EspionageService.duration(remaining); + } + + private static String buildUpTerms() { + if (!EspionageConfig.buildsUp()) return "\u00a77A new Spymaster serves at full aptitude at once."; + return "\u00a77A new Spymaster starts at " + Math.round(EspionageConfig.startingAptitude() * 100) + + "% aptitude, reaching full over " + EspionageService.duration(Math.round(EspionageConfig.buildUpDays() * 86_400_000)) + "."; + } + 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, @@ -327,7 +345,7 @@ private static void candidates(Player viewer, Faction faction, InventoryManager String name = members.get(index); ItemStack head = item(Material.PLAYER_HEAD, CharacterNames.display(viewer, name), "§7Guild: " + net.tfminecraft.simplefactions.utils.Represents.represents(faction, name), - "\u00a77Treasury cost: " + EspionageService.appointmentCost(faction) + "d", + buildUpTerms(), Bukkit.getPlayerExact(name) == null ? "§8Must be online with an active character." : "§aClick to appoint as Spymaster."); var meta = head.getItemMeta(); meta.getPersistentDataContainer().set(new NamespacedKey(SimpleFactions.plugin, "spy-candidate"), diff --git a/src/main/resources/special-positions.yml b/src/main/resources/special-positions.yml index 9ad8d3ea..fdd77143 100644 --- a/src/main/resources/special-positions.yml +++ b/src/main/resources/special-positions.yml @@ -11,10 +11,17 @@ espionage: bypass-permission: simplefactions.espionage.bypass reload-permission: simplefactions.espionage.reload appointments: - # First deliberate appointment is free. The automatic founding appointment does not count. - repeat-cost: 250.0 + # Appointments are free. Replacing an appointed Spymaster brings unrest that fades over penalty-days. + # The automatic founding appointment does not count as a replacement. stability-penalty: 10.0 penalty-days: 7.0 + # A newly appointed Spymaster starts at this share of their aptitude (0-1) and reaches + # full aptitude after build-up-days. Zero days disables the build-up. + build-up-days: 7.0 + starting-aptitude: 0.25 + # Days after an appointment before the leader can appoint another Spymaster. Removing one is + # always allowed. A holder's character death waives the wait. Zero disables it. + change-cooldown-days: 2.0 # Each character rolls once for life. Attribute/formula changes affect only new characters. aptitude: base: 50.0 diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java index dd5f5dac..b5eaf1a6 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeAppointmentsTest.java @@ -2,10 +2,8 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; -import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import net.tfminecraft.simplefactions.database.JsonUtil; -import net.tfminecraft.simplefactions.objects.Bank; import net.tfminecraft.simplefactions.objects.Faction; class OfficeAppointmentsTest { @@ -78,48 +76,86 @@ private SpecialPositionAssignment assignment(String name) { } @Test - void founderDoesNotConsumeFreeAppointmentAndRemovalCannotResetPaidHistory() { + void appointmentsAreFreeAndOnlyReplacementsBringUnrest() { + long day = 86_400_000L; var state = new EspionageState(); state.assignFounder(SpecialPosition.SPYMASTER, assignment("Founder"), 80); assertTrue(state.getSpymaster().automatic); assertEquals(0, state.appointmentCount(SpecialPosition.SPYMASTER)); var faction = mock(Faction.class); - var bank = mock(Bank.class); - var balance = new AtomicReference<>(1000.0); when(faction.getEspionage()).thenReturn(state); - when(faction.getBank()).thenReturn(bank); - when(bank.getWealth()).thenAnswer(ignored -> balance.get()); - doAnswer(call -> { balance.updateAndGet(value -> value - (Double) call.getArgument(0)); return null; }).when(bank).withdraw(anyDouble()); - assertTrue(EspionageService.completeAppointment(faction, assignment("First"), 90)); - assertEquals(1000.0, balance.get()); - assertTrue(state.unrestModifiers(System.currentTimeMillis()).isEmpty()); + assertEquals(0, EspionageService.nextAppointmentAt(faction), "A founder never delays the first appointment"); + assertTrue(EspionageService.completeAppointment(faction, assignment("First"), 90, 10 * day)); + assertTrue(state.unrestModifiers(10 * day).isEmpty()); state.removeSpymaster(); - assertEquals(250.0, EspionageService.appointmentCost(faction)); - assertTrue(EspionageService.completeAppointment(faction, assignment("Second"), 65)); - assertEquals(750.0, balance.get()); - assertEquals(-10, state.unrestModifiers(System.currentTimeMillis()).getFirst().getModifier(), 0.001); + assertTrue(EspionageService.completeAppointment(faction, assignment("Second"), 65, 12 * day)); + assertEquals(-10, state.unrestModifiers(12 * day).getFirst().getModifier(), 0.001); state.removeSpymaster(); var restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(state), EspionageState.class); assertEquals(2, restored.appointmentCount(SpecialPosition.SPYMASTER)); - assertEquals(-10, restored.unrestModifiers(System.currentTimeMillis()).getFirst().getModifier(), 0.001); - verify(bank, times(1)).withdraw(250.0); + assertEquals(-10, restored.unrestModifiers(12 * day).getFirst().getModifier(), 0.001); + verify(faction, never()).getBank(); } @Test - void unaffordableAppointmentDoesNotChangeOfficeBalanceHistoryOrStability() { + void anotherAppointmentWaitsForTheCooldownEvenAfterRemoval() { + long day = 86_400_000L; var state = new EspionageState(); var first = assignment("First"); - state.appoint(first, 82); var faction = mock(Faction.class); - var bank = mock(Bank.class); when(faction.getEspionage()).thenReturn(state); - when(faction.getBank()).thenReturn(bank); - when(bank.getWealth()).thenReturn(249.0); - assertFalse(EspionageService.completeAppointment(faction, assignment("Second"), 20)); + assertTrue(EspionageService.completeAppointment(faction, first, 82, 10 * day)); + assertEquals(12 * day, EspionageService.nextAppointmentAt(faction)); + assertFalse(EspionageService.completeAppointment(faction, assignment("Second"), 20, 12 * day - 1)); assertSame(first, state.getSpymaster()); assertEquals(1, state.appointmentCount(SpecialPosition.SPYMASTER)); - assertTrue(state.unrestModifiers(System.currentTimeMillis()).isEmpty()); - verify(bank, never()).withdraw(anyDouble()); + assertTrue(state.unrestModifiers(12 * day - 1).isEmpty()); + state.removeSpymaster(); + var restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(state), EspionageState.class); + when(faction.getEspionage()).thenReturn(restored); + assertFalse(EspionageService.completeAppointment(faction, assignment("Second"), 20, 11 * day), + "Removing the holder and restarting must not skip the wait"); + assertTrue(EspionageService.completeAppointment(faction, assignment("Second"), 20, 12 * day)); + assertEquals(14 * day, EspionageService.nextAppointmentAt(faction)); + restored.waiveAppointmentWait(SpecialPosition.SPYMASTER); + assertEquals(0, EspionageService.nextAppointmentAt(faction)); + } + + @Test + void newHolderBuildsUpToFullAptitudeOverTheConfiguredDays() { + long day = 86_400_000L; + var faction = mock(Faction.class); + when(faction.isMemberIgnoreCase("Spy")).thenReturn(true); + when(faction.getMembers()).thenReturn(java.util.List.of("Leader", "Spy")); + var holder = assignment("Spy"); + new EspionageState().appoint(holder, 80, 10 * day); + assertEquals(10 * day, holder.appointedAt); + assertEquals(20, EspionageService.effectiveAptitude(faction, holder, 10 * day)); + assertEquals(50, EspionageService.effectiveAptitude(faction, holder, 10 * day + 7 * day / 2)); + assertEquals(80, EspionageService.effectiveAptitude(faction, holder, 17 * day)); + assertEquals(80, EspionageService.effectiveAptitude(faction, holder, 30 * day)); + assertEquals(20, EspionageService.effectiveAptitude(faction, holder, 9 * day), "Clock skew never drops below the start"); + assertEquals(7 * day / 2, EspionageService.buildUpRemaining(holder, 10 * day + 7 * day / 2)); + assertEquals(0, EspionageService.buildUpRemaining(holder, 17 * day)); + var founder = assignment("Spy"); + new EspionageState().assignFounder(SpecialPosition.SPYMASTER, founder, 80); + assertEquals(80, EspionageService.effectiveAptitude(faction, founder, 10 * day), "Founders and older saves start established"); + when(faction.isLeader("Spy")).thenReturn(true); + when(faction.getMembers()).thenReturn(java.util.List.of("Spy")); + assertEquals(20, EspionageService.effectiveAptitude(faction, holder, 10 * day), "An appointed leader builds up like anyone else"); + var config = new org.bukkit.configuration.file.YamlConfiguration(); + try { + config.set("espionage.appointments.build-up-days", 0.0); + EspionageConfig.load(config); + assertEquals(80, EspionageService.effectiveAptitude(faction, holder, 10 * day)); + assertEquals(0, EspionageService.buildUpRemaining(holder, 10 * day)); + config.set("espionage.appointments.build-up-days", 7.0); + config.set("espionage.appointments.starting-aptitude", 1.0); + EspionageConfig.load(config); + assertFalse(EspionageConfig.buildsUp()); + assertEquals(80, EspionageService.effectiveAptitude(faction, holder, 10 * day)); + assertEquals(0, EspionageService.buildUpRemaining(holder, 10 * day), "Full from the start leaves nothing to build"); + } finally { EspionageConfig.load(new org.bukkit.configuration.file.YamlConfiguration()); } } @Test @@ -135,27 +171,43 @@ void unrestFadesOverRealTimeAndExpiresEvenAcrossDowntime() { } @Test - void existingSavedOfficeCountsAsAlreadyAppointedAndConfigRejectsInvalidCosts() { + void existingSavedOfficeCountsAsAlreadyAppointedAndConfigRejectsInvalidValues() { var state = JsonUtil.GSON.fromJson("{\"positions\":{\"SPYMASTER\":{\"playerName\":\"OldSpy\",\"aptitude\":80}}}", EspionageState.class); assertEquals(1, state.appointmentCount(SpecialPosition.SPYMASTER)); state.removeSpymaster(); assertEquals(1, state.appointmentCount(SpecialPosition.SPYMASTER)); + assertEquals(0, state.lastAppointedAt(SpecialPosition.SPYMASTER)); var config = new org.bukkit.configuration.file.YamlConfiguration(); try { - config.set("espionage.appointments.repeat-cost", 500.0); config.set("espionage.appointments.stability-penalty", 12.0); config.set("espionage.appointments.penalty-days", 3.0); + config.set("espionage.appointments.build-up-days", 3.5); + config.set("espionage.appointments.starting-aptitude", 0.5); + config.set("espionage.appointments.change-cooldown-days", 1.0); EspionageConfig.load(config); - assertEquals(500, EspionageConfig.repeatCost()); assertEquals(12, EspionageConfig.stabilityPenalty()); assertEquals(3, EspionageConfig.penaltyDays()); - config.set("espionage.appointments.repeat-cost", -1.0); + assertEquals(3.5, EspionageConfig.buildUpDays()); + assertEquals(0.5, EspionageConfig.startingAptitude()); + assertEquals(1, EspionageConfig.changeCooldownDays()); config.set("espionage.appointments.stability-penalty", Double.NaN); config.set("espionage.appointments.penalty-days", 0.0); + config.set("espionage.appointments.build-up-days", -1.0); + config.set("espionage.appointments.starting-aptitude", 2.0); + config.set("espionage.appointments.change-cooldown-days", Double.NaN); EspionageConfig.load(config); - assertEquals(250, EspionageConfig.repeatCost()); assertEquals(10, EspionageConfig.stabilityPenalty()); assertEquals(0, EspionageConfig.penaltyDays()); + assertEquals(7, EspionageConfig.buildUpDays()); + assertEquals(0.25, EspionageConfig.startingAptitude()); + assertEquals(2, EspionageConfig.changeCooldownDays()); + config.set("espionage.appointments.change-cooldown-days", 0.0); + EspionageConfig.load(config); + var faction = mock(Faction.class); + var waiting = new EspionageState(); + waiting.appoint(assignment("Spy"), 50, 1000); + when(faction.getEspionage()).thenReturn(waiting); + assertEquals(0, EspionageService.nextAppointmentAt(faction)); } finally { EspionageConfig.load(new org.bukkit.configuration.file.YamlConfiguration()); } } } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java index 0b3baf34..92f5d651 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficePersistenceTest.java @@ -4,12 +4,10 @@ import static org.mockito.Mockito.*; import java.util.List; import java.util.UUID; -import java.util.concurrent.atomic.AtomicReference; import org.bukkit.entity.Player; import org.junit.jupiter.api.Test; import net.tfminecraft.simplefactions.database.Database; import net.tfminecraft.simplefactions.database.JsonUtil; -import net.tfminecraft.simplefactions.objects.Bank; import net.tfminecraft.simplefactions.objects.Faction; class OfficePersistenceTest { @@ -56,31 +54,27 @@ class OfficePersistenceTest { } } - @Test void failedPaidAppointmentRefundsTreasuryAndRestoresOfficeHistoryAndUnrest() { + @Test void failedAppointmentRestoresOfficeHistoryUnrestAndWait() { + long now = 30 * 86_400_000L; var faction = mock(Faction.class); - var bank = mock(Bank.class); var state = new EspionageState(); var previous = holder("OldSpy"); - state.appoint(previous, 90); - var balance = new AtomicReference<>(1000.0); + state.appoint(previous, 90, now - 3 * 86_400_000L); when(faction.getEspionage()).thenReturn(state); - when(faction.getBank()).thenReturn(bank); - when(bank.getWealth()).thenAnswer(ignored -> balance.get()); - doAnswer(call -> { balance.updateAndGet(value -> value - (Double) call.getArgument(0)); return null; }).when(bank).withdraw(anyDouble()); - doAnswer(call -> { balance.updateAndGet(value -> value + (Double) call.getArgument(0)); return null; }).when(bank).deposit(anyDouble()); try (var databases = mockConstruction(Database.class)) { - assertFalse(EspionageService.completeAndSaveAppointment(faction, holder("NewSpy"), 70)); + assertFalse(EspionageService.completeAndSaveAppointment(faction, holder("NewSpy"), 70, now)); assertSame(previous, state.getSpymaster()); - assertEquals(1000.0, balance.get()); assertEquals(1, state.appointmentCount(SpecialPosition.SPYMASTER)); - assertTrue(state.unrestModifiers(System.currentTimeMillis()).isEmpty()); + assertEquals(now - 3 * 86_400_000L, state.lastAppointedAt(SpecialPosition.SPYMASTER)); + assertTrue(state.unrestModifiers(now).isEmpty()); } try (var databases = mockConstruction(Database.class, (database, context) -> when(database.saveFactionChecked(faction)).thenReturn(true))) { - assertTrue(EspionageService.completeAndSaveAppointment(faction, holder("NewSpy"), 70)); - assertEquals(750.0, balance.get()); + assertTrue(EspionageService.completeAndSaveAppointment(faction, holder("NewSpy"), 70, now)); + assertEquals(now, state.lastAppointedAt(SpecialPosition.SPYMASTER)); assertEquals("NewSpy", state.getSpymaster().playerName); assertEquals(2, state.appointmentCount(SpecialPosition.SPYMASTER)); - assertEquals(-10.0, state.unrestModifiers(System.currentTimeMillis()).getFirst().getModifier(), 0.001); + assertEquals(-10.0, state.unrestModifiers(now).getFirst().getModifier(), 0.001); + verify(faction, never()).getBank(); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java index f562efca..fc4e800b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/OfficeVacancyTest.java @@ -64,7 +64,7 @@ class OfficeVacancyTest { var state = new EspionageState(); when(faction.getEspionage()).thenReturn(state); var holder = new SpecialPositionAssignment(); holder.playerName = "Spy"; holder.characterId = "appointed"; - state.appoint(holder, 80); + state.appoint(holder, 80, 1000); var character = mock(RPCharacter.class); when(character.getId()).thenReturn("other"); when(character.getStatus()).thenReturn(Status.DEAD); @@ -72,10 +72,12 @@ class OfficeVacancyTest { factions.when(FactionManager::getCopy).thenReturn(List.of(faction)); EspionageService.characterDied(null, character.getId(), "Spy"); assertSame(holder, state.getSpymaster()); + assertEquals(1000, state.lastAppointedAt(SpecialPosition.SPYMASTER)); when(character.getId()).thenReturn("appointed"); EspionageService.characterDied(null, character.getId(), "Spy"); assertNull(state.getSpymaster()); assertEquals(1, state.appointmentCount(SpecialPosition.SPYMASTER)); + assertEquals(0, state.lastAppointedAt(SpecialPosition.SPYMASTER), "Death waives the wait for a successor"); verify(databases.constructed().getFirst()).saveFaction(faction); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFileTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFileTest.java index d3328e01..54c4e3e7 100644 --- a/src/test/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFileTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFileTest.java @@ -31,37 +31,42 @@ class SpecialPositionsConfigFileTest { @Test void migrationPreservesCustomWeightsPermissionCostsAndMainConfiguration() throws Exception { var legacy = directory.resolve("config.yml"); - String original = "unrelated: value\nespionage:\n bypass-permission: staff.spy\n aptitude:\n attribute-weights:\n strength: -4.0\n appointments:\n repeat-cost: 333.0\n"; + String original = "unrelated: value\nespionage:\n bypass-permission: staff.spy\n aptitude:\n attribute-weights:\n strength: -4.0\n appointments:\n stability-penalty: 33.0\n"; Files.writeString(legacy, original); var file = directory.resolve("special-positions.yml"); var defaults = defaults(); var migrated = SpecialPositionsConfigFile.prepare(file.toFile(), legacy.toFile(), defaults); assertEquals("staff.spy", migrated.getString("espionage.bypass-permission")); assertEquals(-4, migrated.getDouble("espionage.aptitude.attribute-weights.strength")); - assertEquals(333, migrated.getDouble("espionage.appointments.repeat-cost")); + assertEquals(33, migrated.getDouble("espionage.appointments.stability-penalty")); assertEquals("reliable", migrated.getString("espionage.intelligence.minimum-tiers.professional-army")); assertEquals(original, Files.readString(legacy)); // Existing dedicated settings always win over stale legacy values; missing new fields are added. - migrated.set("espionage.appointments.repeat-cost", 250); + migrated.set("espionage.appointments.stability-penalty", 25); migrated.set("espionage.intelligence.minimum-tiers.professional-army", null); migrated.save(file.toFile()); var reloaded = SpecialPositionsConfigFile.prepare(file.toFile(), legacy.toFile(), defaults); - assertEquals(250, reloaded.getDouble("espionage.appointments.repeat-cost")); + assertEquals(25, reloaded.getDouble("espionage.appointments.stability-penalty")); assertEquals(-4, reloaded.getDouble("espionage.aptitude.attribute-weights.strength")); assertEquals("reliable", reloaded.getString("espionage.intelligence.minimum-tiers.professional-army")); assertEquals(original, Files.readString(legacy)); } - @Test void retiredSoloLeaderSettingIsRemovedAndExtraPositionPenaltyAdded() throws Exception { + @Test void retiredSettingsAreRemovedAndTheirReplacementsAdded() 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" + Files.writeString(file, "espionage:\n appointments:\n repeat-cost: 250.0\n stability-penalty: 0.0\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")); + assertFalse(prepared.contains("espionage.appointments.repeat-cost")); + assertEquals(0, prepared.getDouble("espionage.appointments.stability-penalty")); + assertEquals(7, prepared.getDouble("espionage.appointments.build-up-days")); + assertEquals(2, prepared.getDouble("espionage.appointments.change-cooldown-days")); 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); + assertFalse(saved.contains("solo-leader") || saved.contains("one-person") || saved.contains("repeat-cost"), saved); assertTrue(saved.contains("extra-position-penalty"), saved); }