From 41c0ebd99309437d9f8b70b3d571d8a2c76d28ae Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:46:11 +0000 Subject: [PATCH 1/4] Export the realm leader's character name for the web map nation.json only carried the leader's Minecraft username, so the web map named rulers by their account. Each faction now remembers its leader's active RPCharacters character and exports it as `leader character`, alongside `leader character of` (the player it was read from). RPCharacters only knows an active character while its player is online, so the name is read whenever the faction saves with the leader online, and five seconds after a leader joins. Otherwise the remembered name is kept, but only for the same leader: a new leader never inherits the previous one's character. Colour codes are stripped. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../simplefactions/SimpleFactions.java | 5 ++ .../simplefactions/database/Database.java | 7 ++ .../simplefactions/database/FactionData.java | 10 +++ .../identity/LeaderCharacterListener.java | 43 +++++++++++ .../identity/LeaderCharacters.java | 63 ++++++++++++++++ .../RpCharactersLeaderCharacterProbe.java | 26 +++++++ .../simplefactions/objects/Faction.java | 13 ++++ .../identity/LeaderCharactersTest.java | 72 +++++++++++++++++++ 8 files changed, 239 insertions(+) create mode 100644 src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacters.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbe.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/identity/LeaderCharactersTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index 25a33d39..68af106e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -374,6 +374,7 @@ public void onDisable() { net.tfminecraft.simplefactions.integration.rpcharacters.chat.RpCharactersChatIntegration.unregister(); MercenaryStatService.clearAll(); net.tfminecraft.simplefactions.mercenary.company.MercenaryEligibility.reset(); + net.tfminecraft.simplefactions.identity.LeaderCharacters.reset(); vehicleMaintenanceDecayTask.stop(); CampaignViewRefreshService.stop(); BattleManager.shutdown(); @@ -430,6 +431,8 @@ public void registerListeners() { getServer().getPluginManager().registerEvents(bankManager, this); getServer().getPluginManager().registerEvents(titleManager, this); getServer().getPluginManager().registerEvents(playerManager, this); + getServer().getPluginManager().registerEvents( + new net.tfminecraft.simplefactions.identity.LeaderCharacterListener(this), this); getServer().getPluginManager().registerEvents(sessionManager, this); getServer().getPluginManager().registerEvents(relocationPrompt, this); getServer().getPluginManager().registerEvents(capitalMovePrompt, this); @@ -691,6 +694,8 @@ private void registerRpCharactersIntegration() { new net.tfminecraft.simplefactions.mercenary.company.RpCharactersMercenaryTraitProbe()); net.tfminecraft.simplefactions.prestige.MemberPlaytime.setProbe( new net.tfminecraft.simplefactions.prestige.RpCharactersPlaytimeProbe()); + net.tfminecraft.simplefactions.identity.LeaderCharacters.setProbe( + new net.tfminecraft.simplefactions.identity.RpCharactersLeaderCharacterProbe()); net.tfminecraft.simplefactions.integration.rpcharacters.chat.RpCharactersChatIntegration.register(); if (!officeCharacterDeathRegistered) { getServer().getPluginManager().registerEvents(new net.tfminecraft.simplefactions.espionage.OfficeCharacterDeathListener(), this); diff --git a/src/main/java/net/tfminecraft/simplefactions/database/Database.java b/src/main/java/net/tfminecraft/simplefactions/database/Database.java index f623f8d5..c529840f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/Database.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/Database.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.database; +import net.tfminecraft.simplefactions.identity.LeaderCharacters; import net.tfminecraft.simplefactions.war.battle.engine.core.Battle; import java.io.File; import java.util.ArrayList; @@ -185,6 +186,7 @@ public void loadFactions() { f.setFoundedAt(data.foundedAt != null ? data.foundedAt : System.currentTimeMillis()/1000L); f.setCapitalMoves(data.capitalMoves != null ? data.capitalMoves : 0); f.setEspionage(data.espionage); + f.rememberLeaderCharacter(data.leaderCharacter, data.leaderCharacterOf); if (data.settlements != null) { f.getSettlementHandler().load(data.settlements); @@ -326,6 +328,11 @@ public boolean saveFactionChecked(Faction f) { data.name = f.getName(); data.rgb = f.getRGB(); data.leader = f.getLeader(); + LeaderCharacters.Remembered leaderCharacter = LeaderCharacters.resolve( + f.getLeader(), f.getLeaderCharacter(), f.getLeaderCharacterOf()); + f.rememberLeaderCharacter(leaderCharacter.name(), leaderCharacter.player()); + data.leaderCharacter = leaderCharacter.name(); + data.leaderCharacterOf = leaderCharacter.player(); data.rulerTitle = f.getRulerTitle(); data.government = f.getGovernmentString(); data.culture = f.getCulture(); diff --git a/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java b/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java index e87fa243..2f05819f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java @@ -15,6 +15,16 @@ public class FactionData { public String rgb; public String leader; + /** + * The leader's roleplay character name, for the web map, and the player + * it was read from. See {@code LeaderCharacters}. + */ + @SerializedName("leader character") + public String leaderCharacter; + + @SerializedName("leader character of") + public String leaderCharacterOf; + @SerializedName("ruler title") public String rulerTitle; diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java new file mode 100644 index 00000000..150bc0f3 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java @@ -0,0 +1,43 @@ +package net.tfminecraft.simplefactions.identity; + +import org.bukkit.Bukkit; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.plugin.Plugin; + +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; + +/** + * Notes a realm leader's character shortly after they join, so the map learns + * it even when the leader is never online at the moment the map exports. The + * delay gives RPCharacters time to load the player's characters. The name is + * kept on the faction and written out with its next save. + */ +public final class LeaderCharacterListener implements Listener { + /** Five seconds: RPCharacters loads player data asynchronously on join. */ + private static final long DELAY_TICKS = 100L; + + private final Plugin plugin; + + public LeaderCharacterListener(Plugin plugin) { + this.plugin = plugin; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onJoin(PlayerJoinEvent event) { + String player = event.getPlayer().getName(); + Bukkit.getScheduler().runTaskLater(plugin, () -> rememberFor(player), DELAY_TICKS); + } + + static void rememberFor(String player) { + for (Faction faction : FactionManager.factions) { + if (faction == null || !player.equalsIgnoreCase(faction.getLeader())) continue; + LeaderCharacters.Remembered remembered = LeaderCharacters.resolve( + faction.getLeader(), faction.getLeaderCharacter(), faction.getLeaderCharacterOf()); + faction.rememberLeaderCharacter(remembered.name(), remembered.player()); + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacters.java b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacters.java new file mode 100644 index 00000000..8eabe03f --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacters.java @@ -0,0 +1,63 @@ +package net.tfminecraft.simplefactions.identity; + +/** + * The roleplay name of a realm's leader, for the web map. + * + * A faction stores its leader as a Minecraft username, which is not what the + * map should show: players are known by their character there. RPCharacters + * only knows a player's active character while they are online, so the name + * is read whenever the leader is seen online and remembered with the player it + * belongs to. A remembered name is dropped as soon as the leader changes, so a + * new leader never inherits the old one's character. + * + * Production swaps in {@link RpCharactersLeaderCharacterProbe} when that + * plugin is present; without it nothing is ever known and the map shows no + * ruler name. + */ +public final class LeaderCharacters { + + /** Active character name of an online player, or null if unknown. */ + public interface Probe { + String activeCharacterName(String player); + } + + /** A remembered character name and the player it belongs to. */ + public record Remembered(String name, String player) { + public static final Remembered NONE = new Remembered(null, null); + } + + private static volatile Probe probe = player -> null; + + private LeaderCharacters() {} + + public static void setProbe(Probe next) { + probe = next == null ? player -> null : next; + } + + public static void reset() { + probe = player -> null; + } + + /** + * What to remember for `leader` now: their active character if they are + * online with one, else what was remembered for this same leader, else + * nothing. + */ + public static Remembered resolve(String leader, String rememberedName, String rememberedFor) { + if (leader == null || leader.isBlank()) return Remembered.NONE; + String active = clean(probe.activeCharacterName(leader)); + if (active != null) return new Remembered(active, leader); + if (rememberedName != null && leader.equalsIgnoreCase(rememberedFor)) { + String kept = clean(rememberedName); + if (kept != null) return new Remembered(kept, leader); + } + return Remembered.NONE; + } + + /** Strips Minecraft colour codes and blank names. */ + static String clean(String name) { + if (name == null) return null; + String stripped = name.replaceAll("(?i)§[0-9A-FK-ORX]", "").replace("§", "").trim(); + return stripped.isEmpty() ? null : stripped; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbe.java b/src/main/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbe.java new file mode 100644 index 00000000..7d0de1c9 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbe.java @@ -0,0 +1,26 @@ +package net.tfminecraft.simplefactions.identity; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.rpcharacters.objects.PlayerData; +import net.tfminecraft.rpcharacters.objects.RPCharacter; + +/** Reads an online player's active RPCharacters character name. */ +public final class RpCharactersLeaderCharacterProbe implements LeaderCharacters.Probe { + @Override + public String activeCharacterName(String player) { + if (Bukkit.getServer() == null + || Bukkit.getPluginManager() == null + || !Bukkit.getPluginManager().isPluginEnabled("RPCharacters")) { + return null; + } + Player online = Bukkit.getPlayerExact(player); + if (online == null) return null; + PlayerData data = PlayerManager.get(online); + if (data == null || !data.hasActiveCharacter()) return null; + RPCharacter character = data.getActiveCharacter(); + return character == null ? null : character.getName(); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java index feb53c51..851e3271 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java @@ -107,6 +107,9 @@ public void setEspionage(EspionageState espionage) { private Double prestige; private String rulerTitle; private String leader; + /** Last known roleplay name of {@link #leader}; see LeaderCharacters. */ + private String leaderCharacter; + private String leaderCharacterOf; private Integer extraNodeCapacity; private List prestigeModifiers = new ArrayList<>(); // Epoch seconds. Faction ids come from the name, so a recycled name reuses the id; @@ -772,6 +775,16 @@ public void setBank(Bank bank) { public String getLeader() { return leader; } + public String getLeaderCharacter() { + return leaderCharacter; + } + public String getLeaderCharacterOf() { + return leaderCharacterOf; + } + public void rememberLeaderCharacter(String name, String player) { + this.leaderCharacter = name; + this.leaderCharacterOf = player; + } public void setLeader(String leader) { getOrCreateMainGuild().setLeader(leader); this.leader = leader; diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/LeaderCharactersTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/LeaderCharactersTest.java new file mode 100644 index 00000000..a36f4373 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/identity/LeaderCharactersTest.java @@ -0,0 +1,72 @@ +package net.tfminecraft.simplefactions.identity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class LeaderCharactersTest { + + @AfterEach + void resetProbe() { + LeaderCharacters.reset(); + } + + private static void online(Map active) { + LeaderCharacters.setProbe(player -> active.get(player)); + } + + @Test + void readsTheActiveCharacterOfAnOnlineLeader() { + online(Map.of("rushork", "Grunk the Bold")); + LeaderCharacters.Remembered result = LeaderCharacters.resolve("rushork", null, null); + assertEquals("Grunk the Bold", result.name()); + assertEquals("rushork", result.player()); + } + + @Test + void keepsTheRememberedNameWhileTheLeaderIsOffline() { + online(Map.of()); + LeaderCharacters.Remembered result = + LeaderCharacters.resolve("rushork", "Grunk the Bold", "RUSHORK"); + assertEquals("Grunk the Bold", result.name()); + } + + @Test + void anOnlineLeaderOverridesAnOldName() { + online(Map.of("rushork", "Grunk the Second")); + assertEquals("Grunk the Second", + LeaderCharacters.resolve("rushork", "Grunk the Bold", "rushork").name()); + } + + @Test + void aNewLeaderNeverInheritsTheOldLeadersCharacter() { + online(Map.of()); + LeaderCharacters.Remembered result = + LeaderCharacters.resolve("newking", "Grunk the Bold", "rushork"); + assertNull(result.name()); + assertNull(result.player()); + } + + @Test + void stripsColourCodesAndBlankNames() { + online(Map.of("a", "§x§a§3§a§1§8§4§lAelin", "b", " ", "c", "§6Brann")); + assertEquals("Aelin", LeaderCharacters.resolve("a", null, null).name()); + assertNull(LeaderCharacters.resolve("b", null, null).name()); + assertEquals("Brann", LeaderCharacters.resolve("c", null, null).name()); + } + + @Test + void noLeaderNoName() { + assertNull(LeaderCharacters.resolve(null, "x", "y").name()); + assertNull(LeaderCharacters.resolve(" ", "x", "y").name()); + } + + @Test + void withoutRpCharactersNothingIsKnown() { + assertNull(LeaderCharacters.resolve("rushork", null, null).name()); + } +} From b2755542003559c7e915c7afb070f3d9375a9268 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:24:16 +0000 Subject: [PATCH 2/4] Export guild leaders' character names too Guild entries in nation.json now carry `leader character` and `leader character of` like the realm does, read the same way: when the guild saves with its leader online, and shortly after a guild leader joins. A realm's own guild follows the realm's leader. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../simplefactions/database/Database.java | 8 ++++++++ .../simplefactions/database/GuildData.java | 7 +++++++ .../simplefactions/guild/Guild.java | 17 ++++++++++++++++ .../identity/LeaderCharacterListener.java | 20 ++++++++++++++----- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/database/Database.java b/src/main/java/net/tfminecraft/simplefactions/database/Database.java index c529840f..7731baf2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/Database.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/Database.java @@ -259,6 +259,7 @@ public void loadFactions() { } Guild g = new Guild(gd, f); + g.rememberLeaderCharacter(gd.leaderCharacter, gd.leaderCharacterOf); // --- Bank --- if ("true".equalsIgnoreCase(gd.bank)) { @@ -411,6 +412,13 @@ public boolean saveFactionChecked(Faction f) { gd.id = g.getId(); gd.name = g.getOwnName(); gd.leader = g.getLeader(); + LeaderCharacters.Remembered guildLeader = LeaderCharacters.resolve( + g.getLeader(), g.getLeaderCharacter(), g.getLeaderCharacterOf()); + if (!g.isBase()) { + g.rememberLeaderCharacter(guildLeader.name(), guildLeader.player()); + } + gd.leaderCharacter = guildLeader.name(); + gd.leaderCharacterOf = guildLeader.player(); gd.rgb = g.getRGB(); gd.type = g.getType().getId(); gd.capital = g.getCapital(); diff --git a/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java index 36a264b4..365d03b4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java @@ -10,6 +10,13 @@ public class GuildData { public String id; public String name; public String leader; + + /** The leader's roleplay character name and the player it was read from. */ + @com.google.gson.annotations.SerializedName("leader character") + public String leaderCharacter; + + @com.google.gson.annotations.SerializedName("leader character of") + public String leaderCharacterOf; public String rgb; public String type; public Integer capital; diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index 45478752..7d99dc6a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -466,6 +466,23 @@ public void kick(String member) { if(stored != null) members.remove(stored); } public String getLeader() { return isBase() ? host.getLeader() : leader; } + + /** Last known roleplay name of the leader; a realm's own guild shares the realm's. */ + private String leaderCharacter; + private String leaderCharacterOf; + + public String getLeaderCharacter() { + return isBase() ? host.getLeaderCharacter() : leaderCharacter; + } + + public String getLeaderCharacterOf() { + return isBase() ? host.getLeaderCharacterOf() : leaderCharacterOf; + } + + public void rememberLeaderCharacter(String name, String player) { + this.leaderCharacter = name; + this.leaderCharacterOf = player; + } public void setLeader(String leader) { this.leader = leader; } diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java index 150bc0f3..3135acad 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java @@ -7,11 +7,12 @@ import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.plugin.Plugin; +import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.objects.Faction; /** - * Notes a realm leader's character shortly after they join, so the map learns + * Notes a realm or guild leader's character shortly after they join, so the map learns * it even when the leader is never online at the moment the map exports. The * delay gives RPCharacters time to load the player's characters. The name is * kept on the faction and written out with its next save. @@ -34,10 +35,19 @@ public void onJoin(PlayerJoinEvent event) { static void rememberFor(String player) { for (Faction faction : FactionManager.factions) { - if (faction == null || !player.equalsIgnoreCase(faction.getLeader())) continue; - LeaderCharacters.Remembered remembered = LeaderCharacters.resolve( - faction.getLeader(), faction.getLeaderCharacter(), faction.getLeaderCharacterOf()); - faction.rememberLeaderCharacter(remembered.name(), remembered.player()); + if (faction == null) continue; + if (player.equalsIgnoreCase(faction.getLeader())) { + LeaderCharacters.Remembered remembered = LeaderCharacters.resolve( + faction.getLeader(), faction.getLeaderCharacter(), faction.getLeaderCharacterOf()); + faction.rememberLeaderCharacter(remembered.name(), remembered.player()); + } + // Guild leaders too; a realm's own guild follows the realm above. + for (Guild guild : faction.getGuildHandler().getGuilds()) { + if (guild == null || guild.isBase() || !player.equalsIgnoreCase(guild.getLeader())) continue; + LeaderCharacters.Remembered remembered = LeaderCharacters.resolve( + guild.getLeader(), guild.getLeaderCharacter(), guild.getLeaderCharacterOf()); + guild.rememberLeaderCharacter(remembered.name(), remembered.player()); + } } } } From 7a0e79d20051a9ead6d28c458fa0641206125eb4 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:45:55 +0000 Subject: [PATCH 3/4] Know offline leaders' characters, and ship names without waiting RPCharacters only keeps online players in memory, so a leader who was not on when the faction saved never got a name. The probe now reads an offline player's saved characters (data/characterdata//, the one marked active), with the UUID from the server's cache; nothing is looked up from Mojang. Every leader is resolved shortly after startup, and when any realm or guild leader's name changes the quiet map cycle re-exports and uploads nation.json, so names reach the map within one cycle instead of at the next hourly full update. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../simplefactions/SimpleFactions.java | 8 +++ .../identity/LeaderCharacterListener.java | 20 ++++++- .../RpCharactersLeaderCharacterProbe.java | 59 +++++++++++++++++-- .../simplefactions/map/MapSystem.java | 23 ++++++++ .../RpCharactersLeaderCharacterProbeTest.java | 52 ++++++++++++++++ 5 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 src/test/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbeTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index 68af106e..b76cb287 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -696,6 +696,14 @@ private void registerRpCharactersIntegration() { new net.tfminecraft.simplefactions.prestige.RpCharactersPlaytimeProbe()); net.tfminecraft.simplefactions.identity.LeaderCharacters.setProbe( new net.tfminecraft.simplefactions.identity.RpCharactersLeaderCharacterProbe()); + // Learn every realm and guild leader's character once the server has + // settled, online or not, and ship it with the next map cycle. + getServer().getScheduler().runTaskLater(this, () -> { + net.tfminecraft.simplefactions.identity.LeaderCharacterListener.refresh(null); + if (FactionManager.getMap() != null) { + FactionManager.getMap().markLeaderNamesChanged(); + } + }, 200L); net.tfminecraft.simplefactions.integration.rpcharacters.chat.RpCharactersChatIntegration.register(); if (!officeCharacterDeathRegistered) { getServer().getPluginManager().registerEvents(new net.tfminecraft.simplefactions.espionage.OfficeCharacterDeathListener(), this); diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java index 3135acad..bbb02d7f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java @@ -34,20 +34,36 @@ public void onJoin(PlayerJoinEvent event) { } static void rememberFor(String player) { + refresh(player); + } + + /** + * Re-reads every realm and guild leader's character (or only `player`'s + * when given) and, if any name changed, asks the map to ship nation.json + * on its next cycle. + */ + public static void refresh(String player) { + boolean changed = false; for (Faction faction : FactionManager.factions) { if (faction == null) continue; - if (player.equalsIgnoreCase(faction.getLeader())) { + if (player == null || player.equalsIgnoreCase(faction.getLeader())) { LeaderCharacters.Remembered remembered = LeaderCharacters.resolve( faction.getLeader(), faction.getLeaderCharacter(), faction.getLeaderCharacterOf()); + changed |= !java.util.Objects.equals(remembered.name(), faction.getLeaderCharacter()); faction.rememberLeaderCharacter(remembered.name(), remembered.player()); } // Guild leaders too; a realm's own guild follows the realm above. for (Guild guild : faction.getGuildHandler().getGuilds()) { - if (guild == null || guild.isBase() || !player.equalsIgnoreCase(guild.getLeader())) continue; + if (guild == null || guild.isBase()) continue; + if (player != null && !player.equalsIgnoreCase(guild.getLeader())) continue; LeaderCharacters.Remembered remembered = LeaderCharacters.resolve( guild.getLeader(), guild.getLeaderCharacter(), guild.getLeaderCharacterOf()); + changed |= !java.util.Objects.equals(remembered.name(), guild.getLeaderCharacter()); guild.rememberLeaderCharacter(remembered.name(), remembered.player()); } } + if (changed && FactionManager.getMap() != null) { + FactionManager.getMap().markLeaderNamesChanged(); + } } } diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbe.java b/src/main/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbe.java index 7d0de1c9..1d242618 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbe.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbe.java @@ -1,14 +1,34 @@ package net.tfminecraft.simplefactions.identity; +import java.io.File; +import java.io.FileReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; + import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + import net.tfminecraft.rpcharacters.managers.PlayerManager; import net.tfminecraft.rpcharacters.objects.PlayerData; import net.tfminecraft.rpcharacters.objects.RPCharacter; -/** Reads an online player's active RPCharacters character name. */ +/** + * A player's active RPCharacters character name. + * + * Online, it asks RPCharacters directly. Offline, RPCharacters holds nothing + * in memory, so this reads the character files it saves, one per character in + * {@code plugins/RPCharacters/data/characterdata//}, and takes the one + * marked {@code active}. Players the server has never cached a UUID for are + * unknown; no lookup ever goes to Mojang. + */ public final class RpCharactersLeaderCharacterProbe implements LeaderCharacters.Probe { + static final File CHARACTER_ROOT = new File("plugins/RPCharacters/data/characterdata"); + @Override public String activeCharacterName(String player) { if (Bukkit.getServer() == null @@ -17,10 +37,37 @@ public String activeCharacterName(String player) { return null; } Player online = Bukkit.getPlayerExact(player); - if (online == null) return null; - PlayerData data = PlayerManager.get(online); - if (data == null || !data.hasActiveCharacter()) return null; - RPCharacter character = data.getActiveCharacter(); - return character == null ? null : character.getName(); + if (online != null) { + PlayerData data = PlayerManager.get(online); + if (data == null || !data.hasActiveCharacter()) return null; + RPCharacter character = data.getActiveCharacter(); + return character == null ? null : character.getName(); + } + OfflinePlayer cached = Bukkit.getOfflinePlayerIfCached(player); + if (cached == null) return null; + return activeNameOnDisk(new File(CHARACTER_ROOT, cached.getUniqueId().toString())); + } + + /** The {@code name} of the character file in {@code folder} marked active. */ + static String activeNameOnDisk(File folder) { + File[] files = folder.listFiles((dir, name) -> !name.startsWith(".")); + if (files == null) return null; + for (File file : files) { + if (!file.isFile()) continue; + try (Reader reader = new FileReader(file, StandardCharsets.UTF_8)) { + JsonElement parsed = JsonParser.parseReader(reader); + if (!parsed.isJsonObject()) continue; + JsonObject json = parsed.getAsJsonObject(); + JsonElement active = json.get("active"); + JsonElement name = json.get("name"); + if (active == null || name == null || !name.isJsonPrimitive()) continue; + if ("true".equalsIgnoreCase(active.getAsString())) { + return name.getAsString(); + } + } catch (Exception e) { + // A file being rewritten or unreadable: try the rest. + } + } + return null; } } diff --git a/src/main/java/net/tfminecraft/simplefactions/map/MapSystem.java b/src/main/java/net/tfminecraft/simplefactions/map/MapSystem.java index 07e1dd75..b5df2bdb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/map/MapSystem.java +++ b/src/main/java/net/tfminecraft/simplefactions/map/MapSystem.java @@ -139,12 +139,35 @@ public void updateMap() { public void updateLiveData() { lastUpdate = 0; prepareLiveFiles(); + // Leader character names live in Data/*.json and ship in nation.json, + // which the quiet path otherwise skips. A name learned since the last + // cycle re-exports just that file, so it reaches the map within one + // cycle rather than at the next hourly full update. + boolean shipNation = leaderNamesChanged; + leaderNamesChanged = false; + if (shipNation) { + Database db = new Database(); + for (Faction fac : FactionManager.factions) { + db.saveFaction(fac); + } + compiler.exportAllFactionsToNationJson(); + } SimpleFactions plugin = SimpleFactions.getInstance(); Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { uploadLiveFiles(); + if (shipNation) { + RestServer.upload("nation", new File("plugins/SimpleFactions/MapAPI/nation.json")); + } RestServer.commenceRegen("trade"); }); } + + private volatile boolean leaderNamesChanged; + + /** A realm or guild leader's character name changed; see LeaderCharacters. */ + public void markLeaderNamesChanged() { + leaderNamesChanged = true; + } public void fullRegen() { lastUpdate = 0; diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbeTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbeTest.java new file mode 100644 index 00000000..a07eafd3 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbeTest.java @@ -0,0 +1,52 @@ +package net.tfminecraft.simplefactions.identity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class RpCharactersLeaderCharacterProbeTest { + + @TempDir + Path folder; + + private void character(String file, String json) throws IOException { + Files.writeString(folder.resolve(file), json, StandardCharsets.UTF_8); + } + + @Test + void readsTheActiveCharacterFromDisk() throws IOException { + character("a.json", "{\"name\":\"Hazel Stonebrook\",\"active\":\"false\"}"); + character("b.json", "{\"name\":\"Grunk the Bold\",\"active\":\"true\"}"); + assertEquals("Grunk the Bold", + RpCharactersLeaderCharacterProbe.activeNameOnDisk(folder.toFile())); + } + + @Test + void acceptsABooleanActiveFlag() throws IOException { + character("a.json", "{\"name\":\"Aelin\",\"active\":true}"); + assertEquals("Aelin", RpCharactersLeaderCharacterProbe.activeNameOnDisk(folder.toFile())); + } + + @Test + void skipsStagingAndBrokenFiles() throws IOException { + character(".rpcharacters-123.tmp", "{\"name\":\"Half written\",\"active\":\"true\"}"); + character("broken.json", "{not json"); + character("c.json", "{\"name\":\"Brann\",\"active\":\"true\"}"); + assertEquals("Brann", RpCharactersLeaderCharacterProbe.activeNameOnDisk(folder.toFile())); + } + + @Test + void noActiveCharacterOrNoFolderIsUnknown() throws IOException { + character("a.json", "{\"name\":\"Hazel\",\"active\":\"false\"}"); + assertNull(RpCharactersLeaderCharacterProbe.activeNameOnDisk(folder.toFile())); + assertNull(RpCharactersLeaderCharacterProbe.activeNameOnDisk(new File(folder.toFile(), "missing"))); + } +} From a6dc6a8a0c1ffdd3d44d525d2f5e0f2462db3668 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:43:15 +0000 Subject: [PATCH 4/4] Keep leader character names right through saves, transitions and shutdown Address the CodeRabbit review on the leader character export: - A faction save that learns a different realm or guild leader name now marks the map, so nation.json still ships on the quiet cycle when the save beats the delayed join refresh. The resolve-and-compare step is shared by the save and the join/startup refresh. - Without RPCharacters the probe reports itself unavailable and remembered names are dropped rather than kept as if the leader were offline, as documented. - A guild elevated, made landless or rebelling keeps its remembered leader character on the new realm, and a realm's own guild becoming an ordinary one keeps the realm's. - Shutdown releases the leader character probe only after the final faction save, so a leader's newer character is what gets written. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../simplefactions/SimpleFactions.java | 9 ++- .../simplefactions/database/Database.java | 33 +++++--- .../simplefactions/guild/Guild.java | 6 ++ .../identity/LeaderCharacterListener.java | 10 +-- .../identity/LeaderCharacters.java | 60 ++++++++++++-- .../RpCharactersLeaderCharacterProbe.java | 13 +-- .../simplefactions/objects/Faction.java | 3 + .../SimpleFactionsShutdownTest.java | 29 +++++++ .../database/DatabaseLeaderCharacterTest.java | 79 +++++++++++++++++++ .../guild/GuildLeaderCharacterTest.java | 46 +++++++++++ .../identity/LeaderCharactersTest.java | 60 ++++++++++++++ .../RpCharactersLeaderCharacterProbeTest.java | 22 ++++++ 12 files changed, 336 insertions(+), 34 deletions(-) create mode 100644 src/test/java/net/tfminecraft/simplefactions/database/DatabaseLeaderCharacterTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/guild/GuildLeaderCharacterTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index b76cb287..432a10ed 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -374,7 +374,6 @@ public void onDisable() { net.tfminecraft.simplefactions.integration.rpcharacters.chat.RpCharactersChatIntegration.unregister(); MercenaryStatService.clearAll(); net.tfminecraft.simplefactions.mercenary.company.MercenaryEligibility.reset(); - net.tfminecraft.simplefactions.identity.LeaderCharacters.reset(); vehicleMaintenanceDecayTask.stop(); CampaignViewRefreshService.stop(); BattleManager.shutdown(); @@ -382,7 +381,7 @@ public void onDisable() { net.tfminecraft.simplefactions.war.battle.persistence.BattlePersistenceService.saveAll(); sessionManager.end(); net.tfminecraft.simplefactions.inactivity.InactivityService.save(); - saveLoadedFactions(); + saveFactionsForShutdown(); for(War w : WarManager.get()){ db.saveWar(w); } @@ -393,6 +392,12 @@ public void onDisable() { vehicleMaintenancePersistence.save(); } } + void saveFactionsForShutdown() { + // The save resolves leader characters, so RPCharacters stays asked until it is done. + saveLoadedFactions(); + net.tfminecraft.simplefactions.identity.LeaderCharacters.reset(); + } + void saveLoadedFactions() { // An early enable failure must not overwrite partially restored faction state. if (!FactionManager.isLoaded()) return; diff --git a/src/main/java/net/tfminecraft/simplefactions/database/Database.java b/src/main/java/net/tfminecraft/simplefactions/database/Database.java index 87448bcd..5d7b612c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/Database.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/Database.java @@ -327,11 +327,9 @@ public boolean saveFactionChecked(Faction f) { data.name = f.getName(); data.rgb = f.getRGB(); data.leader = f.getLeader(); - LeaderCharacters.Remembered leaderCharacter = LeaderCharacters.resolve( - f.getLeader(), f.getLeaderCharacter(), f.getLeaderCharacterOf()); - f.rememberLeaderCharacter(leaderCharacter.name(), leaderCharacter.player()); - data.leaderCharacter = leaderCharacter.name(); - data.leaderCharacterOf = leaderCharacter.player(); + rememberLeaderCharacters(f); + data.leaderCharacter = f.getLeaderCharacter(); + data.leaderCharacterOf = f.getLeaderCharacterOf(); data.rulerTitle = f.getRulerTitle(); data.government = f.getGovernmentString(); data.culture = f.getCulture(); @@ -410,13 +408,8 @@ public boolean saveFactionChecked(Faction f) { gd.id = g.getId(); gd.name = g.getOwnName(); gd.leader = g.getLeader(); - LeaderCharacters.Remembered guildLeader = LeaderCharacters.resolve( - g.getLeader(), g.getLeaderCharacter(), g.getLeaderCharacterOf()); - if (!g.isBase()) { - g.rememberLeaderCharacter(guildLeader.name(), guildLeader.player()); - } - gd.leaderCharacter = guildLeader.name(); - gd.leaderCharacterOf = guildLeader.player(); + gd.leaderCharacter = g.getLeaderCharacter(); + gd.leaderCharacterOf = g.getLeaderCharacterOf(); gd.rgb = g.getRGB(); gd.type = g.getType().getId(); gd.capital = g.getCapital(); @@ -549,6 +542,22 @@ public boolean saveFactionChecked(Faction f) { * HELPERS * ===================================================== */ + /** + * Brings the realm's and its guilds' leader characters up to date before + * they are written. A name learned here must still reach the map: the join + * refresh that runs later would find nothing new and leave nation.json + * stale, so the map is told now. + */ + static void rememberLeaderCharacters(Faction f) { + boolean changed = LeaderCharacters.refresh(f); + for (Guild g : f.getGuildHandler().getGuilds()) { + if (g != null) changed |= LeaderCharacters.refresh(g); + } + if (changed && FactionManager.getMap() != null) { + FactionManager.getMap().markLeaderNamesChanged(); + } + } + public static List loadModifiers(List raw) { List list = new ArrayList<>(); for (String s : raw) { diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index cbfa4d71..cfff861b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -498,6 +498,11 @@ public void rememberLeaderCharacter(String name, String player) { this.leaderCharacter = name; this.leaderCharacterOf = player; } + + /** A realm's own guild becoming an ordinary one keeps the realm's remembered name. */ + void keepHostLeaderCharacter() { + rememberLeaderCharacter(getLeaderCharacter(), getLeaderCharacterOf()); + } public void setLeader(String leader) { this.leader = leader; } @@ -955,6 +960,7 @@ public void convert(GuildType type) { this.capital = getCapital(); this.id = getId(); this.leader = getLeader(); + keepHostLeaderCharacter(); this.members = new ArrayList<>(getMembers()); this.invites = new ArrayList<>(getInvites()); if (this.name == null || this.name.isBlank()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java index bbb02d7f..ede6891d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java @@ -47,19 +47,13 @@ public static void refresh(String player) { for (Faction faction : FactionManager.factions) { if (faction == null) continue; if (player == null || player.equalsIgnoreCase(faction.getLeader())) { - LeaderCharacters.Remembered remembered = LeaderCharacters.resolve( - faction.getLeader(), faction.getLeaderCharacter(), faction.getLeaderCharacterOf()); - changed |= !java.util.Objects.equals(remembered.name(), faction.getLeaderCharacter()); - faction.rememberLeaderCharacter(remembered.name(), remembered.player()); + changed |= LeaderCharacters.refresh(faction); } // Guild leaders too; a realm's own guild follows the realm above. for (Guild guild : faction.getGuildHandler().getGuilds()) { if (guild == null || guild.isBase()) continue; if (player != null && !player.equalsIgnoreCase(guild.getLeader())) continue; - LeaderCharacters.Remembered remembered = LeaderCharacters.resolve( - guild.getLeader(), guild.getLeaderCharacter(), guild.getLeaderCharacterOf()); - changed |= !java.util.Objects.equals(remembered.name(), guild.getLeaderCharacter()); - guild.rememberLeaderCharacter(remembered.name(), remembered.player()); + changed |= LeaderCharacters.refresh(guild); } } if (changed && FactionManager.getMap() != null) { diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacters.java b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacters.java index 8eabe03f..7cc50eae 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacters.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacters.java @@ -1,5 +1,10 @@ package net.tfminecraft.simplefactions.identity; +import java.util.Objects; + +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.objects.Faction; + /** * The roleplay name of a realm's leader, for the web map. * @@ -11,14 +16,19 @@ * new leader never inherits the old one's character. * * Production swaps in {@link RpCharactersLeaderCharacterProbe} when that - * plugin is present; without it nothing is ever known and the map shows no - * ruler name. + * plugin is present; without it nothing is ever known, remembered names are + * dropped too, and the map shows no ruler name. */ public final class LeaderCharacters { /** Active character name of an online player, or null if unknown. */ public interface Probe { String activeCharacterName(String player); + + /** False while RPCharacters is missing, so remembered names are not kept either. */ + default boolean available() { + return true; + } } /** A remembered character name and the player it belongs to. */ @@ -26,26 +36,40 @@ public record Remembered(String name, String player) { public static final Remembered NONE = new Remembered(null, null); } - private static volatile Probe probe = player -> null; + private static final Probe UNAVAILABLE = new Probe() { + @Override + public String activeCharacterName(String player) { + return null; + } + + @Override + public boolean available() { + return false; + } + }; + + private static volatile Probe probe = UNAVAILABLE; private LeaderCharacters() {} public static void setProbe(Probe next) { - probe = next == null ? player -> null : next; + probe = next == null ? UNAVAILABLE : next; } public static void reset() { - probe = player -> null; + probe = UNAVAILABLE; } /** * What to remember for `leader` now: their active character if they are * online with one, else what was remembered for this same leader, else - * nothing. + * nothing. Without RPCharacters it is always nothing. */ public static Remembered resolve(String leader, String rememberedName, String rememberedFor) { if (leader == null || leader.isBlank()) return Remembered.NONE; - String active = clean(probe.activeCharacterName(leader)); + Probe current = probe; + if (!current.available()) return Remembered.NONE; + String active = clean(current.activeCharacterName(leader)); if (active != null) return new Remembered(active, leader); if (rememberedName != null && leader.equalsIgnoreCase(rememberedFor)) { String kept = clean(rememberedName); @@ -54,6 +78,28 @@ public static Remembered resolve(String leader, String rememberedName, String re return Remembered.NONE; } + /** Re-reads the realm leader's character onto `faction`; true if the name changed. */ + public static boolean refresh(Faction faction) { + Remembered remembered = resolve( + faction.getLeader(), faction.getLeaderCharacter(), faction.getLeaderCharacterOf()); + boolean changed = !Objects.equals(remembered.name(), faction.getLeaderCharacter()); + faction.rememberLeaderCharacter(remembered.name(), remembered.player()); + return changed; + } + + /** + * Re-reads the guild leader's character onto `guild`; true if the name + * changed. A realm's own guild shares the realm's, so it is left alone. + */ + public static boolean refresh(Guild guild) { + if (guild.isBase()) return false; + Remembered remembered = resolve( + guild.getLeader(), guild.getLeaderCharacter(), guild.getLeaderCharacterOf()); + boolean changed = !Objects.equals(remembered.name(), guild.getLeaderCharacter()); + guild.rememberLeaderCharacter(remembered.name(), remembered.player()); + return changed; + } + /** Strips Minecraft colour codes and blank names. */ static String clean(String name) { if (name == null) return null; diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbe.java b/src/main/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbe.java index 1d242618..f076704c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbe.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbe.java @@ -29,13 +29,16 @@ public final class RpCharactersLeaderCharacterProbe implements LeaderCharacters.Probe { static final File CHARACTER_ROOT = new File("plugins/RPCharacters/data/characterdata"); + @Override + public boolean available() { + return Bukkit.getServer() != null + && Bukkit.getPluginManager() != null + && Bukkit.getPluginManager().isPluginEnabled("RPCharacters"); + } + @Override public String activeCharacterName(String player) { - if (Bukkit.getServer() == null - || Bukkit.getPluginManager() == null - || !Bukkit.getPluginManager().isPluginEnabled("RPCharacters")) { - return null; - } + if (!available()) return null; Player online = Bukkit.getPlayerExact(player); if (online != null) { PlayerData data = PlayerManager.get(online); diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java index 0db2ebf6..9f96a98d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java @@ -198,6 +198,9 @@ public Faction(Guild guild) { this.name = guild.getName(); this.diplomacyHandler = new DiplomacyHandler(this); this.leader = guild.getLeader(); + // Read before the guild becomes this realm's own and starts sharing these fields. + this.leaderCharacter = guild.getLeaderCharacter(); + this.leaderCharacterOf = guild.getLeaderCharacterOf(); this.rulerTitle = "Leader"; this.bannerPatterns = guild.getBannerPatterns(); this.rank = RankLoader.getLowest(); diff --git a/src/test/java/net/tfminecraft/simplefactions/SimpleFactionsShutdownTest.java b/src/test/java/net/tfminecraft/simplefactions/SimpleFactionsShutdownTest.java index 38ff96bd..d5d23601 100644 --- a/src/test/java/net/tfminecraft/simplefactions/SimpleFactionsShutdownTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/SimpleFactionsShutdownTest.java @@ -1,10 +1,14 @@ package net.tfminecraft.simplefactions; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.Mockito.*; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.identity.LeaderCharacters; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.objects.Faction; @@ -28,4 +32,29 @@ class SimpleFactionsShutdownTest { verify(database).saveFaction(faction); } finally { FactionManager.factions = previous; } } + + @Test void shutdownSavesFactionsBeforeReleasingTheLeaderCharacterProbe() throws Exception { + var plugin = mock(SimpleFactions.class, CALLS_REAL_METHODS); + var database = mock(Database.class); + var field = SimpleFactions.class.getDeclaredField("db"); + field.setAccessible(true); field.set(plugin, database); + var previous = FactionManager.factions; + var faction = mock(Faction.class); + FactionManager.factions = new ArrayList<>(List.of(faction)); + var savedAs = new AtomicReference(); + doAnswer(call -> { + savedAs.set(LeaderCharacters.resolve("rushork", "Grunk the Bold", "rushork").name()); + return null; + }).when(database).saveFaction(faction); + LeaderCharacters.setProbe(player -> "Grunk the Second"); + try (var manager = mockStatic(FactionManager.class)) { + manager.when(FactionManager::isLoaded).thenReturn(true); + plugin.saveFactionsForShutdown(); + assertEquals("Grunk the Second", savedAs.get()); + assertNull(LeaderCharacters.resolve("rushork", "Grunk the Bold", "rushork").name()); + } finally { + FactionManager.factions = previous; + LeaderCharacters.reset(); + } + } } diff --git a/src/test/java/net/tfminecraft/simplefactions/database/DatabaseLeaderCharacterTest.java b/src/test/java/net/tfminecraft/simplefactions/database/DatabaseLeaderCharacterTest.java new file mode 100644 index 00000000..78fe1216 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/database/DatabaseLeaderCharacterTest.java @@ -0,0 +1,79 @@ +package net.tfminecraft.simplefactions.database; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.identity.LeaderCharacters; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.map.MapSystem; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.handler.GuildHandler; + +class DatabaseLeaderCharacterTest { + + private MapSystem previousMap; + private MapSystem map; + private Faction faction; + private Guild guild; + + @BeforeEach + void setUp() { + previousMap = FactionManager.map; + map = mock(MapSystem.class); + FactionManager.map = map; + faction = mock(Faction.class); + guild = mock(Guild.class); + GuildHandler guilds = mock(GuildHandler.class); + when(faction.getGuildHandler()).thenReturn(guilds); + when(guilds.getGuilds()).thenReturn(List.of(guild)); + when(faction.getLeader()).thenReturn("rushork"); + when(faction.getLeaderCharacter()).thenReturn("Grunk the Bold"); + when(faction.getLeaderCharacterOf()).thenReturn("rushork"); + when(guild.getLeader()).thenReturn("hazel"); + when(guild.getLeaderCharacter()).thenReturn("Hazel Stonebrook"); + when(guild.getLeaderCharacterOf()).thenReturn("hazel"); + } + + @AfterEach + void tearDown() { + FactionManager.map = previousMap; + LeaderCharacters.reset(); + } + + private static void online(Map active) { + LeaderCharacters.setProbe(player -> active.get(player)); + } + + @Test + void aSaveThatLearnsANewRealmLeaderNameMarksTheMap() { + online(Map.of("rushork", "Grunk the Second", "hazel", "Hazel Stonebrook")); + Database.rememberLeaderCharacters(faction); + verify(faction).rememberLeaderCharacter("Grunk the Second", "rushork"); + verify(map).markLeaderNamesChanged(); + } + + @Test + void aSaveThatLearnsANewGuildLeaderNameMarksTheMap() { + online(Map.of("rushork", "Grunk the Bold", "hazel", "Hazel Ironbrook")); + Database.rememberLeaderCharacters(faction); + verify(guild).rememberLeaderCharacter("Hazel Ironbrook", "hazel"); + verify(map).markLeaderNamesChanged(); + } + + @Test + void aSaveThatLearnsNothingNewLeavesTheMapAlone() { + online(Map.of()); + Database.rememberLeaderCharacters(faction); + verify(map, never()).markLeaderNamesChanged(); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/GuildLeaderCharacterTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/GuildLeaderCharacterTest.java new file mode 100644 index 00000000..5ac1e797 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/GuildLeaderCharacterTest.java @@ -0,0 +1,46 @@ +package net.tfminecraft.simplefactions.guild; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import java.lang.reflect.Field; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import net.tfminecraft.simplefactions.objects.Faction; + +class GuildLeaderCharacterTest { + + @Test + void aRealmsOwnGuildKeepsTheRealmsNameWhenItBecomesOrdinary() throws Exception { + Guild guild = mock(Guild.class, withSettings().defaultAnswer(Mockito.CALLS_REAL_METHODS)); + Faction host = mock(Faction.class); + when(host.getLeaderCharacter()).thenReturn("Grunk the Bold"); + when(host.getLeaderCharacterOf()).thenReturn("rushork"); + setField(guild, "host", host); + setField(guild, "type", type(true)); + assertEquals("Grunk the Bold", guild.getLeaderCharacter()); + + // What convert() does before the type changes. + guild.keepHostLeaderCharacter(); + setField(guild, "type", type(false)); + + assertEquals("Grunk the Bold", guild.getLeaderCharacter()); + assertEquals("rushork", guild.getLeaderCharacterOf()); + } + + private static GuildType type(boolean base) { + GuildType type = mock(GuildType.class); + when(type.isBase()).thenReturn(base); + return type; + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field field = Guild.class.getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/LeaderCharactersTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/LeaderCharactersTest.java index a36f4373..f155c351 100644 --- a/src/test/java/net/tfminecraft/simplefactions/identity/LeaderCharactersTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/identity/LeaderCharactersTest.java @@ -1,13 +1,22 @@ package net.tfminecraft.simplefactions.identity; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.util.Map; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.objects.Faction; + class LeaderCharactersTest { @AfterEach @@ -69,4 +78,55 @@ void noLeaderNoName() { void withoutRpCharactersNothingIsKnown() { assertNull(LeaderCharacters.resolve("rushork", null, null).name()); } + + @Test + void withoutRpCharactersARememberedNameIsDropped() { + assertNull(LeaderCharacters.resolve("rushork", "Grunk the Bold", "rushork").name()); + assertNull(LeaderCharacters.resolve("rushork", "Grunk the Bold", "rushork").player()); + } + + @Test + void anUnavailableProbeIsNotMistakenForAnOfflineLeader() { + LeaderCharacters.setProbe(new LeaderCharacters.Probe() { + @Override + public String activeCharacterName(String player) { + return null; + } + + @Override + public boolean available() { + return false; + } + }); + assertNull(LeaderCharacters.resolve("rushork", "Grunk the Bold", "rushork").name()); + } + + @Test + void refreshingARealmReportsOnlyARealChange() { + online(Map.of("rushork", "Grunk the Second")); + Faction faction = mock(Faction.class); + when(faction.getLeader()).thenReturn("rushork"); + when(faction.getLeaderCharacter()).thenReturn("Grunk the Bold"); + when(faction.getLeaderCharacterOf()).thenReturn("rushork"); + assertTrue(LeaderCharacters.refresh(faction)); + verify(faction).rememberLeaderCharacter("Grunk the Second", "rushork"); + + when(faction.getLeaderCharacter()).thenReturn("Grunk the Second"); + assertFalse(LeaderCharacters.refresh(faction)); + } + + @Test + void refreshingLeavesARealmsOwnGuildToTheRealm() { + online(Map.of("rushork", "Grunk the Second")); + Guild base = mock(Guild.class); + when(base.isBase()).thenReturn(true); + assertFalse(LeaderCharacters.refresh(base)); + verify(base, never()).rememberLeaderCharacter(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + + Guild guild = mock(Guild.class); + when(guild.getLeader()).thenReturn("rushork"); + assertTrue(LeaderCharacters.refresh(guild)); + verify(guild).rememberLeaderCharacter("Grunk the Second", "rushork"); + } } diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbeTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbeTest.java index a07eafd3..f1e05218 100644 --- a/src/test/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbeTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbeTest.java @@ -1,7 +1,12 @@ package net.tfminecraft.simplefactions.identity; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; import java.io.File; import java.io.IOException; @@ -9,6 +14,9 @@ import java.nio.file.Files; import java.nio.file.Path; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.plugin.PluginManager; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -49,4 +57,18 @@ void noActiveCharacterOrNoFolderIsUnknown() throws IOException { assertNull(RpCharactersLeaderCharacterProbe.activeNameOnDisk(folder.toFile())); assertNull(RpCharactersLeaderCharacterProbe.activeNameOnDisk(new File(folder.toFile(), "missing"))); } + + @Test + void availableOnlyWhileRpCharactersIsEnabled() { + RpCharactersLeaderCharacterProbe probe = new RpCharactersLeaderCharacterProbe(); + try (var bukkit = mockStatic(Bukkit.class)) { + assertFalse(probe.available()); + PluginManager plugins = mock(PluginManager.class); + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + assertFalse(probe.available()); + when(plugins.isPluginEnabled("RPCharacters")).thenReturn(true); + assertTrue(probe.available()); + } + } }