Skip to content

Commit 896c8b1

Browse files
authored
Add configurable faction espionage and special positions (#102)
* Add faction and guild espionage with configurable special offices * Keep reported ledger title readable and document migrated config * Address espionage review findings and cover migration and completion edges * Complete invitations using visible character names * Keep rumours as report minimum and restore masked menu navigation * Label missing reports as botched and clean coloured menu titles * Preserve office state when persistence fails * Simplify faction rosters and apply unrest for vacant offices * Omit inconclusive cashflow rows from ledger summaries * Address espionage review privacy, optional plugins and persistence * Bind pending founder offices to their character identity * Persist changed pending founder identities on office lookups * Roll back failed pending founder identity saves and document name fallback --------- Co-authored-by: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com>
1 parent e092594 commit 896c8b1

77 files changed

Lines changed: 5548 additions & 129 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎ESPIONAGE.md‎

Lines changed: 310 additions & 0 deletions
Large diffs are not rendered by default.

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Players shape a world of provinces and titles, organise settlements and guilds,
1313
- **National economies** — manage wealth and taxation, including income moving through relationships between members, nations, and overlords.
1414
- **Settlements and guilds** — establish named cities and capitals and organise groups within a nation.
1515
- **Military infrastructure** — construct forts, ports, airports, and train stations with `/faction construct <fort|port|airport|train_station> <name>`.
16+
- **Espionage** — appoint a Spymaster, protect faction secrets, and obtain shared daily intelligence estimates. See [the development espionage guide](ESPIONAGE.md).
1617
- **Campaign warfare** — pursue war goals through scheduled battles, player voting, warband participation, and campaign progression.
1718

1819
## A shared political world

‎src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ public class SimpleFactions extends JavaPlugin{
238238
private final VehicleReclaimFeeListener vehicleReclaimFeeListener =
239239
new VehicleReclaimFeeListener(vehicleFeeStore, vehicleFeeConfirmations, this::saveVehicleFees);
240240
private boolean registrationFeeRegistered;
241+
private boolean officeCharacterDeathRegistered;
241242
private boolean vehicleIntegrationRegistered = false;
242243
private boolean constructionFreezeRegistered;
243244
private final PlayerEconomyManager playerEconomyManager = new PlayerEconomyManager();
@@ -279,6 +280,14 @@ public void onEnable() {
279280
}
280281
net.tfminecraft.simplefactions.inactivity.InactivityService.load();
281282
db.loadFactions();
283+
try {
284+
net.tfminecraft.simplefactions.espionage.EspionageService.loadAptitudes(
285+
getDataFolder().toPath().resolve("Cache/character-aptitudes.json"));
286+
} catch (Exception error) {
287+
getLogger().log(java.util.logging.Level.SEVERE, "Cannot load permanent character aptitudes; disabling SimpleFactions", error);
288+
getServer().getPluginManager().disablePlugin(this);
289+
return;
290+
}
282291
getCommand(commands.cmd1).setExecutor(commands);
283292
getCommand(commands.cmd2).setExecutor(commands);
284293
getCommand(commands.cmd1).setTabCompleter(new TabCompletion());
@@ -372,10 +381,7 @@ public void onDisable() {
372381
net.tfminecraft.simplefactions.war.battle.persistence.BattlePersistenceService.saveAll();
373382
sessionManager.end();
374383
net.tfminecraft.simplefactions.inactivity.InactivityService.save();
375-
db.saveTimer(FactionManager.getTimer(), FactionManager.getDay());
376-
for(Faction f : FactionManager.factions) {
377-
db.saveFaction(f);
378-
}
384+
saveLoadedFactions();
379385
for(War w : WarManager.get()){
380386
db.saveWar(w);
381387
}
@@ -386,7 +392,15 @@ public void onDisable() {
386392
vehicleMaintenancePersistence.save();
387393
}
388394
}
395+
void saveLoadedFactions() {
396+
// An early enable failure must not overwrite partially restored faction state.
397+
if (!FactionManager.isLoaded()) return;
398+
db.saveTimer(FactionManager.getTimer(), FactionManager.getDay());
399+
for (Faction faction : FactionManager.factions) db.saveFaction(faction);
400+
}
401+
389402
public void loadConfigs() {
403+
net.tfminecraft.simplefactions.espionage.SpecialPositionsConfigFile.load(this);
390404
configLoader.loadConfig(new File(getDataFolder(), "config.yml"));
391405
configLoader.loadWar(new File(getDataFolder(), "war.yml"));
392406
VehiclesConfigLoader.load(new File(getDataFolder(), "vehicles.yml"));
@@ -678,6 +692,10 @@ private void registerRpCharactersIntegration() {
678692
net.tfminecraft.simplefactions.prestige.MemberPlaytime.setProbe(
679693
new net.tfminecraft.simplefactions.prestige.RpCharactersPlaytimeProbe());
680694
net.tfminecraft.simplefactions.integration.rpcharacters.chat.RpCharactersChatIntegration.register();
695+
if (!officeCharacterDeathRegistered) {
696+
getServer().getPluginManager().registerEvents(new net.tfminecraft.simplefactions.espionage.OfficeCharacterDeathListener(), this);
697+
officeCharacterDeathRegistered = true;
698+
}
681699
}
682700

683701
private void registerVehicleIntegrationHooks() {

‎src/main/java/net/tfminecraft/simplefactions/database/Database.java‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ public void loadFactions() {
184184
}
185185
f.setFoundedAt(data.foundedAt != null ? data.foundedAt : System.currentTimeMillis()/1000L);
186186
f.setCapitalMoves(data.capitalMoves != null ? data.capitalMoves : 0);
187+
f.setEspionage(data.espionage);
187188

188189
if (data.settlements != null) {
189190
f.getSettlementHandler().load(data.settlements);
@@ -312,6 +313,11 @@ public void loadFactions() {
312313
* ===================================================== */
313314

314315
public void saveFaction(Faction f) {
316+
saveFactionChecked(f);
317+
}
318+
319+
/** Office mutations must not acknowledge success unless the faction save completed. */
320+
public boolean saveFactionChecked(Faction f) {
315321
try {
316322
File file = new File("plugins/SimpleFactions/Data", f.getId() + ".json");
317323

@@ -389,6 +395,7 @@ public void saveFaction(Faction f) {
389395
data.rank = f.getRank() != null ? f.getRank().getId() : null;
390396
data.foundedAt = f.getFoundedAt();
391397
data.capitalMoves = f.getCapitalMoves();
398+
data.espionage = f.getEspionage();
392399

393400
// --- Guild ---
394401
for (Guild g : f.getGuildHandler().getGuilds()) {
@@ -514,10 +521,12 @@ public void saveFaction(Faction f) {
514521
data.warReparationsObligations.add(obligationData);
515522
}
516523

517-
JsonUtil.writeJson(file, data);
524+
JsonUtil.writeJsonAtomic(file, data);
525+
return true;
518526

519527
} catch (Exception e) {
520528
e.printStackTrace();
529+
return false;
521530
}
522531
}
523532

‎src/main/java/net/tfminecraft/simplefactions/database/FactionData.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package net.tfminecraft.simplefactions.database;
22

3+
import net.tfminecraft.simplefactions.espionage.EspionageState;
34
import java.util.ArrayList;
45
import java.util.List;
56
import java.util.Map;
@@ -8,6 +9,7 @@
89
import com.google.gson.annotations.SerializedName;
910

1011
public class FactionData {
12+
public EspionageState espionage;
1113
public String id;
1214
public String name;
1315
public String rgb;

‎src/main/java/net/tfminecraft/simplefactions/database/JsonUtil.java‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,25 @@ public static void writeJson(File file, Object data) throws IOException {
2222
GSON.toJson(data, w);
2323
}
2424
}
25+
26+
/** Stage a complete save beside the destination so failed writes keep the previous data. */
27+
public static void writeJsonAtomic(File file, Object data) throws IOException {
28+
var destination = file.toPath().toAbsolutePath();
29+
var pending = java.nio.file.Files.createFile(destination.resolveSibling(file.getName() + "." + java.util.UUID.randomUUID() + ".tmp"));
30+
try {
31+
if (java.nio.file.Files.exists(destination)
32+
&& java.nio.file.Files.getFileAttributeView(destination, java.nio.file.attribute.PosixFileAttributeView.class) != null) {
33+
java.nio.file.Files.setPosixFilePermissions(pending, java.nio.file.Files.getPosixFilePermissions(destination));
34+
}
35+
java.nio.file.Files.writeString(pending, GSON.toJson(data));
36+
try {
37+
java.nio.file.Files.move(pending, destination, java.nio.file.StandardCopyOption.REPLACE_EXISTING,
38+
java.nio.file.StandardCopyOption.ATOMIC_MOVE);
39+
} catch (java.nio.file.AtomicMoveNotSupportedException ignored) {
40+
java.nio.file.Files.move(pending, destination, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
41+
}
42+
} finally {
43+
java.nio.file.Files.deleteIfExists(pending);
44+
}
45+
}
2546
}

‎src/main/java/net/tfminecraft/simplefactions/enums/SFGUI.java‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ public enum SFGUI {
99
FACTION_VIEW,
1010
FACTION_LIST,
1111
FACTION_GUILDS,
12+
SPECIAL_POSITIONS,
13+
SPYMASTER_VIEW,
14+
SPYMASTER_SETTINGS,
15+
SPYMASTER_SELECT,
1216
TIER_VIEW,
1317
TITLE_VIEW,
1418
TITLE_TYPE_VIEW,
@@ -46,6 +50,8 @@ public enum SFGUI {
4650
FEE_CATEGORY_VIEW,
4751
FEE_VEHICLE_VIEW,
4852
LEDGER_VIEW,
53+
FOREIGN_LEDGER_VIEW,
54+
FOREIGN_POSITIONS_VIEW,
4955
COUNCIL_VIEW,
5056
COUNCIL_SELECT,
5157
ELECTION_VIEW,
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package net.tfminecraft.simplefactions.espionage;
2+
3+
import java.io.IOException;
4+
import java.nio.file.Files;
5+
import java.nio.file.Path;
6+
import java.nio.file.StandardCopyOption;
7+
import java.util.LinkedHashMap;
8+
import java.util.Map;
9+
import java.util.function.Supplier;
10+
import com.google.gson.reflect.TypeToken;
11+
import net.tfminecraft.simplefactions.database.JsonUtil;
12+
13+
/** Character identity, rather than faction membership, owns the permanent roll. */
14+
public final class CharacterAptitudes {
15+
private final Path file;
16+
private final Map<String, Integer> aptitudes = new LinkedHashMap<>();
17+
18+
public CharacterAptitudes(Path file) { this.file = file; }
19+
20+
public void load() throws IOException {
21+
aptitudes.clear();
22+
if (!Files.exists(file)) return;
23+
Map<String, Integer> loaded = JsonUtil.GSON.fromJson(Files.readString(file),
24+
new TypeToken<Map<String, Integer>>() {}.getType());
25+
if (loaded == null) throw new IOException("Empty character aptitude registry: " + file);
26+
loaded.forEach((key, value) -> {
27+
if (key != null && value != null) aptitudes.put(key, EspionageMath.clamp(value, 0, 100));
28+
});
29+
}
30+
31+
public void remember(String characterId, int aptitude) {
32+
if (characterId != null && !characterId.isBlank())
33+
aptitudes.putIfAbsent(characterId, EspionageMath.clamp(aptitude, 0, 100));
34+
}
35+
36+
public int aptitude(String characterId, Supplier<Integer> roll) throws IOException {
37+
if (characterId == null || characterId.isBlank()) throw new IllegalArgumentException("Missing character identity");
38+
Integer existing = aptitudes.get(characterId);
39+
if (existing != null) return existing;
40+
int result = EspionageMath.clamp(roll.get(), 0, 100);
41+
aptitudes.put(characterId, result);
42+
try { save(); }
43+
catch (IOException error) { aptitudes.remove(characterId); throw error; }
44+
return result;
45+
}
46+
47+
public void save() throws IOException {
48+
Files.createDirectories(file.getParent());
49+
Path pending = file.resolveSibling(file.getFileName() + ".tmp");
50+
Files.writeString(pending, JsonUtil.GSON.toJson(aptitudes));
51+
try { Files.move(pending, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); }
52+
catch (java.nio.file.AtomicMoveNotSupportedException ignored) {
53+
Files.move(pending, file, StandardCopyOption.REPLACE_EXISTING);
54+
}
55+
}
56+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package net.tfminecraft.simplefactions.espionage;
2+
3+
import java.nio.file.Files;
4+
import java.nio.file.Path;
5+
import java.util.Map;
6+
import org.bukkit.Bukkit;
7+
import net.tfminecraft.rpcharacters.managers.PlayerManager;
8+
import net.tfminecraft.simplefactions.database.JsonUtil;
9+
import com.google.gson.JsonObject;
10+
11+
/** Offline names are read without loading or modifying RPCharacters player state. */
12+
public final class CharacterNames {
13+
private record Cached(String name, long expires) {}
14+
private static final Map<String, Cached> cache = new java.util.concurrent.ConcurrentHashMap<>();
15+
private CharacterNames() {}
16+
17+
public static String display(org.bukkit.entity.Player viewer, String account) {
18+
String character = of(account);
19+
return EspionageService.bypasses(viewer) ? character + " \u00a77(" + account + ")" : character;
20+
}
21+
22+
public static String display(org.bukkit.entity.Player viewer, String account,
23+
net.tfminecraft.simplefactions.objects.Faction faction) {
24+
if (!OfficeCharacters.enabled("RPCharacters") || EspionageService.canViewExact(viewer, faction))
25+
return display(viewer, account);
26+
return forForeign(account);
27+
}
28+
29+
/** A missing active character must not expose account identity in foreign reports. */
30+
public static String forForeign(String account) {
31+
String name = activeName(account);
32+
return name == null ? "Unknown" : name;
33+
}
34+
35+
/** Exact account names take precedence; ambiguous character names never pick a player arbitrarily. */
36+
public static org.bukkit.entity.Player resolveOnline(org.bukkit.entity.Player sender, String input) {
37+
String query = input.strip();
38+
if (query.length() >= 2 && query.startsWith("\"") && query.endsWith("\""))
39+
query = query.substring(1, query.length() - 1).strip();
40+
var result = resolve(query, Bukkit.getOnlinePlayers(), player -> of(player.getName()));
41+
if (result.ambiguous()) sender.sendMessage("\u00a7cSeveral characters share that name. Use their Minecraft name.");
42+
else if (result.player() == null) sender.sendMessage("\u00a7cNo online player found by that character or Minecraft name.");
43+
return result.player();
44+
}
45+
46+
static Resolution resolve(String query, java.util.Collection<? extends org.bukkit.entity.Player> players,
47+
java.util.function.Function<org.bukkit.entity.Player, String> names) {
48+
for (var player : players) if (player.getName().equalsIgnoreCase(query)) return new Resolution(player, false);
49+
var matches = players.stream().filter(player -> org.bukkit.ChatColor.stripColor(names.apply(player))
50+
.equalsIgnoreCase(query)).toList();
51+
return new Resolution(matches.size() == 1 ? matches.getFirst() : null, matches.size() > 1);
52+
}
53+
54+
record Resolution(org.bukkit.entity.Player player, boolean ambiguous) {}
55+
56+
/** Bukkit replaces only the current argument, so return the untyped suffix of a full name. */
57+
public static java.util.List<String> inviteCompletions(java.util.Collection<String> names, String[] args) {
58+
if (args.length < 2) return java.util.List.of();
59+
int skip = args.length - 2;
60+
String prefix = String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length));
61+
return names.stream().filter(java.util.Objects::nonNull).map(org.bukkit.ChatColor::stripColor).distinct()
62+
.filter(name -> name.regionMatches(true, 0, prefix, 0, prefix.length()))
63+
.map(name -> name.split(" "))
64+
.filter(words -> words.length > skip)
65+
.map(words -> String.join(" ", java.util.Arrays.copyOfRange(words, skip, words.length)))
66+
.toList();
67+
}
68+
69+
public static String of(String playerName) {
70+
String name = activeName(playerName);
71+
return name == null ? playerName : name;
72+
}
73+
74+
private static String activeName(String playerName) {
75+
if (playerName == null) return "Unknown";
76+
if (!OfficeCharacters.enabled("RPCharacters")) return playerName;
77+
var player = Bukkit.getPlayerExact(playerName);
78+
if (player != null) {
79+
var data = PlayerManager.get(player);
80+
var character = data == null ? null : data.getActiveCharacter();
81+
if (character != null) return character.getName();
82+
}
83+
Cached cached = cache.get(playerName);
84+
if (cached != null && cached.expires > System.currentTimeMillis()) return cached.name;
85+
String result = null;
86+
var plugin = Bukkit.getPluginManager().getPlugin("RPCharacters");
87+
Path folder = plugin.getDataFolder().toPath().resolve("data/characterdata")
88+
.resolve(Bukkit.getOfflinePlayer(playerName).getUniqueId().toString());
89+
if (Files.isDirectory(folder)) {
90+
try (var files = Files.list(folder)) {
91+
for (Path file : files.filter(path -> path.toString().endsWith(".json")).sorted().toList()) {
92+
JsonObject json = JsonUtil.GSON.fromJson(Files.readString(file), JsonObject.class);
93+
if (json != null && json.has("active") && json.get("active").getAsBoolean() && json.has("name")) {
94+
result = json.get("name").getAsString();
95+
break;
96+
}
97+
}
98+
} catch (java.io.IOException | com.google.gson.JsonParseException | IllegalStateException ignored) {
99+
// Cache the absence too; account-oriented callers supply their own fallback.
100+
}
101+
}
102+
cache.put(playerName, new Cached(result, System.currentTimeMillis() + 30_000));
103+
return result;
104+
}
105+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package net.tfminecraft.simplefactions.espionage;
2+
3+
import java.util.function.Supplier;
4+
import org.bukkit.entity.Player;
5+
6+
/** Only a command creating a faction/guild menu can request new daily intelligence. */
7+
public final class CommandIntelligence {
8+
private static final ThreadLocal<Context> current = new ThreadLocal<>();
9+
private static final class Context {
10+
final Player player;
11+
boolean gathered;
12+
Context(Player player) { this.player = player; }
13+
}
14+
private CommandIntelligence() {}
15+
16+
public static boolean execute(Player player, Supplier<Boolean> command) {
17+
Context previous = current.get();
18+
current.set(new Context(player));
19+
try { return command.get(); }
20+
finally {
21+
if (previous == null) current.remove();
22+
else current.set(previous);
23+
}
24+
}
25+
26+
public static void beforeMenu() {
27+
Context context = current.get();
28+
if (context != null && context.player != null && !context.gathered) {
29+
context.gathered = true;
30+
EspionageService.refreshReports(context.player);
31+
}
32+
}
33+
}

0 commit comments

Comments
 (0)