diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index 25a33d39..432a10ed 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -381,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); } @@ -392,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; @@ -430,6 +436,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 +699,16 @@ 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()); + // 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/database/Database.java b/src/main/java/net/tfminecraft/simplefactions/database/Database.java index 7c16f806..5d7b612c 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); @@ -255,6 +257,7 @@ public void loadFactions() { } Guild g = new Guild(gd, f); + g.rememberLeaderCharacter(gd.leaderCharacter, gd.leaderCharacterOf); // --- Bank --- if ("true".equalsIgnoreCase(gd.bank)) { @@ -324,6 +327,9 @@ public boolean saveFactionChecked(Faction f) { data.name = f.getName(); data.rgb = f.getRGB(); data.leader = f.getLeader(); + rememberLeaderCharacters(f); + data.leaderCharacter = f.getLeaderCharacter(); + data.leaderCharacterOf = f.getLeaderCharacterOf(); data.rulerTitle = f.getRulerTitle(); data.government = f.getGovernmentString(); data.culture = f.getCulture(); @@ -402,6 +408,8 @@ public boolean saveFactionChecked(Faction f) { gd.id = g.getId(); gd.name = g.getOwnName(); gd.leader = g.getLeader(); + gd.leaderCharacter = g.getLeaderCharacter(); + gd.leaderCharacterOf = g.getLeaderCharacterOf(); gd.rgb = g.getRGB(); gd.type = g.getType().getId(); gd.capital = g.getCapital(); @@ -534,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/database/FactionData.java b/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java index c70a5f34..7ac84ed7 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/database/GuildData.java b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java index 234e0c62..d915f927 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 77121670..cfff861b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -481,6 +481,28 @@ 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; + } + + /** 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; } @@ -938,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 new file mode 100644 index 00000000..ede6891d --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java @@ -0,0 +1,63 @@ +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.guild.Guild; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; + +/** + * 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. + */ +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) { + 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 == null || player.equalsIgnoreCase(faction.getLeader())) { + 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; + changed |= LeaderCharacters.refresh(guild); + } + } + if (changed && FactionManager.getMap() != null) { + FactionManager.getMap().markLeaderNamesChanged(); + } + } +} 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..7cc50eae --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacters.java @@ -0,0 +1,109 @@ +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. + * + * 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, 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. */ + public record Remembered(String name, String player) { + public static final Remembered NONE = new Remembered(null, 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 ? UNAVAILABLE : next; + } + + public static void reset() { + 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. Without RPCharacters it is always nothing. + */ + public static Remembered resolve(String leader, String rememberedName, String rememberedFor) { + if (leader == null || leader.isBlank()) return Remembered.NONE; + 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); + if (kept != null) return new Remembered(kept, leader); + } + 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; + 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..f076704c --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbe.java @@ -0,0 +1,76 @@ +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; + +/** + * 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 boolean available() { + return Bukkit.getServer() != null + && Bukkit.getPluginManager() != null + && Bukkit.getPluginManager().isPluginEnabled("RPCharacters"); + } + + @Override + public String activeCharacterName(String player) { + if (!available()) return null; + Player online = Bukkit.getPlayerExact(player); + 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/main/java/net/tfminecraft/simplefactions/objects/Faction.java b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java index aa11aff8..9f96a98d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java @@ -106,6 +106,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; @@ -195,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(); @@ -742,6 +748,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/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 new file mode 100644 index 00000000..f155c351 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/identity/LeaderCharactersTest.java @@ -0,0 +1,132 @@ +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 + 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()); + } + + @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 new file mode 100644 index 00000000..f1e05218 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/identity/RpCharactersLeaderCharacterProbeTest.java @@ -0,0 +1,74 @@ +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; +import java.nio.charset.StandardCharsets; +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; + +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"))); + } + + @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()); + } + } +}