Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Double> 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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<String, Double> weights() { return weights; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ private static int centered(Map<String, Integer> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,41 +96,40 @@ private static void initializeFounder(Faction faction, java.util.Set<Faction> 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. */
Expand All @@ -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()));
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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();
Expand All @@ -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) {
Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public class EspionageState {
private java.util.List<OfficeUnrest> unrest = new java.util.ArrayList<>();
private java.util.Set<SpecialPosition> pendingFounders = java.util.EnumSet.noneOf(SpecialPosition.class);
private Map<SpecialPosition, String> pendingFounderCharacters = new EnumMap<>(SpecialPosition.class);
// Last deliberate appointment per office; starts the wait before the next one.
private Map<SpecialPosition, Long> lastAppointments = new EnumMap<>(SpecialPosition.class);

void pendingFounder(SpecialPosition office) { pendingFounders.add(office); }
void pendingFounder(SpecialPosition office, String characterId) {
Expand All @@ -28,15 +30,17 @@ void pendingFounder(SpecialPosition office, String characterId) {

record OfficeSnapshot(Map<SpecialPosition, SpecialPositionAssignment> positions,
Map<SpecialPosition, Integer> appointments, java.util.List<OfficeUnrest> unrest,
java.util.Set<SpecialPosition> pendingFounders, Map<SpecialPosition, String> pendingFounderCharacters) {}
java.util.Set<SpecialPosition> pendingFounders, Map<SpecialPosition, String> pendingFounderCharacters,
Map<SpecialPosition, Long> lastAppointments) {}

OfficeSnapshot snapshotOffices() {
var savedPositions = new EnumMap<SpecialPosition, SpecialPositionAssignment>(SpecialPosition.class);
savedPositions.putAll(positions);
var savedAppointments = new EnumMap<SpecialPosition, Integer>(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) {
Expand All @@ -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); }
Expand Down Expand Up @@ -105,7 +110,15 @@ void appoint(SpecialPositionAssignment assignment, Supplier<Integer> 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);
Expand All @@ -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<String, Integer> legacyAptitudes() { return Map.copyOf(appointmentAptitudes); }

public IntelligenceReport cachedReport(String targetId, long foundedAt, long day) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> RETIRED_KEYS = java.util.List.of("espionage.aptitude.solo-leader-multiplier");
private static final java.util.List<String> RETIRED_KEYS = java.util.List.of(
"espionage.aptitude.solo-leader-multiplier", "espionage.appointments.repeat-cost");
private SpecialPositionsConfigFile() {}

public static void load(JavaPlugin plugin) {
Expand Down
Loading
Loading