Skip to content

Commit 75b5569

Browse files
XxFran10xXclaude
andcommitted
Make the faction leader the default holder of special positions
The leader now holds any vacant special position by default, whatever the faction's size. The default follows the leadership, while deliberate appointments stay. Dismissing an appointee returns the office to the leader. Aptitude now falls by a configurable share for every office a character holds beyond their first (espionage.aptitude.extra-position-penalty, default 0.25: 100%, 75%, 50%...). This replaces the solo-leader 25% multiplier, whose stale key is removed from existing special-positions.yml. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent c943cb2 commit 75b5569

10 files changed

Lines changed: 231 additions & 56 deletions

File tree

‎src/main/java/net/tfminecraft/simplefactions/espionage/EspionageConfig.java‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public final class EspionageConfig {
1515
private static double stabilityPenalty = 10;
1616
private static double penaltyDays = 7;
1717
private static String reloadPermission = "simplefactions.espionage.reload";
18-
private static double base = 50, soloMultiplier = .25, rollMultiplier = 1.25;
18+
private static double base = 50, extraPositionPenalty = .25, rollMultiplier = 1.25;
1919
private static int center = 6, cap = 16, aptitudeSpread = 20, luckSpread = 75, luckDraws = 3, rosterLimit = 23;
2020
private static final Map<IntelligenceTier, TierSettings> tiers = new java.util.EnumMap<>(IntelligenceTier.class);
2121
private static final Map<String, IntelligenceTier> minimums = new LinkedHashMap<>();
@@ -68,7 +68,7 @@ public static void load(ConfigurationSection config) {
6868
cap = (int) bounded(config, "espionage.aptitude.attribute-cap", 16, 1, 1000);
6969
center = (int) bounded(config, "espionage.aptitude.attribute-center", 6, 0, cap);
7070
aptitudeSpread = (int) bounded(config, "espionage.aptitude.random-spread", 20, 0, 100);
71-
soloMultiplier = bounded(config, "espionage.aptitude.solo-leader-multiplier", .25, 0, 1);
71+
extraPositionPenalty = bounded(config, "espionage.aptitude.extra-position-penalty", .25, 0, 1);
7272
rollMultiplier = bounded(config, "espionage.checks.aptitude-multiplier", 1.25, .01, 10);
7373
luckSpread = (int) bounded(config, "espionage.checks.luck-spread", 75, 0, 1000);
7474
luckDraws = (int) bounded(config, "espionage.checks.luck-draws", 3, 1, 20);
@@ -138,7 +138,7 @@ public static boolean allows(IntelligenceTier tier, String metric) {
138138
public static int center() { return center; }
139139
public static int cap() { return cap; }
140140
public static int aptitudeSpread() { return aptitudeSpread; }
141-
public static double soloMultiplier() { return soloMultiplier; }
141+
public static double extraPositionPenalty() { return extraPositionPenalty; }
142142
public static double rollMultiplier() { return rollMultiplier; }
143143
public static int luckSpread() { return luckSpread; }
144144
public static int luckDraws() { return luckDraws; }

‎src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java‎

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -41,23 +41,28 @@ public static void loadAptitudes(java.nio.file.Path file) throws java.io.IOExcep
4141
}
4242

4343
public static boolean eligible(Faction faction, String playerName) {
44-
if (!faction.isMemberIgnoreCase(playerName) && !faction.isLeader(playerName)) return false;
45-
return !faction.isLeader(playerName) || faction.getMembers().stream()
46-
.allMatch(name -> name.equalsIgnoreCase(playerName));
44+
return faction.isMemberIgnoreCase(playerName) || faction.isLeader(playerName);
4745
}
4846

49-
/** Only new factions pass through addFaction; loading saves never reassigns offices. */
47+
/** The leader's default holding follows the leadership; deliberate appointments stay. */
48+
private static boolean validHolder(Faction faction, SpecialPositionAssignment holder) {
49+
return holder != null && eligible(faction, holder.playerName) && !deadCharacter(holder)
50+
&& !(holder.automatic && !faction.isLeader(holder.playerName));
51+
}
52+
53+
/** New factions and every later vacancy fall to the leader; it waits for their active character. */
5054
public static void initializeFounder(Faction faction) {
5155
initializeFounder(faction, null);
5256
}
5357

5458
private static void initializeFounder(Faction faction, java.util.Set<Faction> dirty) {
55-
if (faction.getEspionage() == null || org.bukkit.Bukkit.getServer() == null) return;
59+
if (faction.getEspionage() == null || faction.getLeader() == null || org.bukkit.Bukkit.getServer() == null) return;
5660
var founder = org.bukkit.Bukkit.getPlayerExact(faction.getLeader());
5761
String characterId = OfficeCharacters.activeCharacterId(founder);
62+
// A dead character would be revoked again on the next check.
63+
if (characterId != null && founder != null && OfficeCharacters.isDead(founder.getUniqueId(), characterId)) characterId = null;
5864
for (SpecialPosition office : SpecialPosition.values()) {
5965
if (faction.getEspionage().holder(office) != null) continue;
60-
if (office == SpecialPosition.SPYMASTER && !eligible(faction, faction.getLeader())) continue;
6166
if (characterId == null) {
6267
faction.getEspionage().pendingFounder(office);
6368
continue;
@@ -124,7 +129,23 @@ static boolean completeAndSaveAppointment(Faction faction, SpecialPositionAssign
124129

125130
public static int effectiveAptitude(Faction faction, SpecialPositionAssignment holder) {
126131
if (holder == null || !eligible(faction, holder.playerName)) return 0;
127-
return faction.isLeader(holder.playerName) ? (int) Math.floor(holder.aptitude * EspionageConfig.soloMultiplier()) : holder.aptitude;
132+
// The epsilon keeps 1 - 0.1 * 3 from flooring a whole result one point low.
133+
return (int) Math.floor(holder.aptitude * positionMultiplier(positionsHeld(faction, holder)) + 1e-9);
134+
}
135+
136+
/** Offices held by the same person; an assignment not saved yet still counts as one. */
137+
public static int positionsHeld(Faction faction, SpecialPositionAssignment holder) {
138+
if (holder == null || holder.playerName == null || faction.getEspionage() == null) return 1;
139+
int held = 0;
140+
for (SpecialPosition office : SpecialPosition.values()) {
141+
var other = faction.getEspionage().holder(office);
142+
if (other != null && holder.playerName.equalsIgnoreCase(other.playerName)) held++;
143+
}
144+
return Math.max(1, held);
145+
}
146+
147+
public static double positionMultiplier(int held) {
148+
return Math.max(0, 1 - EspionageConfig.extraPositionPenalty() * Math.max(0, held - 1));
128149
}
129150

130151
public static boolean isOwn(Player viewer, Faction target) {
@@ -145,8 +166,7 @@ public static boolean canViewExact(Player viewer, Faction target) {
145166
/** A vacant, ineligible or deceased holder leaves every guild's information unguarded. */
146167
public static boolean hasSpymaster(Faction faction) {
147168
if (faction == null || faction.getEspionage() == null) return false;
148-
var holder = faction.getEspionage().getSpymaster();
149-
return holder != null && eligible(faction, holder.playerName) && !deadCharacter(holder);
169+
return validHolder(faction, faction.getEspionage().getSpymaster());
150170
}
151171

152172
public static SpecialPositionAssignment spymaster(Faction faction) {
@@ -159,17 +179,24 @@ private static void saveOrMark(Faction faction, java.util.Set<Faction> dirty) {
159179
}
160180

161181
private static SpecialPositionAssignment spymaster(Faction faction, java.util.Set<Faction> dirty) {
162-
if (faction.getEspionage().hasPendingFounder()) {
163-
initializeFounder(faction, dirty);
164-
if (!faction.getEspionage().hasPendingFounder()) saveOrMark(faction, dirty);
182+
var state = faction.getEspionage();
183+
boolean changed = false;
184+
SpecialPositionAssignment holder = state.getSpymaster();
185+
if (holder != null && !validHolder(faction, holder)) {
186+
state.removeSpymaster();
187+
changed = true;
165188
}
166-
SpecialPositionAssignment holder = faction.getEspionage().getSpymaster();
167-
if (holder != null && (!eligible(faction, holder.playerName) || deadCharacter(holder))) {
168-
faction.getEspionage().removeSpymaster();
169-
saveOrMark(faction, dirty);
170-
return null;
189+
// Whatever emptied the office, it falls back to the current leader.
190+
if (state.getSpymaster() == null && !state.isPendingFounder(SpecialPosition.SPYMASTER) && faction.getLeader() != null) {
191+
state.pendingFounder(SpecialPosition.SPYMASTER);
192+
changed = true;
193+
}
194+
if (state.hasPendingFounder()) {
195+
initializeFounder(faction, dirty);
196+
if (!state.hasPendingFounder()) changed = true;
171197
}
172-
return holder;
198+
if (changed) saveOrMark(faction, dirty);
199+
return state.getSpymaster();
173200
}
174201

175202
private static boolean deadCharacter(SpecialPositionAssignment holder) {
@@ -190,16 +217,15 @@ public static void characterDied(Player owner, String characterId, String charac
190217
state.removeSpymaster();
191218
new Database().saveFaction(faction);
192219
if (owner != null) owner.sendMessage("\u00a78\u00a7oWith the passing of " + characterName
193-
+ ", the keys to the Spymaster's office return to the faction. The office awaits a successor.");
220+
+ ", the keys to the Spymaster's office return to the faction leader until a successor is appointed.");
194221
}
195222
}
196223

197224
public static java.util.List<net.tfminecraft.simplefactions.government.StabilityModifier> stabilityModifiers(Faction faction, long now) {
198225
var result = new java.util.ArrayList<>(faction.getEspionage().unrestModifiers(now));
199226
for (SpecialPosition office : SpecialPosition.values()) {
200227
var holder = faction.getEspionage().holder(office);
201-
boolean occupied = holder != null && (office != SpecialPosition.SPYMASTER
202-
|| eligible(faction, holder.playerName) && !deadCharacter(holder));
228+
boolean occupied = office != SpecialPosition.SPYMASTER ? holder != null : validHolder(faction, holder);
203229
double penalty = EspionageConfig.vacancyPenalty(office);
204230
if (!occupied && penalty > 0) result.add(new net.tfminecraft.simplefactions.government.StabilityModifier(
205231
"Vacant " + office.label(), -penalty, 0));
@@ -216,18 +242,13 @@ public static boolean appoint(Player actor, Faction faction, Player candidate) {
216242
actor.sendMessage("§cChoose an online member of your own faction.");
217243
return false;
218244
}
219-
if (!eligible(faction, candidate.getName())) {
220-
actor.sendMessage("§cThe faction leader cannot be Spymaster unless the faction has only one member.");
221-
return false;
222-
}
223245
String characterId = OfficeCharacters.activeCharacterId(candidate);
224246
if (characterId == null) {
225247
actor.sendMessage("§cThat member needs an active roleplay character.");
226248
return false;
227249
}
228250
SpecialPositionAssignment current = spymaster(faction);
229-
if (current != null && !current.automatic && current.isHolder(candidate.getUniqueId())
230-
&& characterId.equals(current.characterId)) {
251+
if (current != null && current.isHolder(candidate.getUniqueId()) && characterId.equals(current.characterId)) {
231252
actor.sendMessage("§7That member is already your Spymaster.");
232253
return false;
233254
}
@@ -259,8 +280,9 @@ public static boolean appoint(Player actor, Faction faction, Player candidate) {
259280
+ "§8§o's unseen network are now yours. You have been appointed Spymaster.");
260281
candidate.sendMessage("§7Your aptitude for this office is §e" + effectiveAptitude(faction, assignment)
261282
+ "/100§7. Inspect Special Positions in the faction menu, or use §a/faction espionage§7.");
262-
if (faction.isLeader(candidate.getName()))
263-
candidate.sendMessage("§7Leading a one-person faction retains " + Math.round(EspionageConfig.soloMultiplier() * 100) + "% aptitude (base: " + assignment.aptitude + ").");
283+
int held = positionsHeld(faction, assignment);
284+
if (held > 1) candidate.sendMessage("§7Holding " + held + " offices leaves you " + Math.round(positionMultiplier(held) * 100)
285+
+ "% of your aptitude in each (base: " + assignment.aptitude + ").");
264286
if (!actor.getUniqueId().equals(candidate.getUniqueId())) actor.sendMessage("§aSpymaster appointed.");
265287
if (repeat) actor.sendMessage("\u00a77Appointment: " + cost + "d from the treasury. The change of office brings "
266288
+ EspionageConfig.stabilityPenalty() + " points of unrest, fading over " + EspionageConfig.penaltyDays() + " days.");
@@ -278,14 +300,20 @@ public static boolean remove(Player actor, Faction faction) {
278300
actor.sendMessage("§cOnly your faction leader can remove the Spymaster.");
279301
return false;
280302
}
303+
var current = spymaster(faction);
304+
if (current == null || current.automatic) {
305+
actor.sendMessage("§7No Spymaster has been appointed. The office already rests with you as faction leader.");
306+
return false;
307+
}
281308
var previous = faction.getEspionage().snapshotOffices();
282309
faction.getEspionage().removeSpymaster();
283310
if (!new Database().saveFactionChecked(faction)) {
284311
faction.getEspionage().restoreOffices(previous);
285312
actor.sendMessage("\u00a7cThe office removal could not be saved. The Spymaster remains appointed.");
286313
return false;
287314
}
288-
actor.sendMessage("§aThe Spymaster office is now vacant.");
315+
actor.sendMessage("§aThe Spymaster has been dismissed. The office returns to you as faction leader.");
316+
spymaster(faction);
289317
return true;
290318
}
291319

‎src/main/java/net/tfminecraft/simplefactions/espionage/SpecialPositionsConfigFile.java‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
/** Migrates the old espionage section once; existing dedicated settings always win. */
1414
public final class SpecialPositionsConfigFile {
15+
// Settings whose rule no longer exists; their comments would mislead staff.
16+
private static final java.util.List<String> RETIRED_KEYS = java.util.List.of("espionage.aptitude.solo-leader-multiplier");
1517
private SpecialPositionsConfigFile() {}
1618

1719
public static void load(JavaPlugin plugin) {
@@ -54,6 +56,12 @@ else if (legacy.exists()) {
5456
}
5557
boolean changed = !file.exists();
5658
if (changed) settings.options().setHeader(defaults.options().getHeader());
59+
for (String retired : RETIRED_KEYS) {
60+
if (settings.contains(retired)) {
61+
settings.set(retired, null);
62+
changed = true;
63+
}
64+
}
5765
for (var entry : defaults.getValues(true).entrySet()) {
5866
if (!(entry.getValue() instanceof org.bukkit.configuration.ConfigurationSection) && !settings.contains(entry.getKey())) {
5967
settings.set(entry.getKey(), entry.getValue());

‎src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,10 +259,14 @@ public static void spymasterOffice(Player viewer, Faction faction, InventoryMana
259259
Inventory inventory = Bukkit.createInventory(new SFInventoryHolder(faction.getId(), SFGUI.SPYMASTER_VIEW),
260260
27, "\u00a77Spymaster's Office");
261261
SpecialPositionAssignment holder = EspionageService.spymaster(faction);
262+
int held = EspionageService.positionsHeld(faction, holder);
262263
ItemStack head = item(Material.PLAYER_HEAD, "Spymaster: " + (holder == null ? "Vacant" : CharacterNames.display(viewer, holder.playerName)),
263264
"§7Aptitude: §e" + EspionageService.effectiveAptitude(faction, holder) + "/100",
264-
holder != null && faction.isLeader(holder.playerName) ? "§7Solo leader: " + Math.round(EspionageConfig.soloMultiplier() * 100)
265-
+ "% aptitude retained (base " + holder.aptitude + ")." : "§7Faction leaders are ineligible while other members belong.",
265+
holder == null ? "§7Falls to the faction leader once they have an active character."
266+
: held > 1 ? "§7Holds " + held + " offices: " + Math.round(EspionageService.positionMultiplier(held) * 100)
267+
+ "% aptitude in each (base " + holder.aptitude + ")."
268+
: holder.automatic ? "§7Held by the faction leader until a member is appointed."
269+
: "§7Appointed by the faction leader.",
266270
"§7Gathers foreign intelligence and guards your secrets.",
267271
holder == null ? "\u00a7cWithout a Spymaster, all faction and guild information is public."
268272
: "\u00a77An eligible Spymaster protects your faction and guild information.",
@@ -283,7 +287,7 @@ public static void spymasterOffice(Player viewer, Faction faction, InventoryMana
283287
? "\u00a77The founder's assignment does not use your free appointment."
284288
: "\u00a77Unrest: -" + EspionageConfig.stabilityPenalty() + " points, fading over " + EspionageConfig.penaltyDays() + " days."));
285289
inventory.setItem(15, item(Material.REDSTONE, "Remove Spymaster",
286-
"\u00a7cMakes all faction and guild information public."));
290+
"\u00a77Dismisses the appointee. The office returns to you."));
287291
}
288292
inventory.setItem(26, manager.createBackButton(SFGUI.SPYMASTER_VIEW));
289293
viewer.openInventory(inventory);

‎src/main/resources/special-positions.yml‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ espionage:
2121
attribute-center: 6
2222
attribute-cap: 16
2323
random-spread: 20
24-
# A leader can only hold this office in a one-person faction; they retain 25% aptitude.
25-
solo-leader-multiplier: 0.25
24+
# The faction leader holds every vacant office by default. Each office a character holds
25+
# beyond their first costs this fraction of their aptitude in all of them:
26+
# 1 office 100%, 2 offices 75%, 3 offices 50%... Never below 0%. Zero disables this penalty.
27+
extra-position-penalty: 0.25
2628
attribute-weights:
2729
intelligence: 3.0
2830
wisdom: 2.5

0 commit comments

Comments
 (0)