diff --git a/src/main/java/net/tfminecraft/simplefactions/Cache.java b/src/main/java/net/tfminecraft/simplefactions/Cache.java index 34bd7645..bd0d5ab1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/Cache.java +++ b/src/main/java/net/tfminecraft/simplefactions/Cache.java @@ -17,6 +17,8 @@ import net.tfminecraft.simplefactions.war.battle.enums.DefenderRespawnMode; public class Cache { + private Cache() {} + public static String mapRef; /** Chapter slug for a future Archive prefill. Not the live upload folder. */ public static String chapterId = "unknown"; diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index b5a7260d..0bb718ec 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -3,6 +3,8 @@ import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleHealthDecayApi.Vf; import java.io.File; +import java.util.ArrayList; +import java.util.LinkedHashMap; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.event.EventHandler; @@ -252,15 +254,24 @@ public class SimpleFactions extends JavaPlugin{ private final VehicleMaintenanceDecayTask vehicleMaintenanceDecayTask = new VehicleMaintenanceDecayTask(); + private boolean startupComplete; + @Override public void onEnable() { + startupComplete = false; config = getConfig(); plugin = this; FactionManager.inv = inventoryManager; createFolders(); createConfigs(); + try { + loadConfigs(); + } catch (RuntimeException error) { + getLogger().log(java.util.logging.Level.SEVERE, "Cannot load SimpleFactions configuration; disabling before restoring saved state", error); + getServer().getPluginManager().disablePlugin(this); + return; + } registerListeners(); - loadConfigs(); registerRpCharactersIntegrationHooks(); vehicleRegistryPersistence = new VehicleRegistryPersistence( new File(getDataFolder(), "Cache"), @@ -365,6 +376,7 @@ public void onEnable() { } inventoryManager.start(); vehicleMaintenanceDecayTask.start(); + startupComplete = true; } @Override public void onDisable() { @@ -376,8 +388,12 @@ public void onDisable() { CampaignViewRefreshService.stop(); BattleManager.shutdown(); net.tfminecraft.simplefactions.war.battle.persistence.BattlePersistenceService.stopAutosave(); - net.tfminecraft.simplefactions.war.battle.persistence.BattlePersistenceService.saveAll(); sessionManager.end(); + if (!startupComplete) { + net.tfminecraft.simplefactions.identity.LeaderCharacters.reset(); + return; + } + net.tfminecraft.simplefactions.war.battle.persistence.BattlePersistenceService.saveAll(); net.tfminecraft.simplefactions.inactivity.InactivityService.save(); saveFactionsForShutdown(); for(War w : WarManager.get()){ @@ -524,7 +540,39 @@ public static void reloadTitles() { } public static void reloadConfigs() { - plugin.loadConfigs(); + var previousRanks = new ArrayList<>(RankLoader.getRanks()); + var previousTypes = new ArrayList<>(RelationLoader.getTypes()); + var previousAttitudes = new ArrayList<>(RelationLoader.getAttitudes()); + var previousTiers = new ArrayList<>(TierLoader.get()); + var previousTitles = new ArrayList<>(TitleLoader.getTitles()); + var previousGuildTypes = new LinkedHashMap<>(GuildLoader.get()); + var previousBranches = new LinkedHashMap<>(BranchLoader.get()); + var previousUpgrades = new LinkedHashMap<>(UpgradeLoader.get()); + var previousCompanyUpgrades = new LinkedHashMap<>(CompanyUpgradeLoader.get()); + try { + plugin.loadConfigs(); + } catch (RuntimeException failure) { + // Live factions still reference these definitions until every loader succeeds. + RankLoader.getRanks().clear(); + RankLoader.getRanks().addAll(previousRanks); + RelationLoader.getTypes().clear(); + RelationLoader.getTypes().addAll(previousTypes); + RelationLoader.getAttitudes().clear(); + RelationLoader.getAttitudes().addAll(previousAttitudes); + TierLoader.get().clear(); + TierLoader.get().addAll(previousTiers); + TitleLoader.getTitles().clear(); + TitleLoader.getTitles().addAll(previousTitles); + GuildLoader.get().clear(); + GuildLoader.get().putAll(previousGuildTypes); + BranchLoader.get().clear(); + BranchLoader.get().putAll(previousBranches); + UpgradeLoader.get().clear(); + UpgradeLoader.get().putAll(previousUpgrades); + CompanyUpgradeLoader.get().clear(); + CompanyUpgradeLoader.get().putAll(previousCompanyUpgrades); + throw failure; + } FactionManager.rebindRanks(); FactionManager.rebindDiplomacy(); // loadConfigs rebuilds every Title, so point factions at the new copies or hasTitle stops matching. diff --git a/src/main/java/net/tfminecraft/simplefactions/army/Military.java b/src/main/java/net/tfminecraft/simplefactions/army/Military.java index 431fb101..c6745dcc 100644 --- a/src/main/java/net/tfminecraft/simplefactions/army/Military.java +++ b/src/main/java/net/tfminecraft/simplefactions/army/Military.java @@ -273,9 +273,9 @@ public List getLevies() { if(total+count >= subject.getMembers().size()) { count = subject.getMembers().size() - total; } - total+=count; - if(count > 0) r.setSentToOverlord(count); - else continue; + count = Math.max(0, count); + total += count; + r.setSentToOverlord(count); } levies.add(new LevyEntry(subject, total)); for(LevyEntry e : subjectLevies) { diff --git a/src/main/java/net/tfminecraft/simplefactions/database/Database.java b/src/main/java/net/tfminecraft/simplefactions/database/Database.java index 9e34791e..8721528e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/Database.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/Database.java @@ -8,7 +8,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; import org.bukkit.Bukkit; import org.bukkit.Chunk; @@ -104,206 +103,217 @@ public void loadFactions() { // Suppressed for the whole load: each faction and guild calls updateWealth, which // cascades into a full-server prestige pass over a partial list. FactionManager.run // recomputes once everything is in. + File[] files = folder.listFiles(); + if (files == null) { + throw new IllegalStateException("Cannot read faction directory: " + folder.getAbsolutePath()); + } + boolean wasLoading = FactionManager.loading; FactionManager.loading = true; - for (File file : Objects.requireNonNull(folder.listFiles())) { - if (!file.getName().endsWith(".json")) continue; - - try { - FactionData data = JsonUtil.readJson(file, FactionData.class); - if (data == null || data.id == null) continue; - - // --- Provinces --- - List provinces = new ArrayList<>(); - for (Number n : data.provinces) provinces.add(n.intValue()); - - // --- Titles --- - List titles = new ArrayList<>(); - for (String tid : data.titles) { - Title t = TitleLoader.getById(tid); - if (t != null) titles.add(t); - } + try { + for (File file : files) { + if (!file.getName().endsWith(".json")) continue; + + try { + FactionData data = JsonUtil.readJson(file, FactionData.class); + if (data == null || data.id == null) continue; + + // --- Provinces --- + List<Integer> provinces = new ArrayList<>(); + for (Number n : data.provinces) provinces.add(n.intValue()); + + // --- Titles --- + List<Title> titles = new ArrayList<>(); + for (String tid : data.titles) { + Title t = TitleLoader.getById(tid); + if (t != null) titles.add(t); + } - int capital = data.capital != null ? data.capital : -1; - int extraCap = data.extraNodeCapacity != null ? data.extraNodeCapacity.intValue() : 0; + int capital = data.capital != null ? data.capital : -1; + int extraCap = data.extraNodeCapacity != null ? data.extraNodeCapacity.intValue() : 0; - double citizenTax = data.citizenTax != null ? data.citizenTax : 5.0; - double guildTax = data.guildTax != null ? data.guildTax : 5.0; - double vassalTax = data.vassalTax != null ? data.vassalTax : 5.0; - double dividendTax = data.dividendTax != null ? data.dividendTax : 5.0; - double tariffs = data.tariffs != null ? data.tariffs : 5.0; + double citizenTax = data.citizenTax != null ? data.citizenTax : 5.0; + double guildTax = data.guildTax != null ? data.guildTax : 5.0; + double vassalTax = data.vassalTax != null ? data.vassalTax : 5.0; + double dividendTax = data.dividendTax != null ? data.dividendTax : 5.0; + double tariffs = data.tariffs != null ? data.tariffs : 5.0; - // Deep copy specific taxes - HashMap<String, HashMap<String, Double>> specificTaxes = new HashMap<>(); - if (data.specificTaxes != null) { - for (HashMap.Entry<String, HashMap<String, Double>> entry : data.specificTaxes.entrySet()) { - if (entry.getValue() != null) { - specificTaxes.put(entry.getKey(), new HashMap<>(entry.getValue())); + // Deep copy specific taxes + HashMap<String, HashMap<String, Double>> specificTaxes = new HashMap<>(); + if (data.specificTaxes != null) { + for (HashMap.Entry<String, HashMap<String, Double>> entry : data.specificTaxes.entrySet()) { + if (entry.getValue() != null) { + specificTaxes.put(entry.getKey(), new HashMap<>(entry.getValue())); + } } } - } - Faction f = new Faction( - data.id, - data.rgb, - provinces, - titles, - data.leader, - data.name, - data.rulerTitle, - data.banner, - data.government, - data.culture, - data.religion, - extraCap, - loadModifiers(data.prestigeModifiers), - citizenTax, - guildTax, - vassalTax, - dividendTax, - tariffs, - specificTaxes, - capital, - data.laws, - data.governmentData - ); - f.getVehicleFeeHandler().load(data.vehicleFees, data.vehicleTypeFees); - if (data.lawChangedAt != null) { - for (Map.Entry<String, Long> entry : data.lawChangedAt.entrySet()) { - LawGroup group = f.getLawHandler().getGroup(entry.getKey()); - if (group != null && entry.getValue() != null) group.setChangedAt(entry.getValue()); + Faction f = new Faction( + data.id, + data.rgb, + provinces, + titles, + data.leader, + data.name, + data.rulerTitle, + data.banner, + data.government, + data.culture, + data.religion, + extraCap, + loadModifiers(data.prestigeModifiers), + citizenTax, + guildTax, + vassalTax, + dividendTax, + tariffs, + specificTaxes, + capital, + data.laws, + data.governmentData + ); + f.getVehicleFeeHandler().load(data.vehicleFees, data.vehicleTypeFees); + if (data.lawChangedAt != null) { + for (Map.Entry<String, Long> entry : data.lawChangedAt.entrySet()) { + LawGroup group = f.getLawHandler().getGroup(entry.getKey()); + if (group != null && entry.getValue() != null) group.setChangedAt(entry.getValue()); + } } - } - // --- Rank / founding --- - // Rank is derived state that only climbs one level per updatePrestige, so it - // has to be restored rather than re-derived from a cold ladder on every boot. - if (data.rank != null) { - PrestigeRank restored = RankLoader.getByString(data.rank); - if (restored != null) f.setRank(restored); - } - 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); + // --- Rank / founding --- + // Rank is derived state that only climbs one level per updatePrestige, so it + // has to be restored rather than re-derived from a cold ladder on every boot. + if (data.rank != null) { + PrestigeRank restored = RankLoader.getByString(data.rank); + if (restored != null) f.setRank(restored); + } + 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); - } + if (data.settlements != null) { + f.getSettlementHandler().load(data.settlements); + } - if (data.installations != null) { - f.getInstallationHandler().load(data.installations); - } - if (data.installationQueue != null) { - f.getInstallationHandler().loadConstruction(data.installationQueue); - } + if (data.installations != null) { + f.getInstallationHandler().load(data.installations); + } + if (data.installationQueue != null) { + f.getInstallationHandler().loadConstruction(data.installationQueue); + } - // --- Relations --- - if (data.relations == null) { - LogManager.relations("JSON %s relations=null", f.getId()); - } else { - LogManager.relations("JSON %s relations=%s", f.getId(), data.relations); - for (String r : data.relations) { - FactionManager.addDBRelation(f, r); + // --- Relations --- + if (data.relations == null) { + LogManager.relations("JSON %s relations=null", f.getId()); + } else { + LogManager.relations("JSON %s relations=%s", f.getId(), data.relations); + for (String r : data.relations) { + FactionManager.addDBRelation(f, r); + } } - } - if(data.tradeRelations != null) { - for (String r : data.tradeRelations) { - FactionManager.addDBTradeRelation(f, r); + if(data.tradeRelations != null) { + for (String r : data.tradeRelations) { + FactionManager.addDBTradeRelation(f, r); + } } - } - if(data.treatyRelations != null) { - for (String r : data.treatyRelations) { - FactionManager.addDBTreatyRelation(f, r); + if(data.treatyRelations != null) { + for (String r : data.treatyRelations) { + FactionManager.addDBTreatyRelation(f, r); + } } - } - // --- Tier --- - if (data.tierIndex != null) { - f.getTier().setIndex(data.tierIndex.intValue()); - } + // --- Tier --- + if (data.tierIndex != null) { + f.getTier().setIndex(data.tierIndex.intValue()); + } - // --- Military --- - Military m = f.getMilitary(); - for (String s : data.military) { - String[] split = s.split("\\."); - Regiment r = m.getRegiment(split[0]); - if (r == null) continue; - r.setCurrentSlots(Integer.parseInt(split[1])); - // "id.current.free"; older "id.current" saves always had the regiments.yml default free. - Regiment prototype = RegimentLoader.getByString(split[0]); - r.setFreeSlots(split.length > 2 ? Integer.parseInt(split[2]) - : prototype == null ? 0 : prototype.getFreeSlots()); - } - // Laws are already applied; overlord grants are re-derived once relations load. - m.refreshLawSlots(); + // --- Military --- + Military m = f.getMilitary(); + for (String s : data.military) { + String[] split = s.split("\\."); + Regiment r = m.getRegiment(split[0]); + if (r == null) continue; + r.setCurrentSlots(Integer.parseInt(split[1])); + // "id.current.free"; older "id.current" saves always had the regiments.yml default free. + Regiment prototype = RegimentLoader.getByString(split[0]); + r.setFreeSlots(split.length > 2 ? Integer.parseInt(split[2]) + : prototype == null ? 0 : prototype.getFreeSlots()); + } + // Laws are already applied; overlord grants are re-derived once relations load. + m.refreshLawSlots(); - for (String s : data.militaryQueue) { - String[] split = s.split("\\."); - m.addQueueItem(m.getRegiment(split[0]), Integer.parseInt(split[1])); - } + for (String s : data.militaryQueue) { + String[] split = s.split("\\."); + m.addQueueItem(m.getRegiment(split[0]), Integer.parseInt(split[1])); + } - // --- Guild --- - if (data.guilds != null) { - for (GuildData gd : data.guilds) { + // --- Guild --- + if (data.guilds != null) { + for (GuildData gd : data.guilds) { - if(gd.loans != null) { - for (LoanData ld : gd.loans) { - FactionManager.addDBLoan(ld); + if(gd.loans != null) { + for (LoanData ld : gd.loans) { + FactionManager.addDBLoan(ld); + } } - } - Guild g = new Guild(gd, f); - g.rememberLeaderCharacter(gd.leaderCharacter, gd.leaderCharacterOf); + Guild g = new Guild(gd, f); + g.rememberLeaderCharacter(gd.leaderCharacter, gd.leaderCharacterOf); - // --- Bank --- - if ("true".equalsIgnoreCase(gd.bank)) { - Chunk c = Bukkit.getWorld(gd.world) - .getChunkAt(gd.xPos.intValue(), gd.zPos.intValue()); - g.setBank(new Bank(g, gd.balance != null ? gd.balance : 0.0, c)); - g.updateWealth(); - } + // --- Bank --- + if ("true".equalsIgnoreCase(gd.bank)) { + Chunk c = Bukkit.getWorld(gd.world) + .getChunkAt(gd.xPos.intValue(), gd.zPos.intValue()); + g.setBank(new Bank(g, gd.balance != null ? gd.balance : 0.0, c)); + } else if ("false".equalsIgnoreCase(gd.bank)) { + g.setBank(null); + } - // --- Upgrade Queue --- - if (gd.upgradeQueue != null) { - for (UpgradeExpansionData ued : gd.upgradeQueue) { - Upgrade upgrade = g.getUpgrade(ued.upgrade); - if (upgrade != null) { - g.addQueuedUpgrade(upgrade, ued.timeLeft); + // --- Upgrade Queue --- + if (gd.upgradeQueue != null) { + for (UpgradeExpansionData ued : gd.upgradeQueue) { + Upgrade upgrade = g.getUpgrade(ued.upgrade); + if (upgrade != null) { + g.addQueuedUpgrade(upgrade, ued.timeLeft); + } } } - } - f.getGuildHandler().addGuild(g); + f.getGuildHandler().addGuild(g); + // Restored upgrades and persistent modifiers count even without a saved bank. + g.updateWealth(); + } } - } - if (data.warReparationsObligations != null) { - for (WarReparationsObligationData obligationData : data.warReparationsObligations) { - if (obligationData == null - || obligationData.payeeFactionId == null - || obligationData.payeeFactionId.isBlank()) { - continue; - } - double percent = obligationData.incomePercent != null ? obligationData.incomePercent : 0.0; - int days = obligationData.daysRemaining != null ? obligationData.daysRemaining : 0; - if (percent <= 0 || days <= 0) { - continue; + if (data.warReparationsObligations != null) { + for (WarReparationsObligationData obligationData : data.warReparationsObligations) { + if (obligationData == null + || obligationData.payeeFactionId == null + || obligationData.payeeFactionId.isBlank()) { + continue; + } + double percent = obligationData.incomePercent != null ? obligationData.incomePercent : 0.0; + int days = obligationData.daysRemaining != null ? obligationData.daysRemaining : 0; + if (percent <= 0 || days <= 0) { + continue; + } + f.addWarReparationsObligation(new WarReparationsObligation( + obligationData.payeeFactionId, percent, days)); } - f.addWarReparationsObligation(new WarReparationsObligation( - obligationData.payeeFactionId, percent, days)); } - } - FactionManager.factions.add(f); - f.updateWealth(); + FactionManager.factions.add(f); + f.updateWealth(); - } catch (Exception e) { - e.printStackTrace(); + } catch (Exception e) { + e.printStackTrace(); + } } + } finally { + FactionManager.loading = wasLoading; } - FactionManager.loading = false; } /* ===================================================== diff --git a/src/main/java/net/tfminecraft/simplefactions/database/JsonUtil.java b/src/main/java/net/tfminecraft/simplefactions/database/JsonUtil.java index 00ff1ebc..4fa4d4b7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/JsonUtil.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/JsonUtil.java @@ -9,6 +9,7 @@ import com.google.gson.GsonBuilder; public class JsonUtil { + private JsonUtil() {} public static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); public static <T> T readJson(File file, Class<T> type) throws IOException { diff --git a/src/main/java/net/tfminecraft/simplefactions/diplomacy/DiplomacyQueries.java b/src/main/java/net/tfminecraft/simplefactions/diplomacy/DiplomacyQueries.java index a77aab3b..cd56dce8 100644 --- a/src/main/java/net/tfminecraft/simplefactions/diplomacy/DiplomacyQueries.java +++ b/src/main/java/net/tfminecraft/simplefactions/diplomacy/DiplomacyQueries.java @@ -26,9 +26,6 @@ public static boolean hasOfficialRelation(Faction origin, Faction target) { return true; } DiplomacyHandler handler = origin.getDiplomacyHandler(); - if (handler == null) { - return false; - } return handler.hasTradeRelation(target.getId()) || handler.hasTreatyRelation(target.getId()); } diff --git a/src/main/java/net/tfminecraft/simplefactions/diplomacy/Threshold.java b/src/main/java/net/tfminecraft/simplefactions/diplomacy/Threshold.java index 73110bed..95353506 100644 --- a/src/main/java/net/tfminecraft/simplefactions/diplomacy/Threshold.java +++ b/src/main/java/net/tfminecraft/simplefactions/diplomacy/Threshold.java @@ -10,6 +10,10 @@ public class Threshold { public Threshold(ConfigurationSection config) { type = config.getString("mode", "higher_than_or_equal_to"); + if (!type.equalsIgnoreCase("lower_than_or_equal_to") + && !type.equalsIgnoreCase("higher_than_or_equal_to")) { + throw new IllegalArgumentException("Unknown opinion threshold mode: " + type); + } opinion = config.getInt("amount", 20); mutual = config.getBoolean("mutual", false); } @@ -23,26 +27,14 @@ public boolean isMutual() { } public boolean fulfilled(int i) { - if(type.equalsIgnoreCase("lower_than_or_equal_to")) { - if(i > opinion) return false; - } - else if(type.equalsIgnoreCase("higher_than_or_equal_to")) { - if(i < opinion) return false; - } - return true; + return type.equalsIgnoreCase("lower_than_or_equal_to") ? i <= opinion : i >= opinion; } - + public String getFormattedType() { return (new String(type)).replace("_", " "); } public String getFormattedShort() { - if(type.equalsIgnoreCase("lower_than_or_equal_to")) { - return "<="; - } - else if(type.equalsIgnoreCase("higher_than_or_equal_to")) { - return ">="; - } - return "="; + return type.equalsIgnoreCase("lower_than_or_equal_to") ? "<=" : ">="; } } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java b/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java index b163ac0f..a3799085 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/CharacterNames.java @@ -89,13 +89,20 @@ private static String activeName(String playerName) { if (Files.isDirectory(folder)) { try (var files = Files.list(folder)) { for (Path file : files.filter(path -> path.toString().endsWith(".json")).sorted().toList()) { - JsonObject json = JsonUtil.GSON.fromJson(Files.readString(file), JsonObject.class); - if (json != null && json.has("active") && json.get("active").getAsBoolean() && json.has("name")) { - result = json.get("name").getAsString(); - break; + try { + JsonObject json = JsonUtil.GSON.fromJson(Files.readString(file), JsonObject.class); + if (json != null && json.has("active") && json.get("active").isJsonPrimitive() + && json.get("active").getAsBoolean() && json.has("name") + && json.get("name").isJsonPrimitive() && json.getAsJsonPrimitive("name").isString() + && !json.get("name").getAsString().isBlank()) { + result = json.get("name").getAsString(); + break; + } + } catch (java.io.IOException | com.google.gson.JsonParseException | IllegalStateException ignored) { + // A damaged older record must not hide a later active character. } } - } catch (java.io.IOException | com.google.gson.JsonParseException | IllegalStateException ignored) { + } catch (java.io.IOException ignored) { // Cache the absence too; account-oriented callers supply their own fallback. } } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java index 0b16004c..8a00d05d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageCommands.java @@ -53,7 +53,10 @@ public static boolean handle(Player player, String[] args) { } else if (args[0].equalsIgnoreCase("positions")) EspionageView.positions(player, faction, manager); else EspionageView.spymasterOffice(player, faction, manager); } else if (args[0].equalsIgnoreCase("spymaster") && args.length >= 2 && !args[1].equalsIgnoreCase("sabotage")) { - if (args[1].equalsIgnoreCase("remove")) EspionageService.remove(player, faction); + if (args[1].equalsIgnoreCase("remove")) { + if (args.length == 2) EspionageService.remove(player, faction); + else usage(player); + } else { var candidate = CharacterNames.resolveOnline(player, String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length))); if (candidate != null) EspionageService.appoint(player, faction, candidate); diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index bfea57c8..75bd6ef4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -256,7 +256,7 @@ public static boolean appoint(Player actor, Faction faction, Player candidate) { return false; } String characterId = OfficeCharacters.activeCharacterId(candidate); - if (characterId == null) { + if (characterId == null || OfficeCharacters.isDead(candidate.getUniqueId(), characterId)) { actor.sendMessage("§cThat member needs an active roleplay character."); return false; } diff --git a/src/main/java/net/tfminecraft/simplefactions/government/Council.java b/src/main/java/net/tfminecraft/simplefactions/government/Council.java index 19c5a87a..21bbc33c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/Council.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/Council.java @@ -73,8 +73,6 @@ public void reorganize() { case ELECTED_COUNCIL: // keep until next election break; - default: - break; } proposalHandler.clearProposals(); @@ -144,10 +142,13 @@ public boolean canBeMember(String name, boolean ignoreSize, boolean ignoreRefuse for(Guild guild : f.getGuildHandler().getGuilds()) { if(guild.isLeader(name)) return true; } - return true; + return false; } public boolean canRemainMember(String name) { + if (refuses.contains(name)) { + return false; + } if (f.getLeader().equalsIgnoreCase(name)) { return false; } @@ -272,7 +273,7 @@ private void cleanupCouncil() { getMembers().remove(member); } } - for(String refuser : refuses) { + for(String refuser : new ArrayList<>(refuses)) { if(!canBeMember(refuser, true, true)) { refuses.remove(refuser); } diff --git a/src/main/java/net/tfminecraft/simplefactions/government/Government.java b/src/main/java/net/tfminecraft/simplefactions/government/Government.java index 73cec1d7..574e1fba 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/Government.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/Government.java @@ -177,7 +177,7 @@ public void endMovement(Movement movement) { public Movement getMovementByLeader(String leader) { for (Movement movement : movements) { - if (movement.getLeader().equalsIgnoreCase(leader)) { + if (movement.getLeader() != null && movement.getLeader().equalsIgnoreCase(leader)) { return movement; } } @@ -709,14 +709,10 @@ public String getTimeUntilElectionEnds() { public boolean hasElections(Candidate type) { - switch (type) { - case LEADER: - return hasLeaderElections(); - case COUNCIL: - return hasCouncilElections(); - default: - return false; - } + return switch (type) { + case LEADER -> hasLeaderElections(); + case COUNCIL -> hasCouncilElections(); + }; } public boolean hasElection() { @@ -1010,22 +1006,17 @@ public void toggleRepress(Guild guild) { } public void validateFavoursAndRepressions() { + // Repression takes precedence if saved state contains both mutually exclusive flags. for(Guild guild : f.getGuildHandler().getGuilds()) { if(guild.isFavoured() && !canFavour(guild)) { guild.setFavoured(false); } - if(guild.isRepressed() && !canRepress(guild)) { - guild.setRepressed(false); - } } for(Faction vassal : f.getSubjects()) { Guild main = vassal.getOrCreateMainGuild(); if(main.isFavoured() && !canFavour(main)) { main.setFavoured(false); } - if(main.isRepressed() && !canRepress(main)) { - main.setRepressed(false); - } } } } diff --git a/src/main/java/net/tfminecraft/simplefactions/government/election/Election.java b/src/main/java/net/tfminecraft/simplefactions/government/election/Election.java index a07d1436..4346e629 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/election/Election.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/election/Election.java @@ -54,6 +54,7 @@ public void end() { this.active = false; validateVotes(); for(Candidate c : Candidate.values()) { + previousVotes.get(c).clear(); for(String candidate : candidates.get(c)) { previousVotes.get(c).put(candidate, getVotes(c, candidate)); } @@ -98,7 +99,8 @@ public void validateVotes() { if(!gov.hasElections(c)) continue; Map<String, String> voteMap = votes.get(c); for(String voter : new ArrayList<>(voteMap.keySet())) { - if(!canVote(voter)) { + // Existing ballots are valid even though their voter cannot submit a second ballot. + if(!eligibleVoters.contains(voter) || !gov.getFaction().canVote(voter)) { voteMap.remove(voter); } else { String candidate = voteMap.get(voter); @@ -249,10 +251,9 @@ public boolean canBeCandidate(Candidate type, String player, boolean includeAlre break; case COUNCIL: if(candidates.get(Candidate.COUNCIL).contains(player) && includeAlreadySignedUp) return false; + if(!gov.getCouncil().canRemainMember(player)) return false; if(otherCandidateExists(Candidate.COUNCIL, player)) return false; break; - default: - return true; } return true; } diff --git a/src/main/java/net/tfminecraft/simplefactions/government/handler/ProposalHandler.java b/src/main/java/net/tfminecraft/simplefactions/government/handler/ProposalHandler.java index 9d0f5502..ba461bc4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/handler/ProposalHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/handler/ProposalHandler.java @@ -8,7 +8,9 @@ import org.bukkit.Bukkit; import org.bukkit.entity.Player; -import net.tfminecraft.simplefactions.loaders.LawLoader; +import net.tfminecraft.simplefactions.loaders.PoliticalActionLoader; +import net.tfminecraft.simplefactions.government.movement.Action; +import net.tfminecraft.simplefactions.government.movement.PoliticalAction; import net.tfminecraft.simplefactions.government.Government; import net.tfminecraft.simplefactions.government.proposal.FeeChange; import net.tfminecraft.simplefactions.government.proposal.FeeKind; @@ -68,7 +70,8 @@ public boolean canBeProposed(Proposal proposal) { for(Proposal p : proposals) { if(!p.isTaxProposal()) continue; TaxLawChange c = p.getTaxChange(); - if(c.getTarget().equals(change.getTarget()) && c.getId().equalsIgnoreCase(change.getId())) return false; + if(c.getTarget().equals(change.getTarget()) && (c.getId() == null + ? change.getId() == null : c.getId().equalsIgnoreCase(change.getId()))) return false; } } return true; @@ -107,6 +110,9 @@ public List<String> serializeProposals() { FeeChange fee = p.getFeeChange(); result.add(p.getProposer() + ":fee:" + fee.getKind().name() + ":" + (fee.isGeneral() ? ALL_VEHICLES : fee.getVehicleTypeId()) + ":" + fee.getNewRate()); + } else if (p.isPoliticalActionProposal()) { + result.add(p.getProposer() + ":action:" + p.getPoliticalAction().getAction().name() + + ":" + (p.getTarget() == null ? "" : p.getTarget())); } } return result; @@ -115,8 +121,11 @@ public List<String> serializeProposals() { public void restoreProposals(net.tfminecraft.simplefactions.objects.Faction faction, List<String> serialized) { proposals.clear(); for (String s : serialized) { - String proposer = s.split(":", 2)[0]; - s = s.substring(proposer.length() + 1); + if (s == null) continue; + int separator = s.indexOf(':'); + if (separator <= 0) continue; + String proposer = s.substring(0, separator); + s = s.substring(separator + 1); if (s.startsWith("law:")) { String[] parts = s.substring(4).split(":"); if (parts.length >= 2) { @@ -143,20 +152,35 @@ public void restoreProposals(net.tfminecraft.simplefactions.objects.Faction fact FeeKind kind = FeeKind.valueOf(body.substring(0, first)); String typeField = body.substring(first + 1, last); String type = ALL_VEHICLES.equals(typeField) ? null : typeField; + double newRate = Double.parseDouble(body.substring(last + 1)); + if (!Double.isFinite(newRate)) continue; Proposal p = new Proposal(proposer, gov); - p.setFeeProposal(new FeeChange(kind, type, Double.parseDouble(body.substring(last + 1)))); + p.setFeeProposal(new FeeChange(kind, type, newRate)); proposals.add(p); } catch (Exception e) { // Skip malformed proposals } } + } else if (s.startsWith("action:")) { + String[] parts = s.substring(7).split(":", 2); + try { + Action kind = Action.valueOf(parts[0]); + PoliticalAction action = PoliticalActionLoader.getByAction(kind); + Proposal proposal = new Proposal(proposer, gov); + proposal.setPoliticalActionProposal(action != null ? action : new PoliticalAction(kind)); + if (parts.length == 2 && !parts[1].isEmpty()) proposal.setTarget(parts[1]); + proposals.add(proposal); + } catch (IllegalArgumentException e) { + // Skip actions that do not exist in this version. + } } else if (s.startsWith("tax:")) { String[] parts = s.substring(4).split(":"); if (parts.length >= 3) { try { net.tfminecraft.simplefactions.government.proposal.TaxTarget target = net.tfminecraft.simplefactions.government.proposal.TaxTarget.valueOf(parts[0]); - String taxId = parts[1]; + String taxId = "null".equals(parts[1]) ? null : parts[1]; double newRate = Double.parseDouble(parts[2]); + if (!Double.isFinite(newRate)) continue; TaxLawChange tax = new TaxLawChange(target, taxId, newRate); Proposal p = new Proposal(proposer, gov); diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/CoupService.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/CoupService.java index c0d0f072..299febac 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/CoupService.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/CoupService.java @@ -26,9 +26,6 @@ public static void apply(Faction faction, String wantedLeader) { private static void adjustCouncil(Faction faction) { LawHandler laws = faction.getLawHandler(); - if (laws == null) { - return; - } LawGroup group = laws.getGroup(GOVERNMENT_GROUP); if (group == null || group.getCurrent() == null || group.getCurrent().getId() == null) { return; @@ -52,9 +49,6 @@ private static void adjustCouncil(Faction faction) { private static void clearCouncil(Faction faction) { Government government = faction.getGovernment(); - if (government == null || government.getCouncil() == null) { - return; - } Council council = government.getCouncil(); council.clearMembers(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/Movement.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/Movement.java index 05bfd726..10317d80 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/Movement.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/Movement.java @@ -207,9 +207,6 @@ public String foreignBackerBlockReason(Faction faction, boolean staff) { if (RelationManager.sameRealm(faction, f)) { return MovementJoinCopy.backerSameRealm(staff, faction, f); } - if (faction.getId().equalsIgnoreCase(f.getId())) { - return MovementJoinCopy.backerOwnFaction(staff, f); - } for (Movement otherMovement : f.getGovernment().getMovements()) { if (otherMovement.getId().equals(this.id)) continue; if (otherMovement.getForeignBackers().contains(faction)) { @@ -328,6 +325,9 @@ public boolean canJoin(Object obj, Cause cause) { public void join(Object obj, Cause cause) { if (frozen) return; + if (obj instanceof String citizen && isMember(citizen)) return; + if (obj instanceof Guild guild && isMember(guild.getLeader())) return; + if (obj instanceof Faction faction && isMember(faction.getLeader())) return; double power = getPower(); if(cause == null) { // Joining as general supporter @@ -370,13 +370,7 @@ public void leave(Object obj, Cause cause) { } public void joinAsForeignBacker(Faction backer) { - if (!canForeignBackerJoin(backer, false)) return; - - // Check if already backing another movement in this faction - for (Movement otherMovement : f.getGovernment().getMovements()) { - if (otherMovement.getId().equals(this.id)) continue; - if (otherMovement.getForeignBackers().contains(backer)) return; - } + if (foreignBackers.contains(backer) || !canForeignBackerJoin(backer, false)) return; foreignBackers.add(backer); } @@ -426,7 +420,7 @@ public List<Cause> getCauses() { public Cause getCauseByLeader(String leaderName) { for (Cause cause : causes) { - if (cause.getLeader().equalsIgnoreCase(leaderName)) { + if (cause.getLeader() != null && cause.getLeader().equalsIgnoreCase(leaderName)) { return cause; } } diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/MovementOutcomeService.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/MovementOutcomeService.java index 9f417c8c..e6d3abf6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/MovementOutcomeService.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/MovementOutcomeService.java @@ -17,16 +17,16 @@ public static void apply(Movement movement, MovementOutcomeSource source) { if (movement == null || source == null) { return; } + Faction faction = movement.getFaction(); + if (faction == null) { + return; + } LogManager.movement( "OUTCOME movementId=%s faction=%s source=%s power=%.1f", movement.getId(), - movement.getFaction() != null ? movement.getFaction().getId() : "-", + faction.getId(), source.name(), movement.getPower()); - Faction faction = movement.getFaction(); - if (faction == null) { - return; - } Government government = faction.getGovernment(); if (government != null) { StabilityModifier modifier = stabilityFor(movement, source); @@ -36,9 +36,6 @@ public static void apply(Movement movement, MovementOutcomeSource source) { } for (Cause cause : orderedCauses(movement)) { Proposal proposal = cause.getProposal(); - if (proposal == null) { - continue; - } proposal.apply(cause); } if (government != null) { @@ -120,7 +117,7 @@ private static boolean isLawOrTax(Cause cause) { private static Action actionOf(Cause cause) { Proposal proposal = cause.getProposal(); - if (proposal == null || proposal.getPoliticalAction() == null) { + if (proposal.getPoliticalAction() == null) { return null; } return proposal.getPoliticalAction().getAction(); diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/PoliticalAction.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/PoliticalAction.java index 51a4ed11..97ae5c91 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/PoliticalAction.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/PoliticalAction.java @@ -21,7 +21,7 @@ public class PoliticalAction { public PoliticalAction(String key, ConfigurationSection config) { try { - action = Action.valueOf(key.toUpperCase()); + action = Action.valueOf(key.toUpperCase(java.util.Locale.ROOT)); } catch (IllegalArgumentException e) { throw new IllegalArgumentException("Invalid political action key: " + key); } diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminService.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminService.java index 981f0bd9..ffe15a6d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminService.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminService.java @@ -41,7 +41,7 @@ public static List<String> listLines() { return lines; } for (Faction faction : FactionManager.factions) { - if (faction == null || faction.getGovernment() == null) { + if (faction == null) { continue; } for (Movement movement : faction.getGovernment().getMovements()) { @@ -131,7 +131,9 @@ public static Result target(String movementId, String causeIndex, String player) if (cause == null) { return new Result(false, "§cUnknown cause index: " + causeIndex); } - if (cause.getProposal() == null || !cause.getProposal().needsTarget()) { + if (cause.getProposal() == null || !cause.getProposal().isPoliticalActionProposal() + || cause.getProposal().getPoliticalAction().getAction() + != net.tfminecraft.simplefactions.government.movement.Action.CHANGE_LEADER) { return new Result(false, "§cThat cause does not take a wanted leader."); } if (player == null || player.isBlank()) { @@ -147,16 +149,13 @@ public static Result target(String movementId, String causeIndex, String player) } private static String missingTargetReason(Movement movement) { - if (movement.getCauses() == null) { - return null; - } for (Cause cause : movement.getCauses()) { - if (cause == null || cause.getProposal() == null) { + if (cause == null) { continue; } - if (cause.getProposal().isPoliticalActionProposal() && cause.getProposal().needsTarget() - && !cause.getProposal().hasTarget()) { - return "§cOne or more causes lack a target."; + if (cause.getProposal().isPoliticalActionProposal() && cause.getProposal().needsTarget()) { + if (!cause.getProposal().hasTarget()) return "§cOne or more causes lack a target."; + if (!cause.getProposal().checkTarget()) return "§cOne or more causes have a target that is no longer valid."; } } return null; @@ -277,7 +276,7 @@ private static Object resolveMember(String type, String target) { if (type == null || target == null || target.isBlank()) { return null; } - return switch (type.toLowerCase()) { + return switch (type.toLowerCase(java.util.Locale.ROOT)) { case "citizen" -> target; case "guild" -> FactionManager.getGuildByString(target); case "vassal" -> FactionManager.getByString(target); @@ -293,10 +292,7 @@ private static boolean isPresent(Movement movement, Cause cause, Object obj) { if (obj instanceof Guild guild) { return pool.getGuilds().contains(guild); } - if (obj instanceof Faction faction) { - return pool.getFactions().contains(faction); - } - return false; + return pool.getFactions().contains((Faction) obj); } private static String formatListLine(Faction host, Movement movement) { @@ -335,7 +331,7 @@ public static List<String> allMovementIds() { return ids; } for (Faction faction : FactionManager.factions) { - if (faction == null || faction.getGovernment() == null) { + if (faction == null) { continue; } for (Movement movement : faction.getGovernment().getMovements()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementCommandManager.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementCommandManager.java index ff3cea30..918684d2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementCommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementCommandManager.java @@ -27,7 +27,7 @@ public boolean onCommand(CommandSender sender, Command command, String label, St player.sendMessage(MovementAdminService.USAGE); return true; } - String sub = args[1].toLowerCase(); + String sub = args[1].toLowerCase(java.util.Locale.ROOT); return switch (sub) { case "list" -> { for (String line : MovementAdminService.listLines()) { @@ -78,7 +78,7 @@ private boolean handleMutate(Player player, boolean joining, String[] args) { return true; } String movementId = args[2]; - String slot = args[3].toLowerCase(); + String slot = args[3].toLowerCase(java.util.Locale.ROOT); MovementAdminService.Result result; if (slot.equals("backer")) { if (args.length < 5) { diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementTabCompletion.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementTabCompletion.java index d35e1690..a1ba2ac0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementTabCompletion.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/admin/MovementTabCompletion.java @@ -30,7 +30,7 @@ public List<String> onTabComplete(CommandSender sender, Command command, String if (args.length == 2) { return prefix(args[1], "list", "join", "leave", "demands", "target"); } - String action = args[1].toLowerCase(); + String action = args[1].toLowerCase(java.util.Locale.ROOT); if (action.equals("target")) { if (args.length == 3) { return prefixList(args[2], MovementAdminService.allMovementIds()); @@ -75,7 +75,7 @@ public List<String> onTabComplete(CommandSender sender, Command command, String if (movement == null) { return List.of(); } - String slot = args[3].toLowerCase(); + String slot = args[3].toLowerCase(java.util.Locale.ROOT); if (slot.equals("backer")) { if (args.length == 5) { return prefixList(args[4], MovementAdminService.otherFactionIds(movement)); @@ -106,10 +106,7 @@ public List<String> onTabComplete(CommandSender sender, Command command, String } private static List<String> targets(Movement movement, String memberType) { - if (memberType == null) { - return List.of(); - } - return switch (memberType.toLowerCase()) { + return switch (memberType.toLowerCase(java.util.Locale.ROOT)) { case "citizen" -> MovementAdminService.hostCitizenNames(movement); case "guild" -> MovementAdminService.hostGuildIds(movement); case "vassal" -> MovementAdminService.hostSubjectIds(movement); @@ -119,9 +116,9 @@ private static List<String> targets(Movement movement, String memberType) { private static List<String> prefix(String typed, String... candidates) { List<String> completions = new ArrayList<>(); - String lower = typed == null ? "" : typed.toLowerCase(); + String lower = typed == null ? "" : typed.toLowerCase(java.util.Locale.ROOT); for (String candidate : candidates) { - if (candidate.toLowerCase().startsWith(lower)) { + if (candidate.toLowerCase(java.util.Locale.ROOT).startsWith(lower)) { completions.add(candidate); } } @@ -130,9 +127,9 @@ private static List<String> prefix(String typed, String... candidates) { private static List<String> prefixList(String typed, List<String> candidates) { List<String> completions = new ArrayList<>(); - String lower = typed == null ? "" : typed.toLowerCase(); + String lower = typed == null ? "" : typed.toLowerCase(java.util.Locale.ROOT); for (String candidate : candidates) { - if (candidate != null && candidate.toLowerCase().startsWith(lower)) { + if (candidate != null && candidate.toLowerCase(java.util.Locale.ROOT).startsWith(lower)) { completions.add(candidate); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/cause/Cause.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/cause/Cause.java index c156b991..6fc239d1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/cause/Cause.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/cause/Cause.java @@ -118,13 +118,13 @@ public void checkMembers(Pool pool) { for (Guild guild : new ArrayList<>(pool.getGuilds())) { if (!canGuildJoin(guild, false, false)) { - pool.remove("guild", guild.getName()); + pool.removeGuild(guild); } } for (Faction faction : new ArrayList<>(pool.getFactions())) { if (!canFactionJoin(faction, false, false)) { - pool.remove("faction", faction.getName()); + pool.removeFaction(faction); } } } diff --git a/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java b/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java index a8dd05cb..60c31fac 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java @@ -78,10 +78,7 @@ public boolean checkTarget() { Faction faction = gov.getFaction(); return faction != null && faction.canBecomeLeader(target); } - if (isPoliticalActionProposal() && CouncilPeaceQueries.isWarEndAction(action.getAction())) { - return CouncilPeaceQueries.isValidTarget(gov.getFaction(), target); - } - return false; + return CouncilPeaceQueries.isValidTarget(gov.getFaction(), target); } public boolean hasTarget() { diff --git a/src/main/java/net/tfminecraft/simplefactions/government/session/Session.java b/src/main/java/net/tfminecraft/simplefactions/government/session/Session.java index 57158a0c..37011281 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/session/Session.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/session/Session.java @@ -26,9 +26,11 @@ public class Session { private Faction f; private Council c; private boolean started = false; + private boolean terminated; private int time = 0; private Map<Proposal, VoteResult> proposals = new LinkedHashMap<>(); + private final Map<Proposal, int[]> decidedVoteCounts = new HashMap<>(); private SessionHologram hologram; private Map<String, Vote> currentVotes = new HashMap<>(); // Player name -> Vote private Set<String> eligibleVoters = new HashSet<>(); // Council members + leader @@ -43,6 +45,7 @@ public Session(Player leader, Faction f) { } public void tick() { + if (terminated) return; time++; if (time >= 60 && !started) { cancel(); @@ -50,7 +53,7 @@ public void tick() { } public void onLanternClick(Block lanternBlock) { - if (started) return; + if (started || terminated) return; this.lantern = lanternBlock; start(); @@ -61,6 +64,7 @@ public boolean isStarted() { } private void nextProposal() { + if (terminated) return; Proposal proposal = c.getProposalHandler().pop(); if (proposal == null) { end(); @@ -93,7 +97,7 @@ private void initializeVoting() { } public boolean recordVote(String playerName, Vote vote) { - if (!eligibleVoters.contains(playerName)) { + if (!started || terminated || getCurrentProposal() == null || !eligibleVoters.contains(playerName)) { return false; } currentVotes.put(playerName, vote); @@ -128,12 +132,14 @@ private int[] getVoteCounts() { } public void countVotes() { + if (terminated) return; int[] counts = getVoteCounts(); int yay = counts[0]; int nay = counts[1]; Proposal currentProposal = getCurrentProposal(); if (currentProposal == null) return; + decidedVoteCounts.put(currentProposal, counts); VoteResult result; if (yay > nay) { @@ -182,6 +188,7 @@ public Proposal getCurrentProposal() { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void start() { + if (started || terminated) return; if (!c.hasEnoughValidVoters()) { leader.sendMessage("§cNot enough council members online! At least 75% must be present."); cancel(); @@ -192,19 +199,19 @@ public void start() { leader.playSound(leader, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); hologram = new SessionHologram(lantern); hologram.create(); - nextProposal(); started = true; + nextProposal(); } public void kill() { - if (hologram != null) { - hologram.destroy(); - } + endSession(); } // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void end() { + if (terminated) return; + endSession(); // Create session report SessionReport report = new SessionReport(leader.getName(), f); @@ -212,9 +219,7 @@ public void end() { for(Proposal p : proposals.keySet()) { VoteResult result = proposals.get(p); if (!result.equals(VoteResult.IN_PROGRESS)) { - // Find votes for this proposal (reconstruct from currentVotes if needed) - // For now we'll get them from the last counts when the proposal was decided - int[] counts = getVoteCounts(); + int[] counts = decidedVoteCounts.get(p); report.addResult(p, result, counts[0], counts[1], counts[2]); } } @@ -243,11 +248,15 @@ public void end() { // Give leader the report book if (leader != null && leader.isOnline()) { - leader.getInventory().addItem(book); - leader.sendMessage("§aSession report added to your inventory!"); + Map<Integer, ItemStack> leftovers = leader.getInventory().addItem(book); + for (ItemStack remaining : leftovers.values()) { + leader.getWorld().dropItemNaturally(leader.getLocation(), remaining); + } + leader.sendMessage(leftovers.isEmpty() + ? "§aSession report added to your inventory!" + : "§aYour inventory was full; the session report was dropped at your feet."); } - endSession(); } private void updateHologram() { @@ -262,11 +271,14 @@ private void updateHologram() { } private void endSession() { + if (terminated) return; + terminated = true; + started = false; if (hologram != null) { hologram.destroy(); } SessionManager sessionManager = SimpleFactions.getInstance().getSessionManager(); - sessionManager.endSession(f.getGovernment().getCouncil()); + if (sessionManager.getSession(c) == this) sessionManager.endSession(c); } private void cancel() { diff --git a/src/main/java/net/tfminecraft/simplefactions/government/session/SessionReport.java b/src/main/java/net/tfminecraft/simplefactions/government/session/SessionReport.java index ce80132c..d991019c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/session/SessionReport.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/session/SessionReport.java @@ -26,7 +26,7 @@ private static class ProposalResult { Proposal proposal; VoteResult result; int yay, nay, abstain; - + ProposalResult(Proposal proposal, VoteResult result, int yay, int nay, int abstain) { this.proposal = proposal; this.result = result; @@ -35,26 +35,26 @@ private static class ProposalResult { this.abstain = abstain; } } - + private List<ProposalResult> results = new ArrayList<>(); private String leaderName; private Faction faction; - + public SessionReport(String leaderName, Faction faction) { this.leaderName = leaderName; this.faction = faction; } - + public void addResult(Proposal proposal, VoteResult result, int yay, int nay, int abstain) { results.add(new ProposalResult(proposal, result, yay, nay, abstain)); } - + // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public ItemStack generateReportBook() { ItemStack item = new ItemStack(Material.WRITTEN_BOOK); BookMeta meta = (BookMeta) item.getItemMeta(); - + // Format date: dd/mm/yyyy Calendar cal = Calendar.getInstance(); int day = cal.get(Calendar.DAY_OF_MONTH); @@ -63,77 +63,69 @@ public ItemStack generateReportBook() { meta.setDisplayName(StringFormatter.formatHex("#68ab6fCouncil Session #819483" + dateStr)); meta.setTitle(StringFormatter.formatHex("#68ab6fCouncil Session #819483" + dateStr)); meta.setAuthor(faction.getRulerTitle() + " " + leaderName); - + List<String> pages = new ArrayList<>(); StringBuilder currentPage = new StringBuilder(); - + // Title page currentPage.append("§6§lCouncil Session§r§0\n"); currentPage.append(dateStr + "§r\n\n"); currentPage.append("Total Proposals\n"); currentPage.append("" + results.size() + "\n\n"); - + int passed = 0, failed = 0, tied = 0; for (ProposalResult pr : results) { if (pr.result == VoteResult.PASSED) passed++; else if (pr.result == VoteResult.FAILED) failed++; else if (pr.result == VoteResult.TIE) tied++; } - + currentPage.append("§aPassed§0: " + passed + "\n"); currentPage.append("§cFailed§0: " + failed + "\n"); currentPage.append("§7Tied§0: " + tied); - + pages.add(currentPage.toString()); - + // Proposal detail pages int proposalNum = 1; for (ProposalResult pr : results) { currentPage = new StringBuilder(); - - String resultText = pr.result == VoteResult.PASSED ? "§aPASSED" : + + String resultText = pr.result == VoteResult.PASSED ? "§aPASSED" : pr.result == VoteResult.FAILED ? "§cFAILED" : "§7TIED"; - + currentPage.append("§6§lProposal " + proposalNum + " - " + resultText + "§r\n\n"); - + // Get proposal details if (pr.proposal.isLawProposal()) { Law law = pr.proposal.getLaw(); - if (law != null) { - LawGroup group = faction.getLawHandler().getGroup(law.getGroup()); - String groupName = group != null ? group.getName() : law.getGroup(); - String oldLaw = group != null && group.getCurrent() != null ? group.getCurrent().getName() : "Unknown"; - String newLaw = law.getName(); - - currentPage.append("Type: Law\n"); - currentPage.append("Group: ").append(groupName).append("§r\n\n"); - currentPage.append(oldLaw).append(" §0→ ").append(newLaw); - } + LawGroup group = faction.getLawHandler().getGroup(law.getGroup()); + String groupName = group != null ? group.getName() : law.getGroup(); + String oldLaw = group != null && group.getCurrent() != null ? group.getCurrent().getName() : "Unknown"; + String newLaw = law.getName(); + + currentPage.append("Type: Law\n"); + currentPage.append("Group: ").append(groupName).append("§r\n\n"); + currentPage.append(oldLaw).append(" §0→ ").append(newLaw); } else if (pr.proposal.isTaxProposal()) { TaxLawChange taxChange = pr.proposal.getTaxChange(); - if (taxChange != null) { - TaxTarget target = taxChange.getTarget(); - String name = target != null ? target.getDisplayName() : "Unknown"; - String type = ""; - - if (target == TaxTarget.GUILD_ID) { - Guild guild = FactionManager.getGuildByString(taxChange.getId()); - if (guild != null) name = guild.getName(); - } else if (target == TaxTarget.VASSAL_ID) { - name = FactionManager.getByString(taxChange.getId()).getName(); - } else if (target == TaxTarget.TARIFF_ID) { - name = FactionManager.getByString(taxChange.getId()).getName(); - } - - double oldRate = faction.getTaxRate(target, taxChange.getId(), false); - if (oldRate == -1.0) { - oldRate = faction.getTaxRate(target, null, false); - } - - currentPage.append("Type: Tax\n"); - currentPage.append("Target: ").append(name).append("§r\n\n"); - currentPage.append(String.format("%.0f", oldRate)).append("% §0→ ").append(taxChange.getNewTax()).append("%"); + TaxTarget target = taxChange.getTarget(); + String name = target != null ? target.getDisplayName() : "Unknown"; + String type = ""; + + if (target == TaxTarget.GUILD_ID) { + Guild guild = FactionManager.getGuildByString(taxChange.getId()); + name = guild != null ? guild.getName() : taxChange.getId(); + } else if (target == TaxTarget.VASSAL_ID || target == TaxTarget.TARIFF_ID) { + Faction targetFaction = FactionManager.getByString(taxChange.getId()); + name = targetFaction != null ? targetFaction.getName() : taxChange.getId(); } + + double oldRate = faction.getTaxRate(target, taxChange.getId(), false); + + currentPage.append("Type: Tax\n"); + currentPage.append("Target: ").append(name).append("§r\n\n"); + currentPage.append(String.format("%.0f", oldRate)).append("% §0→ ").append(taxChange.getNewTax()).append("%"); } else if (pr.proposal.isFeeProposal()) { FeeChange fee = pr.proposal.getFeeChange(); double oldRate = faction.getVehicleFeeHandler().getRate(fee.getKind(), fee.getVehicleTypeId()); @@ -143,16 +135,16 @@ public ItemStack generateReportBook() { currentPage.append(fee.getKind().formatRate(oldRate)).append(" §0→ ") .append(fee.getKind().formatRate(fee.getNewRate())); } - + currentPage.append("\n\n§6Votes\n"); currentPage.append("§a").append(pr.yay).append(" §aYay\n"); currentPage.append("§c").append(pr.nay).append(" §cNay\n"); currentPage.append("§7").append(pr.abstain).append(" §7Abstain"); - + pages.add(currentPage.toString()); proposalNum++; } - + meta.setPages(pages); item.setItemMeta(meta); return item; diff --git a/src/main/java/net/tfminecraft/simplefactions/government/stability/GovernmentIncompatibility.java b/src/main/java/net/tfminecraft/simplefactions/government/stability/GovernmentIncompatibility.java index 7b7e225d..ae710d31 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/stability/GovernmentIncompatibility.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/stability/GovernmentIncompatibility.java @@ -50,10 +50,9 @@ private static Guild realm(Faction host) { private static String law(Faction faction, String groupId, String fallback) { LawHandler laws = faction.getLawHandler(); - if (laws == null) return fallback; LawGroup group = laws.getGroup(groupId); if (group == null || group.getCurrent() == null || group.getCurrent().getId() == null) return fallback; Law current = group.getCurrent(); - return current.getId().toLowerCase(); + return current.getId().toLowerCase(java.util.Locale.ROOT); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/government/stability/StabilityMath.java b/src/main/java/net/tfminecraft/simplefactions/government/stability/StabilityMath.java index 0f1f9124..1f5da6d0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/stability/StabilityMath.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/stability/StabilityMath.java @@ -128,7 +128,7 @@ public static double baseLegitimacy(StabilityFacts facts, StabilityTuning tuning public static double weight(String stance, StabilityTuning tuning) { if (stance == null) return 1; - return switch (stance.toUpperCase()) { + return switch (stance.toUpperCase(java.util.Locale.ROOT)) { case "OPPOSE" -> 0; case "NEUTRAL" -> tuning.neutralWeight; default -> 1; @@ -196,7 +196,7 @@ private static void explain(StabilityFacts facts, StabilityTuning tuning, Stabil } private static String id(String value) { - return value == null ? "" : value.toLowerCase(); + return value == null ? "" : value.toLowerCase(java.util.Locale.ROOT); } private static double clamp(double value) { diff --git a/src/main/java/net/tfminecraft/simplefactions/government/stability/StateStability.java b/src/main/java/net/tfminecraft/simplefactions/government/stability/StateStability.java index 818ffe7f..1cbbe463 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/stability/StateStability.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/stability/StateStability.java @@ -24,7 +24,7 @@ public static StabilityFacts facts(Faction faction) { return facts; } facts.government = law(faction, "government", "autocracy"); - facts.electedLeadership = "elected".equals(law(faction, "leadership", "fixed")); + facts.electedLeadership = "elected".equalsIgnoreCase(law(faction, "leadership", "fixed")); facts.provinces = faction.getProvinces() == null ? 0 : faction.getProvinces().size(); facts.bankrupt = faction.getOrCreateMainGuild() != null && faction.getOrCreateMainGuild().isBankrupt(); if (faction.getGovernment() != null && faction.getGovernment().getStabilityModifiers() != null) { @@ -51,9 +51,7 @@ public static StabilityFacts facts(Faction faction) { Body body = new Body(); body.name = vassal.getName() == null ? vassal.getId() : vassal.getName(); body.members = vassal.getMembers() == null ? 0 : vassal.getMembers().size(); - body.stance = main == null || main.getStance(faction) == null - ? "SUPPORT" - : main.getStance(faction).name(); + body.stance = main.getStance(faction).name(); facts.vassals.add(body); } return facts; diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index 765775a7..aa2a3819 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -120,10 +120,10 @@ public Guild(Faction f) { members.add(leader); type = GuildLoader.getBaseType(); this.wealth = 0.0; - int group = 0; - while(BranchLoader.getByGroup(this, group) != null) { - branches.put(group, new Branch(BranchLoader.getByGroup(this, group), 0)); - group++; + for (Branch branch : BranchLoader.getList()) { + if (branch.isAllowed(type)) { + branches.putIfAbsent(branch.getGroup(), new Branch(branch, 0)); + } } for(Upgrade u : UpgradeLoader.getList()) { upgrades.put(u.getId(), new Upgrade(u, 0)); @@ -151,10 +151,10 @@ public Guild(String id, Player p, Faction f, int province) { this.type = GuildLoader.getDefaultType(); this.capital = province; this.wealth = 0.0; - int group = 0; - while(BranchLoader.getByGroup(this, group) != null) { - branches.put(group, new Branch(BranchLoader.getByGroup(this, group), 0)); - group++; + for (Branch branch : BranchLoader.getList()) { + if (branch.isAllowed(type)) { + branches.putIfAbsent(branch.getGroup(), new Branch(branch, 0)); + } } for(Upgrade u : UpgradeLoader.getList()) { upgrades.put(u.getId(), new Upgrade(u, 0)); @@ -301,6 +301,7 @@ public void relocate( .onGuildRelocateTo(actor, this, newCapital, settlementNameOpt); actor.sendMessage(result.getMessage()); } + newFaction.updateWealth(); } public CapitalResult relocateWithinFaction(Player actor, int newCapital, String settlementNameOpt) { @@ -309,10 +310,9 @@ public CapitalResult relocateWithinFaction(Player actor, int newCapital, String } Faction faction = host; - setCapital(newCapital); - CapitalResult result = faction.getSettlementHandler() .onGuildRelocateTo(actor, this, newCapital, settlementNameOpt); + if (result.isSuccess()) setCapital(newCapital); if(actor != null) { actor.sendMessage(result.getMessage()); } @@ -488,7 +488,8 @@ public void setLeader(String leader) { this.leader = leader; } public boolean isLeader(String p) { - return leader.equalsIgnoreCase(p); + String currentLeader = getLeader(); + return currentLeader != null && currentLeader.equalsIgnoreCase(p); } public boolean isLeader(Player p) { return isLeader(p.getName()); } public Map<Integer, Branch> getBranches() { return branches; } @@ -633,11 +634,10 @@ public void setWealth(Double wealth) { } public void updateWealth() { - if(bank == null) return; wealth = 0.0; - addWealthModifier(new Modifier("Bank", bank.getWealth(), false)); + addWealthModifier(new Modifier("Bank", bank == null ? 0.0 : bank.getWealth(), false)); double spent = getTotalExpansionSpent(); - if(spent > 0) addWealthModifier(new Modifier("Expansions", spent, false)); + addWealthModifier(new Modifier("Expansions", spent, false)); for(Modifier p : wealthModifiers) { wealth = wealth + p.getAmount(); } @@ -702,7 +702,7 @@ public double getTotalExpansionSpent() { double baseCost = Cache.branchUpgradeCost; double r = Cache.branchUpgradeExponent; - double total = baseCost * (Math.pow(r, size) - 1) / (r - 1); + double total = r == 1.0 ? baseCost * size : baseCost * (Math.pow(r, size) - 1) / (r - 1); return Math.round(total * 100.0) / 100.0; } @@ -713,7 +713,7 @@ public double getTotalPossibleRefund() { double baseCost = Cache.branchUpgradeCost; double r = Cache.branchUpgradeExponent; - double totalRefund = 0.8 * baseCost * (Math.pow(r, size) - 1) / (r - 1); + double totalRefund = r == 1.0 ? 0.8 * baseCost * size : 0.8 * baseCost * (Math.pow(r, size) - 1) / (r - 1); return Math.round(totalRefund * 100.0) / 100.0; } @@ -929,7 +929,7 @@ public boolean canBeElevated(Player p) { } public void convert(GuildType type) { - if (this.type == GuildLoader.getBaseType()) { + if (isBase()) { this.capital = getCapital(); this.id = getId(); this.leader = getLeader(); @@ -973,8 +973,8 @@ public Faction toLandless(boolean subjugate) { setCapital(-1); host.getGuildHandler().removeGuild(id); clearFavoursAndRepressions(); - Faction landless = new Faction(this); Faction old = host; + Faction landless = new Faction(this); host = landless; FactionManager.addFaction(landless); if(subjugate) RelationManager.setRelation(null, RelationLoader.getElevationTarget(), landless, old, false); @@ -1006,8 +1006,8 @@ public Faction elevate(boolean subjugate) { if(!canBeElevated(null)) return null; host.getGuildHandler().removeGuild(id); clearFavoursAndRepressions(); - Faction elevated = new Faction(this); Faction old = host; + Faction elevated = new Faction(this); host = elevated; elevated.getProvinceHandler().addProvince(capital); InstallationTransferService.transfer(old, elevated, capital); @@ -1091,11 +1091,10 @@ public boolean canLiquidate() { public void liquidateRandom() { if (!canLiquidate() || bank == null) return; - int target = (int) Math.floor(Math.random()*branches.size()); - Branch b = branches.values().stream().filter(br -> br.getGroup() == target && br.getLevel() > 0).findFirst().orElse(null); - if(b == null) return; - b.levelDown(); + List<Branch> expanded = branches.values().stream().filter(br -> br.getLevel() > 0).toList(); + Branch b = expanded.get((int) (Math.random() * expanded.size())); double refund = getRefund(); + b.levelDown(); bank.deposit(refund); } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/GuildModifierOverride.java b/src/main/java/net/tfminecraft/simplefactions/guild/GuildModifierOverride.java index eeae5d14..3cdb8b9b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/GuildModifierOverride.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/GuildModifierOverride.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.guild; import java.util.Map; +import java.util.HashMap; import net.tfminecraft.simplefactions.enums.GuildModifier; @@ -16,7 +17,7 @@ public final class GuildModifierOverride { private GuildModifierOverride(Guild guild, Map<GuildModifier, Double> amounts) { this.guild = guild; - this.amounts = amounts; + this.amounts = new HashMap<>(amounts); } public static void use(Guild guild, Map<GuildModifier, Double> amounts) { diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/branch/Branch.java b/src/main/java/net/tfminecraft/simplefactions/guild/branch/Branch.java index 7a4384eb..4fdd4731 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/branch/Branch.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/branch/Branch.java @@ -48,15 +48,16 @@ public Branch(String key, ConfigurationSection config) { } if(config.contains("modifiers")) { for(String s : config.getStringList("modifiers")) { - String[] args = s.split("\\s+"); - if(args.length < 2) continue; + String[] args = s.trim().split("\\s+"); + if(args.length < 2 || args.length > 3) continue; try { double base = Double.parseDouble(args[1]); double perLevel = base; if(args.length == 3) { perLevel = Double.parseDouble(args[2]); } - modifiers.put(GuildModifier.valueOf(args[0].toUpperCase()), new BranchModifier(base, perLevel)); + if (!Double.isFinite(base) || !Double.isFinite(perLevel)) continue; + modifiers.put(GuildModifier.valueOf(args[0].toUpperCase(java.util.Locale.ROOT)), new BranchModifier(base, perLevel)); } catch (Exception e) { Bukkit.getLogger().info("[SimpleFactions] could not parse modifier "+s); // TODO: handle exception @@ -97,7 +98,7 @@ public ItemStack getIconItem() { String[] args = icon.split("\\."); ItemStack item = new ItemStack(Material.DIRT, 1); try { - item = new ItemStack(Material.valueOf(args[0].toUpperCase()), 1); + item = new ItemStack(Material.valueOf(args[0].toUpperCase(java.util.Locale.ROOT)), 1); ItemMeta m = item.getItemMeta(); LegacyModelData.set(m, Integer.parseInt(args[1])); item.setItemMeta(m); diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/Highway.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/Highway.java index dab29f8e..83b88f84 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/Highway.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/Highway.java @@ -71,10 +71,10 @@ public static void deliver( return; } int stopCount = offerCount(lines, graph, pushing); - int guard = stopCount * stopCount + 1; - for (int pass = 0; pass < guard; pass++) { + long guard = (long) stopCount * stopCount + 1; + for (long pass = 0; pass < guard; pass++) { if (!onePass(provinces, guild, graph, lines, accessByOwner, owners, false)) { - return; + break; } } } @@ -91,10 +91,10 @@ public static void deliverProduction( return; } int stopCount = offerCount(lines, graph, false); - int guard = stopCount * stopCount + 1; - for (int pass = 0; pass < guard; pass++) { + long guard = (long) stopCount * stopCount + 1; + for (long pass = 0; pass < guard; pass++) { if (!onePass(provinces, guild, graph, lines, accessByOwner, owners, true)) { - return; + break; } } } @@ -165,9 +165,6 @@ public static double wouldArrive( continue; } Link link = bestLink(graph, other, node); - if (link == null) { - continue; - } double delivered = raw(provinces, guild, other) * hopFactor(link, 1, 1); if (delivered > best) { @@ -490,9 +487,6 @@ private static Link toLink(Node from, Node to, Edge edge) { /** Higher trade factor wins. Equal factors keep rail, which is what a measured link did. */ private static boolean prefer(Link candidate, Link incumbent, boolean production) { - if (candidate == null) { - return false; - } if (incumbent == null) { return true; } @@ -506,9 +500,6 @@ private static boolean prefer(Link candidate, Link incumbent, boolean production } private static Set<Node> hubNodes(TradeGraph graph, Set<HubSite> hubbed) { - if (hubbed == null || hubbed.isEmpty()) { - return Set.of(); - } Set<Node> nodes = new HashSet<>(); for (Node node : graph.nodes()) { if (hubbed.contains(new HubSite(node.ownerFactionId(), node.installationId()))) { diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/TrackReach.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/TrackReach.java index 23b49438..c9b67573 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/TrackReach.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/TrackReach.java @@ -46,7 +46,7 @@ static double[] travel(List<List<Edge>> adjacent, boolean[] source, double range } while (!queue.isEmpty()) { Step step = queue.poll(); - if (step.distance != distance[step.node] || step.distance > range) { + if (step.distance != distance[step.node]) { continue; } for (Edge edge : adjacent.get(step.node)) { diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/hub/VehicleFrameworkTracks.java b/src/main/java/net/tfminecraft/simplefactions/guild/hub/VehicleFrameworkTracks.java index 7a9f7910..06718cf8 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/hub/VehicleFrameworkTracks.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/hub/VehicleFrameworkTracks.java @@ -129,10 +129,8 @@ private static Network network(Collection<TrackSpline> splines, Collection<Track List<List<TrackReach.Edge>> adjacent = new ArrayList<>(); List<SplineEdge> edges = new ArrayList<>(); Map<UUID, Integer> base = new HashMap<>(); - Map<UUID, TrackSpline> byId = new HashMap<>(); for (TrackSpline spline : splines) { if (spline == null || spline.getId() == null || spline.getSamples().size() < 2) continue; - byId.put(spline.getId(), spline); int origin = xy.size(); base.put(spline.getId(), origin); for (TrackSample sample : spline.getSamples()) { @@ -152,46 +150,70 @@ private static Network network(Collection<TrackSpline> splines, Collection<Track TrackSample right = spline.getSamples().get((edge + 1) % count); weight = Math.hypot(left.x - right.x, left.z - right.z); } - TrackReach.link(adjacent, from, to, weight); edges.add(new SplineEdge(spline, start, end, weight, from, to)); } } - for (TrackJunction junction : junctions) { - if (junction == null || junction.stemSplineId == null || junction.branchSplineId == null - || junction.stemSplineId.equals(junction.branchSplineId)) { - continue; + List<SplineEdge> splitEdges = new ArrayList<>(); + for (SplineEdge edge : edges) { + List<EdgeCut> cuts = new ArrayList<>(); + cuts.add(new EdgeCut(0, edge.from)); + cuts.add(new EdgeCut(edge.weight, edge.to)); + for (TrackJunction junction : junctions) { + if (junction == null || !edge.spline.getId().equals(junction.stemSplineId) + || junction.stemSplineId.equals(junction.branchSplineId)) continue; + Integer branch = base.get(junction.branchSplineId); + if (branch == null) continue; + double at = TrackJunction.wrapS(junction.s, edge.spline.length(), edge.spline.isLoop()); + if (at < edge.startS - EPS || at > edge.endS + EPS) continue; + double fraction = edge.endS > edge.startS + ? (at - edge.startS) / (edge.endS - edge.startS) : 0; + cuts.add(new EdgeCut(Math.max(0, Math.min(edge.weight, fraction * edge.weight)), branch)); } - TrackSpline stem = byId.get(junction.stemSplineId); - TrackSpline branch = byId.get(junction.branchSplineId); - if (stem == null || branch == null) continue; - Integer stemBase = base.get(stem.getId()); - Integer branchBase = base.get(branch.getId()); - if (stemBase == null || branchBase == null) continue; - double at = TrackJunction.wrapS(junction.s, stem.length(), stem.isLoop()); - int count = stem.getSamples().size(); - for (int edge = 0; edge < stem.edgeCount(); edge++) { - if (stem.segment(edge).broken) continue; - double start = startS(stem, edge); - double end = endS(stem, edge); - if (at < start - EPS || at > end + EPS) continue; - TrackReach.link(adjacent, stemBase + edge, branchBase, Math.abs(at - start)); - TrackReach.link(adjacent, stemBase + ((edge + 1) % count), branchBase, Math.abs(end - at)); + cuts.sort(java.util.Comparator.comparingDouble(EdgeCut::along)); + for (int index = 1; index < cuts.size(); index++) { + EdgeCut left = cuts.get(index - 1), right = cuts.get(index); + double weight = right.along - left.along; + TrackReach.link(adjacent, left.node, right.node, weight); + splitEdges.add(new SplineEdge(edge.spline, edgePosition(edge, left.along), + edgePosition(edge, right.along), weight, left.node, right.node)); } } - return new Network(xy, adjacent, edges); + return new Network(xy, adjacent, splitEdges); + } + + private record EdgeCut(double along, int node) {} + + private static double edgePosition(SplineEdge edge, double travelled) { + return edge.weight <= EPS ? edge.startS + : edge.startS + (travelled / edge.weight) * (edge.endS - edge.startS); } private static Map<Integer, Double> cover( Network network, double x, double z, double radius, double range, TrackReach.At provinceAt, int home) { int count = network.xy.size(); - boolean[] source = new boolean[count]; + List<List<TrackReach.Edge>> adjacent = new ArrayList<>(); + for (List<TrackReach.Edge> edges : network.adjacent) adjacent.add(new ArrayList<>(edges)); + List<Integer> seeds = new ArrayList<>(); double radiusSquared = radius * radius; for (int index = 0; index < count; index++) { double dx = network.xy.get(index)[0] - x; double dz = network.xy.get(index)[1] - z; - if (dx * dx + dz * dz <= radiusSquared) source[index] = true; + if (dx * dx + dz * dz <= radiusSquared) seeds.add(index); + } + Map<SplineEdge, double[]> boarding = new HashMap<>(); + for (SplineEdge edge : network.edges) { + double[] interval = boardingInterval(edge, x, z, radius); + if (interval == null) continue; + boarding.put(edge, interval); + int source = adjacent.size(); + adjacent.add(new ArrayList<>()); + seeds.add(source); + TrackReach.link(adjacent, source, edge.from, interval[0]); + TrackReach.link(adjacent, source, edge.to, Math.max(0, edge.weight - interval[1])); } - double[] distance = TrackReach.travel(network.adjacent, source, range); + boolean[] source = new boolean[adjacent.size()]; + for (int seed : seeds) source[seed] = true; + double[] distance = TrackReach.travel(adjacent, source, range); Map<Integer, Double> out = new HashMap<>(); for (int index = 0; index < count; index++) { if (!Double.isFinite(distance[index])) continue; @@ -200,28 +222,50 @@ private static Map<Integer, Double> cover( for (SplineEdge edge : network.edges) { double from = distance[edge.from]; double to = distance[edge.to]; - if (!Double.isFinite(from) && !Double.isFinite(to)) continue; + double[] interval = boarding.get(edge); + if (!Double.isFinite(from) && !Double.isFinite(to) && interval == null) continue; double weight = edge.weight; int steps = weight <= 0 ? 0 : (int) Math.floor(weight / SAMPLE_SPACING); for (int step = 0; step <= steps; step++) { double travelled = weight <= 0 ? 0 : Math.min(weight, step * SAMPLE_SPACING); - recordAlong(out, edge, travelled, from, to, range, provinceAt, home); + recordAlong(out, edge, travelled, from, to, interval, range, provinceAt, home); } - recordAlong(out, edge, weight, from, to, range, provinceAt, home); + recordAlong(out, edge, weight, from, to, interval, range, provinceAt, home); } return Map.copyOf(out); } + /** The portion of this intact segment inside the station's horizontal boarding circle. */ + private static double[] boardingInterval(SplineEdge edge, double x, double z, double radius) { + TrackPose left = edge.spline.sampleAt(edge.startS), right = edge.spline.sampleAt(edge.endS); + double dx = right.x - left.x, dz = right.z - left.z; + double a = dx * dx + dz * dz; + double ox = left.x - x, oz = left.z - z; + double c = ox * ox + oz * oz - radius * radius; + if (a <= EPS * EPS) return c <= 0 ? new double[] {0, edge.weight} : null; + double b = 2 * (ox * dx + oz * dz); + double discriminant = b * b - 4 * a * c; + if (discriminant < 0) return null; + double root = Math.sqrt(discriminant); + double first = Math.max(0, (-b - root) / (2 * a)); + double last = Math.min(1, (-b + root) / (2 * a)); + return first <= last ? new double[] {first * edge.weight, last * edge.weight} : null; + } + private static void recordAlong( Map<Integer, Double> out, SplineEdge edge, double travelled, - double fromDistance, double toDistance, double range, TrackReach.At provinceAt, int home) { + double fromDistance, double toDistance, double[] boarding, + double range, TrackReach.At provinceAt, int home) { double fromHere = Double.isFinite(fromDistance) ? fromDistance + travelled : Double.POSITIVE_INFINITY; double fromThere = Double.isFinite(toDistance) ? toDistance + Math.max(0, edge.weight - travelled) : Double.POSITIVE_INFINITY; double along = Math.min(fromHere, fromThere); + if (boarding != null) { + double direct = Math.max(0, Math.max(boarding[0] - travelled, travelled - boarding[1])); + along = Math.min(along, direct); + } if (!Double.isFinite(along) || along > range) return; - double span = edge.endS - edge.startS; - double s = edge.weight <= EPS ? edge.startS : edge.startS + (travelled / edge.weight) * span; + double s = edgePosition(edge, travelled); TrackPose pose = edge.spline.sampleAt(s); record(out, pose.x, pose.z, along, range, provinceAt, home); } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/EconomicPreview.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/EconomicPreview.java index 6d6d9d3e..79307e22 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/EconomicPreview.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/EconomicPreview.java @@ -112,9 +112,6 @@ private static Map<Guild, Double> project( private static Map<Guild, Double> nets(boolean capitalsOnly) { Map<Guild, Double> nets = new HashMap<>(); for (Guild guild : FactionManager.getAllGuilds()) { - if (guild == null || guild.getLedger() == null) { - continue; - } if (capitalsOnly && !guild.hasCapital()) { continue; } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewContext.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewContext.java index eb2ae43f..3f8b0357 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewContext.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewContext.java @@ -121,7 +121,7 @@ public static IncomePreviewContext trade(Faction origin, Faction target, Relatio RelationType targetRelation = targetCurrent; boolean overrideTarget = false; if (agreement != null) { - if (agreement.hasLink()) { + if (agreement.isMutual()) { targetRelation = agreement.getLink(); overrideTarget = true; } @@ -305,7 +305,6 @@ private static Rules ruleFor(TaxTarget target) { case VASSALS, VASSAL_ID -> Rules.VASSAL_TAX; case DIVIDENDS -> Rules.DIVIDEND_TAX; case TARIFFS, TARIFF_ID -> Rules.TARIFFS; - default -> null; }; } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java index 36974ff4..52970f71 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java @@ -307,9 +307,6 @@ public double getIncome(Cashflow cashflow) { if (!guild.isBase()) { return 0; } - if (f.getInstallationHandler() == null || f.getInstallationHandler().getAll() == null) { - break; - } for (Installation installation : f.getInstallationHandler().getAll()) { if (installation == null) continue; amount -= InstallationConfigLoader.getDailyUpkeep( @@ -645,13 +642,8 @@ private double unclampedPool(double base) { } private double getDividendTaxReceived() { - if (!guild.isBase()) { - return 0.0; - } + // getIncome(DIVIDENDS) has already checked the base guild and its faction. Faction faction = guild.getFaction(); - if (faction == null || faction.getGuildHandler() == null) { - return 0.0; - } double total = 0.0; for (Guild g : faction.getGuildHandler().getGuilds()) { if (g == null || g.isBase()) { @@ -729,17 +721,11 @@ public double getWarReparationsReceived() { if (f == null || f.getId().equals(self.getId())) { continue; } - if (f.getGuildHandler() == null) { - continue; - } for (WarReparationsObligation obligation : WarReparationsService.activeObligations(f)) { if (!self.getId().equalsIgnoreCase(obligation.getPayeeFactionId())) { continue; } for (Guild payerGuild : f.getGuildHandler().getGuilds()) { - if (payerGuild == null) { - continue; - } Ledger payerLedger = payerGuild.getLedger(); if (payerLedger == null || payerLedger.skipsMoneyMovement()) { continue; diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/loans/LoanBook.java b/src/main/java/net/tfminecraft/simplefactions/guild/loans/LoanBook.java index 63e006b4..fb75d588 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/loans/LoanBook.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/loans/LoanBook.java @@ -21,6 +21,7 @@ import net.md_5.bungee.api.chat.TextComponent; public class LoanBook { + private LoanBook() {} /** The highest weekly interest a loan may charge, in percent. */ public static final double MAX_INTEREST = 100.0; @@ -241,10 +242,10 @@ else if (line.startsWith("Overdue Fee")) { private static double parseDoubleAfterColon(String line) { try { String[] split = line.split(":"); - if (split.length < 2) return 0; + if (split.length < 2) return Double.NaN; return Double.parseDouble(split[1].trim()); - } catch (Exception e) { - return 0; + } catch (NumberFormatException e) { + return Double.NaN; } } } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/upgrade/Upgrade.java b/src/main/java/net/tfminecraft/simplefactions/guild/upgrade/Upgrade.java index 0f0009e7..cb90e5d6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/upgrade/Upgrade.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/upgrade/Upgrade.java @@ -56,15 +56,16 @@ public Upgrade(String key, ConfigurationSection config) { } if(config.contains("modifiers")) { for(String s : config.getStringList("modifiers")) { - String[] args = s.split("\\s+"); - if(args.length < 2) continue; + String[] args = s.trim().split("\\s+"); + if(args.length < 2 || args.length > 3) continue; try { double base = Double.parseDouble(args[1]); double perLevel = base; if(args.length == 3) { perLevel = Double.parseDouble(args[2]); } - modifiers.put(GuildModifier.valueOf(args[0].toUpperCase()), new BranchModifier(base, perLevel)); + if (!Double.isFinite(base) || !Double.isFinite(perLevel)) continue; + modifiers.put(GuildModifier.valueOf(args[0].toUpperCase(java.util.Locale.ROOT)), new BranchModifier(base, perLevel)); } catch (Exception e) { Bukkit.getLogger().info("[SimpleFactions] could not parse modifier "+s); // TODO: handle exception @@ -120,7 +121,7 @@ public ItemStack getIconItem() { String[] args = icon.split("\\."); ItemStack item = new ItemStack(Material.DIRT, 1); try { - item = new ItemStack(Material.valueOf(args[0].toUpperCase()), 1); + item = new ItemStack(Material.valueOf(args[0].toUpperCase(java.util.Locale.ROOT)), 1); ItemMeta m = item.getItemMeta(); LegacyModelData.set(m, Integer.parseInt(args[1])); item.setItemMeta(m); diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacters.java b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacters.java index 7cc50eae..10073b68 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacters.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacters.java @@ -36,28 +36,16 @@ 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 static volatile Probe probe; private LeaderCharacters() {} public static void setProbe(Probe next) { - probe = next == null ? UNAVAILABLE : next; + probe = next; } public static void reset() { - probe = UNAVAILABLE; + probe = null; } /** @@ -68,7 +56,7 @@ public static void reset() { 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; + if (current == null || !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)) { diff --git a/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityService.java b/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityService.java index 47cf9f3b..b3e002bd 100644 --- a/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityService.java +++ b/src/main/java/net/tfminecraft/simplefactions/inactivity/InactivityService.java @@ -34,6 +34,7 @@ public final class InactivityService { private static final Map<String, DecayClock> factionClocks = new HashMap<>(); private static long earliestDue = Long.MAX_VALUE; private static boolean loaded; + private static boolean saveBlocked; private InactivityService() {} @@ -48,33 +49,40 @@ public static final class SavedState { } public static void load() { - loaded = true; - guildClocks.clear(); - factionClocks.clear(); - if (!FILE.exists()) { - recomputeEarliest(); - return; - } + Map<String, DecayClock> nextGuilds = new HashMap<>(); + Map<String, DecayClock> nextFactions = new HashMap<>(); try { - SavedState state = JsonUtil.readJson(FILE, SavedState.class); - if (state != null) { - readInto(state.guilds, guildClocks); - readInto(state.factions, factionClocks); + if (FILE.exists()) { + SavedState state = JsonUtil.readJson(FILE, SavedState.class); + if (state == null) throw new IOException("Inactivity timer file contains no state"); + readInto(state.guilds, nextGuilds); + readInto(state.factions, nextFactions); } - } catch (IOException ex) { + } catch (IOException | RuntimeException ex) { + // Preserve both the last good snapshot and the unreadable source until an explicit reload succeeds. + loaded = true; + saveBlocked = true; log(Level.WARNING, "Could not read inactivity timers", ex); + return; } + guildClocks.clear(); + guildClocks.putAll(nextGuilds); + factionClocks.clear(); + factionClocks.putAll(nextFactions); + loaded = true; + saveBlocked = false; recomputeEarliest(); } public static void save() { + if (!loaded || saveBlocked) return; try { File parent = FILE.getParentFile(); if (parent != null && !parent.exists()) parent.mkdirs(); SavedState state = new SavedState(); writeFrom(guildClocks, state.guilds); writeFrom(factionClocks, state.factions); - JsonUtil.writeJson(FILE, state); + JsonUtil.writeJsonAtomic(FILE, state); } catch (IOException ex) { log(Level.WARNING, "Could not save inactivity timers", ex); } diff --git a/src/main/java/net/tfminecraft/simplefactions/keys/Keys.java b/src/main/java/net/tfminecraft/simplefactions/keys/Keys.java index eb18b7ea..5c839ff2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/keys/Keys.java +++ b/src/main/java/net/tfminecraft/simplefactions/keys/Keys.java @@ -5,6 +5,8 @@ import net.tfminecraft.simplefactions.SimpleFactions; public class Keys { + private Keys() {} + public static final NamespacedKey BRANCH_ID = new NamespacedKey(SimpleFactions.plugin, "branch_id"); /** Latest async branch income preview written onto an upgrade or downgrade button. */ public static final NamespacedKey BRANCH_PREVIEW = new NamespacedKey(SimpleFactions.plugin, "branch_preview"); diff --git a/src/main/java/net/tfminecraft/simplefactions/laws/CanHaveLaw.java b/src/main/java/net/tfminecraft/simplefactions/laws/CanHaveLaw.java index 42ad3682..bbbd1a4f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/laws/CanHaveLaw.java +++ b/src/main/java/net/tfminecraft/simplefactions/laws/CanHaveLaw.java @@ -97,9 +97,6 @@ private static String requirementsReason(Faction faction, Law law) { private static String requirementReason(Faction faction, String line) { String[] parts = line.split("\\s+"); - if (parts.length == 0 || parts[0].isBlank()) { - return null; - } String verb = parts[0].toLowerCase(); if ("has_law".equals(verb)) { if (parts.length < 2) { @@ -129,9 +126,6 @@ private static boolean hasCurrentLaw(Faction faction, String lawId) { return false; } List<Law> current = faction.getLawHandler().getCurrentLaws(); - if (current == null) { - return false; - } for (Law law : current) { if (law != null && law.getId() != null && law.getId().equalsIgnoreCase(lawId)) { return true; diff --git a/src/main/java/net/tfminecraft/simplefactions/laws/Law.java b/src/main/java/net/tfminecraft/simplefactions/laws/Law.java index 003c82c2..79b186fe 100644 --- a/src/main/java/net/tfminecraft/simplefactions/laws/Law.java +++ b/src/main/java/net/tfminecraft/simplefactions/laws/Law.java @@ -4,6 +4,7 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import org.bukkit.Bukkit; @@ -47,7 +48,7 @@ public Law(String group, String key, ConfigurationSection config) { if(config.contains("effects")) { for(String s : config.getConfigurationSection("effects").getKeys(false)) { try { - Scope scope = Scope.valueOf(s.toUpperCase()); + Scope scope = Scope.valueOf(s.toUpperCase(Locale.ROOT)); scopedEffects.put(scope, new LawEffect(scope, config.getConfigurationSection("effects."+s))); } catch (Exception e) { Bukkit.getLogger().info("[SimpleFactions] could not parse modifier for scope "+s+" in law "+key); diff --git a/src/main/java/net/tfminecraft/simplefactions/laws/LawEffect.java b/src/main/java/net/tfminecraft/simplefactions/laws/LawEffect.java index 4aeec566..fbc4abd3 100644 --- a/src/main/java/net/tfminecraft/simplefactions/laws/LawEffect.java +++ b/src/main/java/net/tfminecraft/simplefactions/laws/LawEffect.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import org.bukkit.Bukkit; @@ -37,7 +38,10 @@ public LawEffect(Scope scope, ConfigurationSection config) { String[] args = s.split("\\s+"); if (args.length != 2) continue; - Rules rule = Rules.valueOf(args[0].toUpperCase()); + Rules rule = Rules.valueOf(args[0].toUpperCase(Locale.ROOT)); + if (!"true".equalsIgnoreCase(args[1]) && !"false".equalsIgnoreCase(args[1])) { + throw new IllegalArgumentException("Invalid rule value: " + args[1]); + } rules.put(rule, Boolean.parseBoolean(args[1])); } catch (Exception e) { Bukkit.getLogger().warning( @@ -55,6 +59,7 @@ public LawEffect(Scope scope, ConfigurationSection config) { if (args.length != 2) continue; Regiment reg = RegimentLoader.getByString(args[0]); + if (reg == null) throw new IllegalArgumentException("Unknown regiment: " + args[0]); regiments.put(reg, Integer.parseInt(args[1])); } catch (Exception e) { Bukkit.getLogger().warning( @@ -67,7 +72,7 @@ public LawEffect(Scope scope, ConfigurationSection config) { if (config.contains("brackets")) { for (String key : config.getConfigurationSection("brackets").getKeys(false)) { try { - Brackets type = Brackets.valueOf(key.toUpperCase()); + Brackets type = Brackets.valueOf(key.toUpperCase(Locale.ROOT)); String value = config.getString("brackets." + key); String[] split = value.split("-"); @@ -75,6 +80,9 @@ public LawEffect(Scope scope, ConfigurationSection config) { double min = Double.parseDouble(split[0]); double max = Double.parseDouble(split[1]); + if (!Double.isFinite(min) || !Double.isFinite(max) || min > max) { + throw new IllegalArgumentException("Invalid bracket bounds: " + value); + } brackets.put(type, new Bracket(min, max)); @@ -104,7 +112,7 @@ public LawEffect(Scope scope, ConfigurationSection config) { || key.equalsIgnoreCase("modifiers")) continue; try { - Region region = Region.valueOf(key.toUpperCase()); + Region region = Region.valueOf(key.toUpperCase(Locale.ROOT)); for (String mod : config.getStringList(key)) { try { diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/BattleTemplateLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/BattleTemplateLoader.java index 823a0c35..06e7a85d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/BattleTemplateLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/BattleTemplateLoader.java @@ -50,7 +50,6 @@ public static void putForTests(BattleTemplate template) { } public void load(File configFile) { - templates.clear(); if (configFile == null) { return; } @@ -61,20 +60,22 @@ public void load(File configFile) { if (Bukkit.getServer() != null) { Bukkit.getLogger().warning("[SimpleFactions] Failed to load battle-templates.yml: " + e.getMessage()); } - e.printStackTrace(); - return; + throw new IllegalStateException("Cannot load battle templates from " + configFile, e); } + Map<String, BattleTemplate> loaded = new HashMap<>(); Set<String> keys = config.getKeys(false); for (String key : keys) { try { - templates.put(key, new BattleTemplate(key, config.getConfigurationSection(key))); + loaded.put(key, new BattleTemplate(key, config.getConfigurationSection(key))); } catch (Exception e) { if (Bukkit.getServer() != null) { Bukkit.getLogger().warning("[SimpleFactions] Failed to load battle template '" + key + "': " + e.getMessage()); } - e.printStackTrace(); + throw new IllegalStateException("Cannot load battle template " + key + " from " + configFile, e); } } + templates.clear(); + templates.putAll(loaded); if (Bukkit.getServer() != null) { Bukkit.getLogger().info("[SimpleFactions] Loaded " + templates.size() + " battle template(s) from battle-templates.yml"); } diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/BranchLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/BranchLoader.java index 9585e925..9caba7b0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/BranchLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/BranchLoader.java @@ -59,19 +59,17 @@ public static void replaceDisallowedBranches(Map<Integer, Branch> branches, Guil } } public void load(File configFile) { - FileConfiguration config = new YamlConfiguration(); + FileConfiguration config = new YamlConfiguration(); + Map<String, Branch> loaded = new java.util.LinkedHashMap<>(); try { - config.load(configFile); - } catch (IOException | InvalidConfigurationException e) { - e.printStackTrace(); + config.load(configFile); + for (String key : config.getKeys(false)) { + loaded.put(key, new Branch(key, config.getConfigurationSection(key))); + } + } catch (IOException | InvalidConfigurationException | RuntimeException e) { + throw new IllegalStateException("Cannot load definitions from " + configFile, e); } - Set<String> set = config.getKeys(false); - - List<String> list = new ArrayList<>(set); - - for(String key : list) { - Branch r = new Branch(key, config.getConfigurationSection(key)); - map.put(key, r); - } - } + map.clear(); + map.putAll(loaded); + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/CompanyUpgradeLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/CompanyUpgradeLoader.java index 4c8aebae..1f7016d1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/CompanyUpgradeLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/CompanyUpgradeLoader.java @@ -37,14 +37,16 @@ public static Upgrade getByString(String id) { public void load(File configFile) { FileConfiguration config = new YamlConfiguration(); + Map<String, Upgrade> loaded = new java.util.LinkedHashMap<>(); try { config.load(configFile); - } catch (IOException | InvalidConfigurationException e) { - e.printStackTrace(); + for (String key : config.getKeys(false)) { + loaded.put(key, new Upgrade(key, config.getConfigurationSection(key))); + } + } catch (IOException | InvalidConfigurationException | RuntimeException e) { + throw new IllegalStateException("Cannot load definitions from " + configFile, e); } map.clear(); - for (String key : new ArrayList<>(config.getKeys(false))) { - map.put(key, new Upgrade(key, config.getConfigurationSection(key))); - } + map.putAll(loaded); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java index 032f2f5d..2c63f3d2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java @@ -6,6 +6,7 @@ import java.util.Collections; import java.util.EnumMap; import java.util.List; +import java.util.Locale; import org.bukkit.Bukkit; import org.bukkit.configuration.InvalidConfigurationException; @@ -38,6 +39,8 @@ private static void loadInstallationTrade(FileConfiguration config) { public void loadConfig(File configFile) { FileConfiguration config = loadYaml(configFile); + validateBattlePresenceConfig(config); + validateBattleTemplateDefaultsConfig(config); Cache.mapRef = config.getString("map-reference", "main"); Cache.chapterId = ChapterIdentity.normalizeId(config.getString("map-id")); Cache.chapterName = ChapterIdentity.normalizeName(config.getString("map-name")); @@ -116,8 +119,6 @@ public void loadConfig(File configFile) { Cache.battleEmptySideGraceSeconds = Math.max(0, config.getInt("battle.empty_side_grace_seconds", 300)); Cache.battleTimeCapEnabled = config.getBoolean("battle.time_cap_enabled", false); Cache.battleTimeCapMinutes = config.getInt("battle.time_cap_minutes", 120); - validateBattlePresenceConfig(); - validateBattleTemplateDefaultsConfig(); Cache.branchUpgradeCost = config.getDouble("branch-upgrade-cost", 100.0); Cache.branchUpgradeExponent = config.getDouble("branch-upgrade-exponent", 1.1); @@ -133,7 +134,7 @@ public void loadConfig(File configFile) { String[] args = s.split("\\s+"); if(args.length != 2) continue; try { - Cache.tradeCarry.put(Terrain.valueOf(args[0].toUpperCase()), Double.parseDouble(args[1])); + Cache.tradeCarry.put(Terrain.valueOf(args[0].toUpperCase(Locale.ROOT)), Double.parseDouble(args[1])); } catch (Exception e) { // TODO: handle exception Bukkit.getLogger().info("Could not parse "+s); @@ -143,16 +144,23 @@ public void loadConfig(File configFile) { if(config.contains("icons")) { for(String s : config.getStringList("icons")) { - String id = s.split("\\(")[0]; - String path = s.split("\\(")[1].replace(")", ""); + int opening = s.indexOf('('); + if (opening <= 0 || !s.endsWith(")") || opening == s.length() - 2) { + Bukkit.getLogger().warning("[SimpleFactions] Could not parse icon " + s); + continue; + } + String id = s.substring(0, opening).trim(); + String path = s.substring(opening + 1, s.length() - 1).trim(); + if (id.isEmpty() || path.isEmpty()) continue; Cache.icons.put(id, path); } } - if(config.contains("base-effects")) { - for(String s : config.getConfigurationSection("base-effects").getKeys(false)) { + org.bukkit.configuration.ConfigurationSection effects = config.getConfigurationSection("base-effects"); + if(effects != null) { + for(String s : effects.getKeys(false)) { try { - Scope scope = Scope.valueOf(s.toUpperCase()); + Scope scope = Scope.valueOf(s.toUpperCase(Locale.ROOT)); Cache.baseEffects.put(scope, new LawEffect(scope, config.getConfigurationSection("base-effects."+s))); } catch (Exception e) { Bukkit.getLogger().info("[SimpleFactions] could not parse modifier for scope "+s); @@ -166,10 +174,13 @@ public void loadWar(File warFile) { FileConfiguration config = loadYaml(warFile); if (!config.contains("war") && warFile != null && warFile.getParentFile() != null) { FileConfiguration fromConfig = loadYaml(new File(warFile.getParentFile(), "config.yml")); - if (fromConfig.contains("war")) { + if (fromConfig != null && fromConfig.contains("war")) { config = fromConfig; } } + validateBattleScheduleConfig(config); + validateCampaignRaidConfig(config); + validateWarDevmodeConfig(config); Cache.warRequireDeclareCode = config.getBoolean("war.require_declare_code", false); Cache.warDeclareCodeTimeoutSeconds = Math.max(1, config.getInt("war.declare_code_timeout_seconds", 10)); @@ -250,7 +261,6 @@ public void loadWar(File warFile) { double loserLossFraction = config.getDouble("war.autoresolve.loser_loss_fraction", 0.5); Cache.warAutoresolveLoserLossFraction = loserLossFraction < 0 ? 0 : loserLossFraction; Cache.warDevmodePhantomCount = config.getInt("war.devmode.phantom_count", 10); - validateBattleScheduleConfig(); Cache.warBattleLivesPerRegiment = config.getInt("war.battle_military.lives_per_regiment", 5); Cache.warBattleMinSideLives = config.getInt("war.battle_military.min_side_lives", 1); @@ -274,8 +284,6 @@ public void loadWar(File warFile) { config.getInt("war.campaign_raid.intruder_damage_interval_ticks", 10); Cache.campaignRaidIntruderDamageAmount = config.getInt("war.campaign_raid.intruder_damage_amount", 4); - validateCampaignRaidConfig(); - validateWarDevmodeConfig(); } private static double nonNegative(double value, double fallback) { @@ -292,101 +300,101 @@ private static FileConfiguration loadYaml(File file) { config.load(file); } } catch (IOException | InvalidConfigurationException e) { - e.printStackTrace(); + throw new IllegalStateException("Cannot load configuration from " + file, e); } return config; } - private static void validateBattleScheduleConfig() { + private static void validateBattleScheduleConfig(FileConfiguration config) { // Hours in war.battle_schedule are Europe/Paris (CET/CEST), not UTC. - int defenderDeadline = Cache.warDefenderChoiceDeadlineHour; - int voteClose = Cache.warVoteCloseHour; - int raidStart = Cache.warRaidWindowStartHour; - int raidEnd = Cache.warRaidWindowEndHour; - int windowStart = Cache.warBattleWindowStartHour; - int windowEnd = Cache.warBattleWindowEndHour; + int defenderDeadline = config.getInt("war.battle_schedule.defender_choice_deadline_hour", 12); + int voteClose = config.getInt("war.battle_schedule.vote_close_hour", 16); + int raidStart = config.getInt("war.battle_schedule.raid_window_start_hour", 19); + int raidEnd = config.getInt("war.battle_schedule.raid_window_end_hour", 20); + int windowStart = config.getInt("war.battle_schedule.window_start_hour", 21); + int windowEnd = config.getInt("war.battle_schedule.window_end_hour", 24); if (defenderDeadline < 0 || defenderDeadline >= voteClose) { - failBattleSchedule("war.battle_schedule.defender_choice_deadline_hour must be >= 0 and < vote_close_hour"); + throw invalidBattleConfiguration("war.battle_schedule.defender_choice_deadline_hour must be >= 0 and < vote_close_hour"); } if (voteClose >= raidStart) { - failBattleSchedule("war.battle_schedule.vote_close_hour must be < raid_window_start_hour"); + throw invalidBattleConfiguration("war.battle_schedule.vote_close_hour must be < raid_window_start_hour"); } if (raidStart > raidEnd) { - failBattleSchedule("war.battle_schedule requires raid_window_start_hour <= raid_window_end_hour"); + throw invalidBattleConfiguration("war.battle_schedule requires raid_window_start_hour <= raid_window_end_hour"); } if (raidEnd >= windowStart) { - failBattleSchedule("war.battle_schedule.raid_window_end_hour must be < window_start_hour"); + throw invalidBattleConfiguration("war.battle_schedule.raid_window_end_hour must be < window_start_hour"); } if (windowStart > windowEnd || windowEnd > 24) { - failBattleSchedule("war.battle_schedule requires window_start_hour <= window_end_hour <= 24"); + throw invalidBattleConfiguration("war.battle_schedule requires window_start_hour <= window_end_hour <= 24"); } - if (Cache.warBattleVotingMinPlayers < 1) { - failBattleSchedule("war.battle_voting.min_players must be >= 1"); + if (config.getInt("war.battle_voting.min_players", 4) < 1) { + throw invalidBattleConfiguration("war.battle_voting.min_players must be >= 1"); } - if (Cache.warBattleVotingDevMinPlayersEnabled && Cache.warBattleVotingDevMinPlayers < 1) { - failBattleSchedule("war.battle_voting.dev_min_players must be >= 1"); + if (config.contains("war.battle_voting.dev_min_players") + && config.getInt("war.battle_voting.dev_min_players") < 1) { + throw invalidBattleConfiguration("war.battle_voting.dev_min_players must be >= 1"); } } - private static void failBattleSchedule(String message) { + private static IllegalStateException invalidBattleConfiguration(String message) { if (Bukkit.getServer() != null) { Bukkit.getLogger().severe("[SimpleFactions] " + message); } - throw new IllegalStateException(message); + return new IllegalStateException(message); } - private static void validateCampaignRaidConfig() { - if (Cache.campaignRaidMusterSeconds < 1) { - failBattleSchedule("war.campaign_raid.muster_seconds must be >= 1"); + private static void validateCampaignRaidConfig(FileConfiguration config) { + if (config.getInt("war.campaign_raid.muster_seconds", 60) < 1) { + throw invalidBattleConfiguration("war.campaign_raid.muster_seconds must be >= 1"); } - if (Cache.campaignRaidDurationSeconds < 1) { - failBattleSchedule("war.campaign_raid.duration_seconds must be >= 1"); + if (config.getInt("war.campaign_raid.duration_seconds", 600) < 1) { + throw invalidBattleConfiguration("war.campaign_raid.duration_seconds must be >= 1"); } - if (Cache.campaignRaidRepairLockHours < 1) { - failBattleSchedule("war.campaign_raid.repair_lock_hours must be >= 1"); + if (config.getInt("war.campaign_raid.repair_lock_hours", 48) < 1) { + throw invalidBattleConfiguration("war.campaign_raid.repair_lock_hours must be >= 1"); } - if (Cache.campaignRaidIntruderDamageIntervalTicks < 1) { - failBattleSchedule("war.campaign_raid.intruder_damage_interval_ticks must be >= 1"); + if (config.getInt("war.campaign_raid.intruder_damage_interval_ticks", 10) < 1) { + throw invalidBattleConfiguration("war.campaign_raid.intruder_damage_interval_ticks must be >= 1"); } - if (Cache.campaignRaidIntruderDamageAmount < 1) { - failBattleSchedule("war.campaign_raid.intruder_damage_amount must be >= 1"); + if (config.getInt("war.campaign_raid.intruder_damage_amount", 4) < 1) { + throw invalidBattleConfiguration("war.campaign_raid.intruder_damage_amount must be >= 1"); } - for (int offset : Cache.campaignRaidMusterReminderSecondsBefore) { + for (int offset : loadReminderOffsets(config, + "war.campaign_raid.muster_reminder_seconds_before", List.of(45, 30, 15, 10))) { if (offset < 1) { - failBattleSchedule("war.campaign_raid.muster_reminder_seconds_before values must be >= 1"); + throw invalidBattleConfiguration("war.campaign_raid.muster_reminder_seconds_before values must be >= 1"); } } } - private static void validateBattlePresenceConfig() { - if (Cache.battleProvincePollIntervalTicks < 1) { - failBattleSchedule("battle.province_poll_interval_ticks must be >= 1"); + private static void validateBattlePresenceConfig(FileConfiguration config) { + if (config.getInt("battle.province_poll_interval_ticks", 20) < 1) { + throw invalidBattleConfiguration("battle.province_poll_interval_ticks must be >= 1"); } - if (Cache.battleCaptureMinPlayers < 1) { - failBattleSchedule("battle.capture_min_players must be >= 1"); + if (config.getInt("battle.capture_min_players", 1) < 1) { + throw invalidBattleConfiguration("battle.capture_min_players must be >= 1"); } } - private static void validateWarDevmodeConfig() { - if (Cache.warDevmodePhantomCount < 0) { - failBattleSchedule("war.devmode.phantom_count must be >= 0"); + private static void validateWarDevmodeConfig(FileConfiguration config) { + if (config.getInt("war.devmode.phantom_count", 10) < 0) { + throw invalidBattleConfiguration("war.devmode.phantom_count must be >= 0"); } } - private static void validateBattleTemplateDefaultsConfig() { - if (Cache.battleProvinceLeaveCountdownSeconds < 1) { - failBattleSchedule("battle.province_leave_countdown_seconds must be >= 1"); - } - if (Cache.battleSiegeContestDurationSeconds < 1) { - failBattleSchedule("battle.siege.contest_duration_seconds must be >= 1"); + private static void validateBattleTemplateDefaultsConfig(FileConfiguration config) { + if (config.getInt("battle.province_leave_countdown_seconds", 10) < 1) { + throw invalidBattleConfiguration("battle.province_leave_countdown_seconds must be >= 1"); } - if (Cache.battleRaidDefenderRespawnModeDefault == null) { - failBattleSchedule("battle.raid.defender_respawn_mode_default must be INFINITE or LIVES"); + if (config.getInt("battle.siege.contest_duration_seconds", 180) < 1) { + throw invalidBattleConfiguration("battle.siege.contest_duration_seconds must be >= 1"); } - for (int offset : Cache.battleSignupReminderSecondsBefore) { + for (int offset : loadReminderOffsets(config, + "battle.signup_reminder_seconds_before", List.of(1800, 600, 300, 60))) { if (offset < 1) { - failBattleSchedule("battle.signup_reminder_seconds_before values must be >= 1"); + throw invalidBattleConfiguration("battle.signup_reminder_seconds_before values must be >= 1"); } } } @@ -461,13 +469,7 @@ private static String warGoalString(FileConfiguration config, String key) { private static List<String> trimmedLawIds(List<String> raw) { List<String> ids = new ArrayList<>(); - if (raw == null) { - return List.of(); - } for (String id : raw) { - if (id == null) { - continue; - } String trimmed = id.trim(); if (!trimmed.isEmpty()) { ids.add(trimmed); diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/GuildLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/GuildLoader.java index 44392c8c..1519ebd4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/GuildLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/GuildLoader.java @@ -44,19 +44,17 @@ public static GuildType getDefaultType() { return getList().get(0); } public void load(File configFile) { - FileConfiguration config = new YamlConfiguration(); + FileConfiguration config = new YamlConfiguration(); + Map<String, GuildType> loaded = new java.util.LinkedHashMap<>(); try { - config.load(configFile); - } catch (IOException | InvalidConfigurationException e) { - e.printStackTrace(); + config.load(configFile); + for (String key : config.getKeys(false)) { + loaded.put(key, new GuildType(key, config.getConfigurationSection(key))); + } + } catch (IOException | InvalidConfigurationException | RuntimeException e) { + throw new IllegalStateException("Cannot load definitions from " + configFile, e); } - Set<String> set = config.getKeys(false); - - List<String> list = new ArrayList<>(set); - - for(String key : list) { - GuildType r = new GuildType(key, config.getConfigurationSection(key)); - map.put(key, r); - } - } + map.clear(); + map.putAll(loaded); + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/InstallationConfigLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/InstallationConfigLoader.java index ce79a18a..55d12e1f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/InstallationConfigLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/InstallationConfigLoader.java @@ -27,30 +27,30 @@ public final class InstallationConfigLoader { private InstallationConfigLoader() {} public static void load(File installationsYaml) { - byKind.clear(); + Map<InstallationKind, InstallationKindConfig> nextKinds = new EnumMap<>(InstallationKind.class); FileConfiguration config = new YamlConfiguration(); try { config.load(installationsYaml); } catch (IOException | InvalidConfigurationException e) { e.printStackTrace(); - fail("Failed to load installations.yml"); + throw failure("Failed to load installations.yml"); } if (!config.contains("consent-proximity-blocks")) { - fail("installations.yml consent-proximity-blocks is required"); + throw failure("installations.yml consent-proximity-blocks is required"); } if (!config.contains("transfer-request-timeout-seconds")) { - fail("installations.yml transfer-request-timeout-seconds is required"); + throw failure("installations.yml transfer-request-timeout-seconds is required"); } - consentProximityBlocks = config.getInt("consent-proximity-blocks"); - transferRequestTimeoutSeconds = config.getInt("transfer-request-timeout-seconds"); - if (consentProximityBlocks < 0) { - fail("installations.yml consent-proximity-blocks must be >= 0"); + int nextConsentProximityBlocks = config.getInt("consent-proximity-blocks"); + int nextTransferRequestTimeoutSeconds = config.getInt("transfer-request-timeout-seconds"); + if (nextConsentProximityBlocks < 0) { + throw failure("installations.yml consent-proximity-blocks must be >= 0"); } - if (transferRequestTimeoutSeconds <= 0) { - fail("installations.yml transfer-request-timeout-seconds must be > 0"); + if (nextTransferRequestTimeoutSeconds <= 0) { + throw failure("installations.yml transfer-request-timeout-seconds must be > 0"); } Set<String> knownCategories = VehiclesConfigLoader.getCategoryIds(); @@ -60,7 +60,7 @@ public static void load(File installationsYaml) { ConfigurationSection section = config.getConfigurationSection(key); if (section == null) { if (kind == InstallationKind.TRAIN_STATION) { - byKind.put( + nextKinds.put( kind, new InstallationKindConfig( 80, @@ -70,36 +70,36 @@ public static void load(File installationsYaml) { 3, new Level(100, 432000, Map.of("static_emplacements", 4))))); continue; } - fail("installations.yml missing required section: " + key); + throw failure("installations.yml missing required section: " + key); } if (!section.contains("daily-upkeep")) { - fail("installations.yml " + key + ".daily-upkeep is required"); + throw failure("installations.yml " + key + ".daily-upkeep is required"); } if (!section.contains("construction-time")) { - fail("installations.yml " + key + ".construction-time is required"); + throw failure("installations.yml " + key + ".construction-time is required"); } if (!section.contains("radius")) { - fail("installations.yml " + key + ".radius is required"); + throw failure("installations.yml " + key + ".radius is required"); } double dailyUpkeep = section.getDouble("daily-upkeep"); int constructionTimeSeconds = section.getInt("construction-time"); int radius = section.getInt("radius"); - if (dailyUpkeep < 0) { - fail("installations.yml " + key + ".daily-upkeep must be >= 0"); + if (!Double.isFinite(dailyUpkeep) || dailyUpkeep < 0) { + throw failure("installations.yml " + key + ".daily-upkeep must be finite and >= 0"); } if (constructionTimeSeconds <= 0) { - fail("installations.yml " + key + ".construction-time must be > 0"); + throw failure("installations.yml " + key + ".construction-time must be > 0"); } if (radius <= 0) { - fail("installations.yml " + key + ".radius must be > 0"); + throw failure("installations.yml " + key + ".radius must be > 0"); } ConfigurationSection slotsSection = section.getConfigurationSection("slots"); if (slotsSection == null) { - fail("installations.yml " + key + ".slots is required"); + throw failure("installations.yml " + key + ".slots is required"); } Map<String, Integer> categorySlots = readSlots(key, slotsSection, knownCategories); @@ -107,7 +107,7 @@ public static void load(File installationsYaml) { levels.put(1, new Level(dailyUpkeep, constructionTimeSeconds, categorySlots)); ConfigurationSection levelsSection = section.getConfigurationSection("levels"); if (section.contains("levels") && levelsSection == null) { - fail("installations.yml " + key + ".levels must be a section"); + throw failure("installations.yml " + key + ".levels must be a section"); } if (levelsSection != null) { Map<Integer, ConfigurationSection> parsedLevels = new HashMap<>(); @@ -116,39 +116,38 @@ public static void load(File installationsYaml) { try { level = Integer.parseInt(levelKey); } catch (NumberFormatException e) { - fail("installations.yml " + key + ".levels." + levelKey + throw failure("installations.yml " + key + ".levels." + levelKey + " must be a level number >= 2"); - return; } if (level < 2 || parsedLevels.put( level, levelsSection.getConfigurationSection(levelKey)) != null) { - fail("installations.yml " + key + ".levels must start at 2 with no gaps"); + throw failure("installations.yml " + key + ".levels must start at 2 with no gaps"); } } for (int level = 2; level <= parsedLevels.size() + 1; level++) { ConfigurationSection levelSection = parsedLevels.get(level); if (levelSection == null) { - fail("installations.yml " + key + ".levels must start at 2 with no gaps"); + throw failure("installations.yml " + key + ".levels must start at 2 with no gaps"); } String prefix = key + ".levels." + level; if (!levelSection.contains("daily-upkeep")) { - fail("installations.yml " + prefix + ".daily-upkeep is required"); + throw failure("installations.yml " + prefix + ".daily-upkeep is required"); } if (!levelSection.contains("construction-time")) { - fail("installations.yml " + prefix + ".construction-time is required"); + throw failure("installations.yml " + prefix + ".construction-time is required"); } double levelUpkeep = levelSection.getDouble("daily-upkeep"); int levelConstruction = levelSection.getInt("construction-time"); - if (levelUpkeep < 0) { - fail("installations.yml " + prefix + ".daily-upkeep must be >= 0"); + if (!Double.isFinite(levelUpkeep) || levelUpkeep < 0) { + throw failure("installations.yml " + prefix + ".daily-upkeep must be finite and >= 0"); } if (levelConstruction <= 0) { - fail("installations.yml " + prefix + ".construction-time must be > 0"); + throw failure("installations.yml " + prefix + ".construction-time must be > 0"); } ConfigurationSection levelSlots = levelSection.getConfigurationSection("slots"); if (levelSlots == null) { - fail("installations.yml " + prefix + ".slots is required"); + throw failure("installations.yml " + prefix + ".slots is required"); } levels.put( level, @@ -159,8 +158,12 @@ public static void load(File installationsYaml) { } } - byKind.put(kind, new InstallationKindConfig(radius, levels)); + nextKinds.put(kind, new InstallationKindConfig(radius, levels)); } + byKind.clear(); + byKind.putAll(nextKinds); + consentProximityBlocks = nextConsentProximityBlocks; + transferRequestTimeoutSeconds = nextTransferRequestTimeoutSeconds; } private static Map<String, Integer> readSlots( @@ -169,14 +172,14 @@ private static Map<String, Integer> readSlots( Set<String> knownCategories) { Map<String, Integer> categorySlots = new HashMap<>(); for (String categoryId : slotsSection.getKeys(false)) { - String normalizedCategoryId = categoryId.toLowerCase(); + String normalizedCategoryId = categoryId.toLowerCase(java.util.Locale.ROOT); if (!knownCategories.contains(normalizedCategoryId)) { - fail("installations.yml " + key + ".slots." + categoryId + throw failure("installations.yml " + key + ".slots." + categoryId + " references unknown vehicle category (check vehicles.yml categories)"); } int capacity = slotsSection.getInt(categoryId); if (capacity < 0) { - fail("installations.yml " + key + ".slots." + categoryId + " must be >= 0"); + throw failure("installations.yml " + key + ".slots." + categoryId + " must be >= 0"); } categorySlots.put(normalizedCategoryId, capacity); } @@ -219,7 +222,7 @@ public static int getCategorySlotCapacity(InstallationKind kind, int level, Stri if (categoryId == null || categoryId.isEmpty()) { return 0; } - Integer capacity = require(kind).getCategorySlots(level).get(categoryId.toLowerCase()); + Integer capacity = require(kind).getCategorySlots(level).get(categoryId.toLowerCase(java.util.Locale.ROOT)); return capacity == null ? 0 : capacity; } @@ -248,10 +251,10 @@ private static InstallationKindConfig require(InstallationKind kind) { return config; } - private static void fail(String message) { + private static IllegalStateException failure(String message) { if (Bukkit.getServer() != null) { Bukkit.getLogger().severe("[SimpleFactions] " + message); } - throw new IllegalStateException(message); + return new IllegalStateException(message); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/LawLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/LawLoader.java index cd92d577..8762f64d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/LawLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/LawLoader.java @@ -32,19 +32,17 @@ public static LawGroup getByString(String id) { } public void load(File configFile) { - FileConfiguration config = new YamlConfiguration(); + FileConfiguration config = new YamlConfiguration(); + Map<String, LawGroup> loaded = new java.util.LinkedHashMap<>(); try { - config.load(configFile); - } catch (IOException | InvalidConfigurationException e) { - e.printStackTrace(); + config.load(configFile); + for (String key : config.getKeys(false)) { + loaded.put(key, new LawGroup(key, config.getConfigurationSection(key))); + } + } catch (IOException | InvalidConfigurationException | RuntimeException e) { + throw new IllegalStateException("Cannot load definitions from " + configFile, e); } - Set<String> set = config.getKeys(false); - - List<String> list = new ArrayList<>(set); - - for(String key : list) { - LawGroup r = new LawGroup(key, config.getConfigurationSection(key)); - map.put(key, r); - } - } + map.clear(); + map.putAll(loaded); + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/PoliticalActionLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/PoliticalActionLoader.java index 14b51950..1ceb874a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/PoliticalActionLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/PoliticalActionLoader.java @@ -32,19 +32,19 @@ public static PoliticalAction getByAction(Action id) { } public void load(File configFile) { - FileConfiguration config = new YamlConfiguration(); + FileConfiguration config = new YamlConfiguration(); + Map<Action, PoliticalAction> loaded = new java.util.LinkedHashMap<>(); try { - config.load(configFile); - } catch (IOException | InvalidConfigurationException e) { - e.printStackTrace(); + config.load(configFile); + loaded.put(Action.NONE, new PoliticalAction(Action.NONE)); + for (String key : config.getKeys(false)) { + PoliticalAction action = new PoliticalAction(key, config.getConfigurationSection(key)); + loaded.put(action.getAction(), action); + } + } catch (IOException | InvalidConfigurationException | RuntimeException e) { + throw new IllegalStateException("Cannot load definitions from " + configFile, e); } - Set<String> set = config.getKeys(false); - - List<String> list = new ArrayList<>(set); - map.put(Action.NONE, new PoliticalAction(Action.NONE)); - for(String key : list) { - PoliticalAction r = new PoliticalAction(key, config.getConfigurationSection(key)); - map.put(r.getAction(), r); - } - } + map.clear(); + map.putAll(loaded); + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/RankLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/RankLoader.java index 3e3a905b..dbcab7d0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/RankLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/RankLoader.java @@ -6,7 +6,6 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; -import java.util.Set; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.FileConfiguration; @@ -30,7 +29,7 @@ public int compare(PrestigeRank c1, PrestigeRank c2) { } public static PrestigeRank getByLevel(Integer i) { for(PrestigeRank r : ranks) { - if(r.getLevel() == i) return r; + if(java.util.Objects.equals(r.getLevel(), i)) return r; } return null; } @@ -42,18 +41,23 @@ public static PrestigeRank getByString(String id) { } public void loadRanks(File configFile) { FileConfiguration config = new YamlConfiguration(); - try { - config.load(configFile); - } catch (IOException | InvalidConfigurationException e) { - e.printStackTrace(); - } - Set<String> set = config.getKeys(false); - - List<String> list = new ArrayList<String>(set); - ranks.clear(); - for(String key : list) { - PrestigeRank r = new PrestigeRank(key, config.getConfigurationSection(key)); - ranks.add(r); + List<PrestigeRank> loaded = new ArrayList<>(); + try { + config.load(configFile); + for (String key : config.getKeys(false)) { + var section = config.getConfigurationSection(key); + if (section == null) { + throw new IllegalArgumentException("Rank " + key + " must be a section"); + } + loaded.add(new PrestigeRank(key, section)); + } + if (loaded.isEmpty()) { + throw new IllegalArgumentException("At least one rank is required"); + } + } catch (IOException | InvalidConfigurationException | RuntimeException error) { + throw new IllegalStateException("Cannot load ranks from " + configFile, error); } + ranks.clear(); + ranks.addAll(loaded); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/RegimentLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/RegimentLoader.java index 13e5bd19..b727b507 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/RegimentLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/RegimentLoader.java @@ -31,19 +31,17 @@ public static Regiment getMercenaryRegiment() { return null; } public void loadRegiments(File configFile) { - FileConfiguration config = new YamlConfiguration(); + FileConfiguration config = new YamlConfiguration(); + List<Regiment> loaded = new ArrayList<>(); try { - config.load(configFile); - } catch (IOException | InvalidConfigurationException e) { - e.printStackTrace(); + config.load(configFile); + for (String key : config.getKeys(false)) { + loaded.add(new Regiment(key, config.getConfigurationSection(key))); + } + } catch (IOException | InvalidConfigurationException | RuntimeException e) { + throw new IllegalStateException("Cannot load definitions from " + configFile, e); } - Set<String> set = config.getKeys(false); - - List<String> list = new ArrayList<String>(set); - - for(String key : list) { - Regiment r = new Regiment(key, config.getConfigurationSection(key)); - oList.add(r); - } - } + oList.clear(); + oList.addAll(loaded); + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/RegionLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/RegionLoader.java index 2a179aed..00d0d217 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/RegionLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/RegionLoader.java @@ -43,11 +43,8 @@ public static void loadAll() { } public static void loadAll(File file) { - regions = new ArrayList<>(); - byId = new HashMap<>(); - byProvince = new HashMap<>(); - if (file == null || !file.isFile()) { + loadFrom(null); return; } @@ -64,12 +61,10 @@ public static void loadAll(File file) { } static void loadFrom(JsonObject root) { - regions = new ArrayList<>(); - byId = new HashMap<>(); - byProvince = new HashMap<>(); - if (root == null) { - return; - } + List<MapRegion> loadedRegions = new ArrayList<>(); + Map<String, MapRegion> loadedById = new HashMap<>(); + Map<Integer, MapRegion> loadedByProvince = new HashMap<>(); + if (root == null) root = new JsonObject(); for (Map.Entry<String, JsonElement> entry : root.entrySet()) { String id = entry.getKey() == null ? "" : entry.getKey().trim(); @@ -96,11 +91,11 @@ static void loadFrom(JsonObject root) { } MapRegion region = new MapRegion(id, name, provinces); - regions.add(region); - byId.put(id.toLowerCase(Locale.ROOT), region); + loadedRegions.add(region); + loadedById.put(id.toLowerCase(Locale.ROOT), region); for (int provinceId : provinces) { - if (byProvince.containsKey(provinceId)) { - MapRegion existing = byProvince.get(provinceId); + if (loadedByProvince.containsKey(provinceId)) { + MapRegion existing = loadedByProvince.get(provinceId); logger().warning( "Province " + provinceId @@ -113,9 +108,12 @@ static void loadFrom(JsonObject root) { + "'"); continue; } - byProvince.put(provinceId, region); + loadedByProvince.put(provinceId, region); } } + regions = loadedRegions; + byId = loadedById; + byProvince = loadedByProvince; } public static List<MapRegion> getRegions() { diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/RelationLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/RelationLoader.java index 2dca1efe..376904bd 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/RelationLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/RelationLoader.java @@ -4,7 +4,6 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; -import java.util.Set; import java.util.logging.Logger; import org.bukkit.configuration.InvalidConfigurationException; @@ -111,38 +110,54 @@ public static Attitude getAttitude(String id) { return null; } public void loadRelationTypes(File configFile) { - SimpleFactions plugin = SimpleFactions.getInstance(); - Logger logger = plugin != null ? plugin.getLogger() : Logger.getLogger(RelationLoader.class.getName()); - FileConfiguration config = new YamlConfiguration(); - try { - config.load(configFile); - } catch (IOException | InvalidConfigurationException e) { - e.printStackTrace(); - } - Set<String> set = config.getConfigurationSection("types").getKeys(false); - - List<String> list = new ArrayList<String>(set); - types.clear(); - for(String key : list) { - RelationType r = new RelationType(key, config.getConfigurationSection("types."+key)); - logger.info("loaded relationtype "+r.getId()); - types.add(r); + FileConfiguration config = readConfiguration(configFile); + try { + var section = config.getConfigurationSection("types"); + if (section == null) throw new IllegalArgumentException("Missing relation types section"); + List<RelationType> staged = new ArrayList<>(); + for (String key : section.getKeys(false)) { + var row = section.getConfigurationSection(key); + if (row == null) throw new IllegalArgumentException("Invalid relation type: " + key); + staged.add(new RelationType(key, row)); + } + types.clear(); + types.addAll(staged); + for (RelationType relation : staged) logger().info("loaded relationtype " + relation.getId()); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("Cannot load relation types from " + configFile, e); } } + public void loadAttitudes(File configFile) { - FileConfiguration config = new YamlConfiguration(); - try { - config.load(configFile); - } catch (IOException | InvalidConfigurationException e) { - e.printStackTrace(); - } - Set<String> set = config.getConfigurationSection("attitudes").getKeys(false); + FileConfiguration config = readConfiguration(configFile); + try { + var section = config.getConfigurationSection("attitudes"); + if (section == null) throw new IllegalArgumentException("Missing attitudes section"); + List<Attitude> staged = new ArrayList<>(); + for (String key : section.getKeys(false)) { + var row = section.getConfigurationSection(key); + if (row == null) throw new IllegalArgumentException("Invalid attitude: " + key); + staged.add(new Attitude(key, row)); + } + attitudes.clear(); + attitudes.addAll(staged); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("Cannot load attitudes from " + configFile, e); + } + } - List<String> list = new ArrayList<String>(set); - attitudes.clear(); - for(String key : list) { - Attitude a = new Attitude(key, config.getConfigurationSection("attitudes."+key)); - attitudes.add(a); + private static FileConfiguration readConfiguration(File file) { + FileConfiguration config = new YamlConfiguration(); + try { + config.load(file); + return config; + } catch (IOException | InvalidConfigurationException | IllegalArgumentException e) { + throw new IllegalStateException("Cannot read diplomacy configuration from " + file, e); } } + + private static Logger logger() { + SimpleFactions plugin = SimpleFactions.getInstance(); + return plugin != null ? plugin.getLogger() : Logger.getLogger(RelationLoader.class.getName()); + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/TierLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/TierLoader.java index 179e9149..8138f0e3 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/TierLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/TierLoader.java @@ -42,19 +42,17 @@ public static Tier getByLevel(int i) { return null; } public void load(File configFile) { - FileConfiguration config = new YamlConfiguration(); + FileConfiguration config = new YamlConfiguration(); + List<Tier> loaded = new ArrayList<>(); try { - config.load(configFile); - } catch (IOException | InvalidConfigurationException e) { - e.printStackTrace(); + config.load(configFile); + for (String key : config.getKeys(false)) { + loaded.add(new Tier(key, config.getConfigurationSection(key))); + } + } catch (IOException | InvalidConfigurationException | RuntimeException e) { + throw new IllegalStateException("Cannot load definitions from " + configFile, e); } - Set<String> set = config.getKeys(false); - - List<String> list = new ArrayList<String>(set); - - for(String key : list) { - Tier r = new Tier(key, config.getConfigurationSection(key)); - oList.add(r); - } - } + oList.clear(); + oList.addAll(loaded); + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/TitleLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/TitleLoader.java index 02ecd518..2e3e7e3d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/TitleLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/TitleLoader.java @@ -23,10 +23,10 @@ public void reload() { } public void loadAll() { - titles.clear(); + List<Title> loaded = new ArrayList<>(); for (Tier tier : TierLoader.get()) { - String filename = tier.getId().toLowerCase() + ".json"; + String filename = tier.getId().toLowerCase(Locale.ROOT) + ".json"; File file = new File(inputFolder, filename); if (!file.exists()) { @@ -40,13 +40,15 @@ public void loadAll() { String id = entry.getKey(); JsonObject data = entry.getValue().getAsJsonObject(); Title title = new Title(tier, id, data); - titles.add(title); + loaded.add(title); } } catch (Exception e) { - e.printStackTrace(); + throw new IllegalStateException("Cannot load titles from " + file, e); } } + titles.clear(); + titles.addAll(loaded); } public static List<Title> getTitles() { @@ -88,7 +90,7 @@ public static boolean saveTitle(Title title) { } static boolean saveTitle(Title title, File folder) { - File file = new File(folder, title.getTier().getId().toLowerCase() + ".json"); + File file = new File(folder, title.getTier().getId().toLowerCase(Locale.ROOT) + ".json"); JsonObject root = new JsonObject(); if (file.exists()) { try (Reader reader = new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)) { @@ -141,18 +143,6 @@ static void writeEntry(JsonObject entry, Title title) { //Create new public static Title createNewTitle(Tier tier, String id, String name, String rgb, List<Integer> provinces, List<String> usedTitles, boolean titleComplete) { - File file = new File(inputFolder, tier.getId().toLowerCase() + ".json"); - JsonObject root = new JsonObject(); - - // Load existing file content if exists - if (file.exists()) { - try (Reader reader = new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)) { - root = JsonParser.parseReader(reader).getAsJsonObject(); - } catch (IOException e) { - e.printStackTrace(); - } - } - JsonObject newTitle = new JsonObject(); newTitle.addProperty("name", name); newTitle.addProperty("rgb", rgb); @@ -174,23 +164,12 @@ public static Title createNewTitle(Tier tier, String id, String name, String rgb newTitle.add("titles", titleArray); } - // Insert into the root JSON under the new ID - root.add(id, newTitle); - - // Save the updated file - try (Writer writer = new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8)) { - Gson gson = new GsonBuilder().setPrettyPrinting().create(); - gson.toJson(root, writer); - writer.flush(); - } catch (IOException e) { - e.printStackTrace(); - } - - // Optionally load it into the TitleLoader's memory now Title title = new Title(tier, id, newTitle); + if (!saveTitle(title)) { + return null; + } titles.add(title); return title; } } - diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/UpgradeLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/UpgradeLoader.java index da896b4d..9c128e6e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/UpgradeLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/UpgradeLoader.java @@ -30,19 +30,17 @@ public static Upgrade getByString(String id) { return null; } public void load(File configFile) { - FileConfiguration config = new YamlConfiguration(); + FileConfiguration config = new YamlConfiguration(); + Map<String, Upgrade> loaded = new java.util.LinkedHashMap<>(); try { - config.load(configFile); - } catch (IOException | InvalidConfigurationException e) { - e.printStackTrace(); + config.load(configFile); + for (String key : config.getKeys(false)) { + loaded.put(key, new Upgrade(key, config.getConfigurationSection(key))); + } + } catch (IOException | InvalidConfigurationException | RuntimeException e) { + throw new IllegalStateException("Cannot load definitions from " + configFile, e); } - Set<String> set = config.getKeys(false); - - List<String> list = new ArrayList<>(set); - - for(String key : list) { - Upgrade r = new Upgrade(key, config.getConfigurationSection(key)); - map.put(key, r); - } - } + map.clear(); + map.putAll(loaded); + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/VehiclesConfigLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/VehiclesConfigLoader.java index f53b907e..39d3e533 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/VehiclesConfigLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/VehiclesConfigLoader.java @@ -37,51 +37,50 @@ public static void load(File vehiclesYaml) { config.load(vehiclesYaml); } catch (IOException | InvalidConfigurationException e) { e.printStackTrace(); - fail("Failed to load vehicles.yml"); + throw failure("Failed to load vehicles.yml"); } - personalSlotLimit = config.getInt("personal-slot-limit", 1); - if (personalSlotLimit < 0) { - fail("vehicles.yml personal-slot-limit must be >= 0"); + int nextPersonalSlotLimit = config.getInt("personal-slot-limit", 1); + if (nextPersonalSlotLimit < 0) { + throw failure("vehicles.yml personal-slot-limit must be >= 0"); } - defaultPerPerson = config.getInt("default-per-person", 1); - if (defaultPerPerson < 1) { - fail("vehicles.yml default-per-person must be >= 1"); + int nextDefaultPerPerson = config.getInt("default-per-person", 1); + if (nextDefaultPerPerson < 1) { + throw failure("vehicles.yml default-per-person must be >= 1"); } boolean hasDefaultUpkeep = config.contains("default-upkeep"); double defaultUpkeep = hasDefaultUpkeep ? config.getDouble("default-upkeep") : 0.0; - if (hasDefaultUpkeep && defaultUpkeep < 0) { - fail("vehicles.yml default-upkeep must be >= 0"); + if (hasDefaultUpkeep && (!Double.isFinite(defaultUpkeep) || defaultUpkeep < 0)) { + throw failure("vehicles.yml default-upkeep must be finite and >= 0"); } - maintenanceHourlyDamagePercent = config.getInt("maintenance-hourly-damage-percent", 20); - if (maintenanceHourlyDamagePercent < 0 || maintenanceHourlyDamagePercent > 100) { - fail("vehicles.yml maintenance-hourly-damage-percent must be between 0 and 100"); + int nextMaintenanceHourlyDamagePercent = config.getInt("maintenance-hourly-damage-percent", 20); + if (nextMaintenanceHourlyDamagePercent < 0 || nextMaintenanceHourlyDamagePercent > 100) { + throw failure("vehicles.yml maintenance-hourly-damage-percent must be between 0 and 100"); } - maintenanceMinHealthPercent = config.getInt("maintenance-min-health-percent", 3); - if (maintenanceMinHealthPercent < 0 || maintenanceMinHealthPercent > 100) { - fail("vehicles.yml maintenance-min-health-percent must be between 0 and 100"); + int nextMaintenanceMinHealthPercent = config.getInt("maintenance-min-health-percent", 3); + if (nextMaintenanceMinHealthPercent < 0 || nextMaintenanceMinHealthPercent > 100) { + throw failure("vehicles.yml maintenance-min-health-percent must be between 0 and 100"); } - maintenanceIntervalTicks = config.getLong("maintenance-interval-ticks", 72000L); - if (maintenanceIntervalTicks < 1L) { - fail("vehicles.yml maintenance-interval-ticks must be >= 1"); + long nextMaintenanceIntervalTicks = config.getLong("maintenance-interval-ticks", 72000L); + if (nextMaintenanceIntervalTicks < 1L) { + throw failure("vehicles.yml maintenance-interval-ticks must be >= 1"); } Set<String> excluded = new HashSet<>(); for (String category : config.getStringList("fee-excluded-categories")) { excluded.add(category.toLowerCase(java.util.Locale.ROOT)); } - feeExcludedCategories = Collections.unmodifiableSet(excluded); if (config.isConfigurationSection("upkeep")) { - fail("vehicles.yml uses legacy upkeep block; use categories.<category>.<type>.upkeep instead"); + throw failure("vehicles.yml uses legacy upkeep block; use categories.<category>.<type>.upkeep instead"); } ConfigurationSection categoriesSection = config.getConfigurationSection("categories"); if (categoriesSection == null) { - fail("vehicles.yml missing required categories section"); + throw failure("vehicles.yml missing required categories section"); } Set<String> categories = new HashSet<>(); @@ -90,8 +89,10 @@ public static void load(File vehiclesYaml) { Map<String, String> displayNames = new HashMap<>(); for (String categoryId : categoriesSection.getKeys(false)) { - String normalizedCategoryId = categoryId.toLowerCase(); - categories.add(normalizedCategoryId); + String normalizedCategoryId = categoryId.toLowerCase(java.util.Locale.ROOT); + if (!categories.add(normalizedCategoryId)) { + throw failure("vehicles.yml duplicate category id: " + categoryId); + } ConfigurationSection categorySection = categoriesSection.getConfigurationSection(categoryId); Map<String, VehicleTypeConfig> types = new HashMap<>(); @@ -107,7 +108,7 @@ public static void load(File vehiclesYaml) { } String path = "categories." + categoryId + "." + vehicleTypeId; if (!config.contains(path + ".size")) { - fail("vehicles.yml " + path + ".size is required"); + throw failure("vehicles.yml " + path + ".size is required"); } double upkeep; @@ -116,21 +117,20 @@ public static void load(File vehiclesYaml) { } else if (hasDefaultUpkeep) { upkeep = defaultUpkeep; } else { - fail("vehicles.yml " + path + ".upkeep is required"); - upkeep = 0; + throw failure("vehicles.yml " + path + ".upkeep is required"); } - if (upkeep < 0) { - fail("vehicles.yml " + path + ".upkeep must be >= 0"); + if (!Double.isFinite(upkeep) || upkeep < 0) { + throw failure("vehicles.yml " + path + ".upkeep must be finite and >= 0"); } int size = config.getInt(path + ".size"); if (size <= 0) { - fail("vehicles.yml " + path + ".size must be > 0"); + throw failure("vehicles.yml " + path + ".size must be > 0"); } - int perPerson = config.getInt(path + ".per-person", defaultPerPerson); + int perPerson = config.getInt(path + ".per-person", nextDefaultPerPerson); if (perPerson < 1) { - fail("vehicles.yml " + path + ".per-person must be >= 1"); + throw failure("vehicles.yml " + path + ".per-person must be >= 1"); } boolean ignoreLimit = config.getBoolean(path + ".ignore-limit", false); @@ -138,9 +138,9 @@ public static void load(File vehiclesYaml) { ? config.getBoolean(path + ".show-on-upcoming-battle-icon") : categoryShowIcon; - String normalizedTypeId = vehicleTypeId.toLowerCase(); + String normalizedTypeId = vehicleTypeId.toLowerCase(java.util.Locale.ROOT); if (typeToCategory.containsKey(normalizedTypeId)) { - fail("vehicles.yml duplicate vehicle type id: " + vehicleTypeId); + throw failure("vehicles.yml duplicate vehicle type id: " + vehicleTypeId); } types.put( normalizedTypeId, @@ -151,6 +151,12 @@ public static void load(File vehiclesYaml) { byCategory.put(normalizedCategoryId, Collections.unmodifiableMap(types)); } + personalSlotLimit = nextPersonalSlotLimit; + defaultPerPerson = nextDefaultPerPerson; + maintenanceHourlyDamagePercent = nextMaintenanceHourlyDamagePercent; + maintenanceMinHealthPercent = nextMaintenanceMinHealthPercent; + maintenanceIntervalTicks = nextMaintenanceIntervalTicks; + feeExcludedCategories = Collections.unmodifiableSet(excluded); categoryIds = Collections.unmodifiableSet(categories); typesByCategory = Collections.unmodifiableMap(byCategory); categoryByVehicleTypeId = Collections.unmodifiableMap(typeToCategory); @@ -219,7 +225,7 @@ public static Optional<String> getCategoryId(String vehicleTypeId) { if (vehicleTypeId == null || vehicleTypeId.isEmpty()) { return Optional.empty(); } - return Optional.ofNullable(categoryByVehicleTypeId.get(vehicleTypeId.toLowerCase())); + return Optional.ofNullable(categoryByVehicleTypeId.get(vehicleTypeId.toLowerCase(java.util.Locale.ROOT))); } public static int getSize(String vehicleTypeId) { @@ -235,14 +241,14 @@ public static String getCategoryDisplayName(String categoryId) { if (categoryId == null || categoryId.isEmpty()) { return null; } - return categoryDisplayNames.get(categoryId.toLowerCase()); + return categoryDisplayNames.get(categoryId.toLowerCase(java.util.Locale.ROOT)); } public static Map<String, VehicleTypeConfig> getTypesInCategory(String categoryId) { if (categoryId == null || categoryId.isEmpty()) { return Map.of(); } - Map<String, VehicleTypeConfig> types = typesByCategory.get(categoryId.toLowerCase()); + Map<String, VehicleTypeConfig> types = typesByCategory.get(categoryId.toLowerCase(java.util.Locale.ROOT)); return types == null ? Map.of() : types; } @@ -250,17 +256,17 @@ private static VehicleTypeConfig resolveType(String vehicleTypeId) { if (vehicleTypeId == null || vehicleTypeId.isEmpty()) { return null; } - String categoryId = categoryByVehicleTypeId.get(vehicleTypeId.toLowerCase()); + String categoryId = categoryByVehicleTypeId.get(vehicleTypeId.toLowerCase(java.util.Locale.ROOT)); if (categoryId == null) { return null; } - return typesByCategory.get(categoryId).get(vehicleTypeId.toLowerCase()); + return typesByCategory.get(categoryId).get(vehicleTypeId.toLowerCase(java.util.Locale.ROOT)); } - private static void fail(String message) { + private static IllegalStateException failure(String message) { if (Bukkit.getServer() != null) { Bukkit.getLogger().severe("[SimpleFactions] " + message); } - throw new IllegalStateException(message); + return new IllegalStateException(message); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java index 03e4dbae..7f1a9fac 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java @@ -133,6 +133,21 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§cYou cannot delete the base guild of a faction"); return true; } + // Administrators may remove an unsettled guild, but leaders must settle it first. + if(!Permissions.isAdmin(sender)) { + if(guild.isBankrupt()) { + p.sendMessage("§cCannot delete a guild that is in bankruptcy!"); + return true; + } + if(guild.getBank() != null && guild.getBank().getWealth() > 0) { + p.sendMessage("§cCannot delete a guild while the bank balance is above 0"); + return true; + } + if(!guild.getLoanHandler().getLoansTaken().isEmpty()) { + p.sendMessage("§cCannot delete a guild with active loans"); + return true; + } + } guild.getFaction().getGuildHandler().removeGuild(guild.getId()); guild.getFaction().getProvinceHandler().revalidateClaims(); for(String member : guild.getMembers()) { @@ -246,6 +261,10 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, return true; } Guild g = FactionManager.getGuildByString(args[1]); + if(g == null) { + p.sendMessage("§cNo guild by the id "+args[1]); + return true; + } if(!g.consumeInvite(p.getName())) { p.sendMessage("§cYou need to be invited to this guild by the leader first!"); return true; @@ -303,39 +322,6 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, } } return true; - } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("delete") && args.length == 2) { - Guild g = FactionManager.getGuildByString(args[1]); - if(g == null) { - p.sendMessage("§a[SimpleFactions]§c Error! guild does not exist!"); - return true; - } - if(!Permissions.isAdmin(sender)) { - if(!g.getMembers().contains(p.getName())) { - p.sendMessage("§cCannot delete a guild you are not part of!"); - return true; - } - if(g.isBankrupt()) { - p.sendMessage("§cCannot delete a guild that is in bankruptcy!"); - return true; - } - if(!p.getName().equalsIgnoreCase(g.getLeader())) { - p.sendMessage("§cOnly the guild leader can delete the guild!"); - return true; - } - if(g.getBank() != null && g.getBank().getWealth() > 0){ - p.sendMessage("§cCannot delete a guild while the bank balance is above 0"); - return true; - } - if(g.getLoanHandler().getLoansTaken().size() > 0) { - p.sendMessage("§cCannot delete a guild with active loans"); - return true; - } - } - - Faction host = g.getFaction(); - host.getGuildHandler().removeGuild(g.getId()); - p.sendMessage("§aGuild "+g.getName()+" §adeleted!"); - return true; } else if(cmd.getName().equalsIgnoreCase(cmd2) && args[0].equalsIgnoreCase("setbank") && args.length == 1) { if(FactionManager.getGuildByLeader(p.getName()) != null) { Guild g = FactionManager.getGuildByLeader(p.getName()); @@ -643,7 +629,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§cYou need to be a faction leader to deconstruct installations"); return true; } - InventoryManager inv = new InventoryManager(); + InventoryManager inv = FactionManager.getInv(); if(args.length == 1) { inv.installationsView(null, p, f, true); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); @@ -681,7 +667,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, p.sendMessage("§cNo installation with id §f" + args[1]); return true; } - InventoryManager inv = new InventoryManager(); + InventoryManager inv = FactionManager.getInv(); inv.confirming.put(p, f); inv.confirmView(p, f, "installation_upgrade", args[1]); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); @@ -746,7 +732,7 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, net.tfminecraft.simplefactions.vehicles.berth.VehicleFindMessages.sendInstallationVehicles( p, installation); return true; - } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("unclaim") && args.length >= 1) { + } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("unclaim") && (args.length == 1 || args.length == 2)) { if(!Cache.requireProvinces(p)) { return true; } @@ -970,6 +956,10 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, return true; } Faction f = FactionManager.getByString(args[1]); + if(f == null) { + p.sendMessage("§cNo faction with that name exists"); + return true; + } if(!f.consumeInvite(p.getName())) { p.sendMessage("§cYou need to be invited to this faction by the leader first!"); return true; @@ -1233,7 +1223,17 @@ private boolean dispatchCommand(CommandSender sender, Command cmd, String label, return true; } String type = args[2]; - Double amount = Double.parseDouble(args[3]); + double amount; + try { + amount = Double.parseDouble(args[3]); + } catch (NumberFormatException exception) { + p.sendMessage("§cAmount must be a finite number"); + return false; + } + if (!Double.isFinite(amount)) { + p.sendMessage("§cAmount must be a finite number"); + return false; + } Modifier m = new Modifier(type, amount, true); f.addPersistentPrestigeModifier(m); f.updatePrestige(); @@ -1476,25 +1476,28 @@ else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("forced p.sendMessage("§a[SimpleFactions]§c Error! faction does not exist!"); return true; } - double amount = 0.0; - try { - amount = Double.parseDouble(args[2]); - } catch (Exception e) { - p.sendMessage("Error reading amount, setting it to 0"); - } - if(amount <= 0) { - p.sendMessage("§cAmount must be greater than 0"); + Double parsed = BankAmount.parse(args[2]); + if(parsed == null) { + p.sendMessage("§cEnter a positive amount in whole cents, such as 10 or 2.50"); return false; } + double amount = parsed; + Bank bank = f.getBank(); + if(bank == null) { + p.sendMessage("§cThis faction has not established a bank"); + return true; + } - if(f.getBank().getWealth() < amount) { + if(bank.getWealth() < amount) { p.sendMessage("§cBank does not have enough wealth"); return true; } - f.getBank().withdraw(amount); + bank.withdraw(amount); List<ItemStack> items = DenarEconomy.getMoneyManager().amountToItems(amount); for(ItemStack i : items) { - p.getInventory().addItem(i); + for(ItemStack leftover : p.getInventory().addItem(i).values()) { + p.getWorld().dropItemNaturally(p.getLocation(), leftover); + } } p.sendMessage("§6"+amount+" §cwas withdrawn from the bank of "+f.getName()); for(String s : f.getMembers()) { @@ -1631,16 +1634,26 @@ else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("forced if(!Cache.requireProvinces(p)) { return true; } - SimpleFactions.reloadTitles(); - p.sendMessage("§eReloaded titles!"); + try { + SimpleFactions.reloadTitles(); + p.sendMessage("§eReloaded titles!"); + } catch (RuntimeException error) { + p.sendMessage("§cCould not reload titles: " + error.getMessage()); + Bukkit.getLogger().log(java.util.logging.Level.WARNING, "SimpleFactions title reload failed", error); + } return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("reloadconfigs") && args.length == 1) { if(!Permissions.isAdmin(sender)) { p.sendMessage("§a[SimpleFactions]§c You do not have access to this command"); return true; } - SimpleFactions.reloadConfigs(); - p.sendMessage("§eReloaded configs!"); + try { + SimpleFactions.reloadConfigs(); + p.sendMessage("§eReloaded configs!"); + } catch (RuntimeException error) { + p.sendMessage("§cCould not reload configs: " + error.getMessage()); + Bukkit.getLogger().log(java.util.logging.Level.WARNING, "SimpleFactions configuration reload failed", error); + } return true; } else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("destroytitle") && args.length == 2) { if(!Permissions.isAdmin(sender)) { @@ -1798,6 +1811,10 @@ else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("forced p.sendMessage("§cAmount must be a number"); return false; } + if (!Double.isFinite(amount)) { + p.sendMessage("§cAmount must be a finite number"); + return false; + } faction.getGovernment().setPower(amount); p.sendMessage("§aSet admin power of "+faction.getName()+" §ato "+faction.getGovernment().getPower()); return true; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java index 15687a7a..69e04c9d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java @@ -140,10 +140,7 @@ public static void loadRelations() { for(Map.Entry<Faction, List<String>> entry : dbRelations.entrySet()) { Faction f = entry.getKey(); List<String> relations = entry.getValue(); - LogManager.relations("load %s rawCount=%d", factionId(f), relations == null ? 0 : relations.size()); - if (relations == null) { - continue; - } + LogManager.relations("load %s rawCount=%d", factionId(f), relations.size()); for(String s : relations) { applyStoredRelation(f, s); } @@ -151,10 +148,7 @@ public static void loadRelations() { for(Map.Entry<Faction, List<String>> entry : dbTradeRelations.entrySet()) { Faction f = entry.getKey(); List<String> relations = entry.getValue(); - LogManager.relations("loadTrade %s rawCount=%d", factionId(f), relations == null ? 0 : relations.size()); - if (relations == null) { - continue; - } + LogManager.relations("loadTrade %s rawCount=%d", factionId(f), relations.size()); for(String s : relations) { applyStoredTradeRelation(f, s); } @@ -162,10 +156,7 @@ public static void loadRelations() { for(Map.Entry<Faction, List<String>> entry : dbTreatyRelations.entrySet()) { Faction f = entry.getKey(); List<String> relations = entry.getValue(); - LogManager.relations("loadTreaty %s rawCount=%d", factionId(f), relations == null ? 0 : relations.size()); - if (relations == null) { - continue; - } + LogManager.relations("loadTreaty %s rawCount=%d", factionId(f), relations.size()); for(String s : relations) { applyStoredTreatyRelation(f, s); } @@ -247,7 +238,7 @@ private static void applyStoredTradeRelation(Faction f, String s) { } String info = s.split("\\(")[1].replace(")", ""); RelationType r = RelationLoader.getType(info); - if(r == null) { + if(r == null || !r.isTradeAgreement()) { LogManager.relations("SKIP trade %s -> %s type=%s", factionId(f), targetId, info); return; } @@ -758,6 +749,8 @@ public void start(List<Faction> l) { public static void addFaction(Faction f) { net.tfminecraft.simplefactions.espionage.EspionageService.initializeFounder(f); factions.add(f); + // A new faction changes the global wealth denominator; calculate against the full registry. + updateAllPrestige(); if (f.getProvinces() == null || f.getProvinces().isEmpty()) { return; } @@ -1013,7 +1006,7 @@ public static void requestRelocation(Player sender, Guild g, Faction target, int p.sendMessage("§7Type §a/faction accept §7to accept"); p.sendMessage("§7Request will time out in 60 seconds"); sender.sendMessage("§aRelocation request sent to "+target.getName()); - RequestManager.addRequest(sender, p, new RelocateRequest(g, capital, settlementName)); + RequestManager.addRequest(sender, p, new RelocateRequest(g, capital, settlementName, target)); } public static void acceptRelocateRequest(Player p) { @@ -1024,6 +1017,10 @@ public static void acceptRelocateRequest(Player p) { return; } Guild sender = req.getSender(); + if (!req.isCurrentFor(reciever)) { + p.sendMessage("§cThis relocation request is no longer valid. Ask the guild to send a new request."); + return; + } int capital = req.getNewCapital(); if(reciever.getSettlementHandler().requiresFoundingName(capital) && (req.getSettlementName() == null || req.getSettlementName().isBlank())) { @@ -1036,15 +1033,25 @@ public static void acceptRelocateRequest(Player p) { } double cost = sender.getRelocationCost(capital); Player sp = Bukkit.getPlayerExact(sender.getLeader()); - if(sender.getBank().getWealth() < cost) { + if(sender.getBank() == null || !Double.isFinite(cost) || cost < 0 || sender.getBank().getWealth() < cost) { p.sendMessage("§c"+sender.getName()+" does not have enough funds to relocate (Cost: "+Formatter.formatDouble(cost)+"d)"); if(sp != null && sp.isOnline()) sp.sendMessage("§cYour guild does not have enough funds to relocate (Cost: "+Formatter.formatDouble(cost)+"d)"); return; } - sender.getBank().withdraw(cost); - if(sp != null && sp.isOnline()) sp.sendMessage(reciever.getName()+" §aaccepted your request to relocate to their faction"); Player actor = (sp != null && sp.isOnline()) ? sp : p; + if (!reciever.hasProvince(capital)) { + p.sendMessage("§cYour faction no longer owns the relocation province."); + return; + } + var validation = reciever.getSettlementHandler() + .validateRelocationCapital(actor, capital, req.getSettlementName()); + if (!validation.isSuccess()) { + p.sendMessage(validation.getMessage()); + return; + } sender.relocate(reciever, capital, actor, req.getSettlementName()); + sender.getBank().withdraw(cost); + if(sp != null && sp.isOnline()) sp.sendMessage(reciever.getName()+" §aaccepted your request to relocate to their faction"); p.sendMessage(sender.getName()+"§a has been relocated to your faction"); } @@ -1062,13 +1069,17 @@ public static void requestElevation(Player sender, Guild target) { } public static void acceptElevationRequest(Player p) { - ElevateRequest req = (ElevateRequest) RequestManager.getRequest(p); + if (!(RequestManager.getRequest(p) instanceof ElevateRequest req)) return; Guild guild = FactionManager.getGuildByLeader(p.getName()); if(guild == null) { p.sendMessage("§cYou are not the leader of a guild"); return; } - Guild sender = req.getSender(); + Faction sponsoringFaction = req.getSponsoringFaction(); + if (guild != req.getSender() || guild.getFaction() != sponsoringFaction) { + p.sendMessage("§cThat elevation request is no longer valid."); + return; + } double cost = guild.getElevationCost(); if(guild.getFaction().getGovernment().getPower() < cost) { p.sendMessage("§cCannot afford to elevate"); @@ -1080,7 +1091,7 @@ public static void acceptElevationRequest(Player p) { p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 1f); return; } - sender.getFaction().getGovernment().spendPower(cost); + sponsoringFaction.getGovernment().spendPower(cost); p.sendMessage("§aGuild elevated to Faction!"); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); } @@ -1095,11 +1106,11 @@ public static void requestMovementJoin(Player sender, Movement movement, String p.sendMessage("§7Type §a/faction accept §7to accept"); p.sendMessage("§7Request will time out in 60 seconds"); sender.sendMessage("§aJoin request sent to "+movement.getLeader()); - RequestManager.addRequest(sender, p, new MovementJoinRequest(null, sender.getName(), type, movement.getFaction().getId(), cause == null ? -1 : cause.getIndex())); + RequestManager.addRequest(sender, p, new MovementJoinRequest(null, sender.getName(), type, movement, cause)); } public static void acceptMovementJoinRequest(Player p) { - MovementJoinRequest req = (MovementJoinRequest) RequestManager.getRequest(p); + if (!(RequestManager.getRequest(p) instanceof MovementJoinRequest req)) return; String sender = req.getPlayer(); Player sp = Bukkit.getPlayerExact(sender); if(sp == null || !sp.isOnline()) { @@ -1116,14 +1127,15 @@ public static void acceptMovementJoinRequest(Player p) { p.sendMessage("§cYou are not the leader of a movement"); return; } + if (target != req.getMovement()) { + p.sendMessage("§cThe requested movement is no longer available."); + return; + } Object o = target.getJoiningAs(sp); - Cause cause = null; - if(req.getCauseIndex() != -1) { - cause = target.getCauses().get(req.getCauseIndex()); - if(cause == null) { - p.sendMessage("§cSpecified cause no longer exists"); - return; - } + Cause cause = req.getCause(); + if (cause != null && !target.getCauses().contains(cause)) { + p.sendMessage("§cSpecified cause no longer exists"); + return; } if ("foreign_backer".equalsIgnoreCase(req.getType())) { Faction fac = getByMember(sender); @@ -1182,32 +1194,30 @@ public static void requestMovementLeaderTarget(Player sender, Movement movement, new MovementLeaderTargetRequest( null, sender.getName(), - movement.getId(), - cause.getIndex(), + movement, + cause, targetName)); } public static void acceptMovementLeaderTargetRequest(Player p) { - MovementLeaderTargetRequest req = (MovementLeaderTargetRequest) RequestManager.getRequest(p); - if (req == null) { - return; - } + if (!(RequestManager.getRequest(p) instanceof MovementLeaderTargetRequest req)) return; if (!p.getName().equalsIgnoreCase(req.getProposedName())) { p.sendMessage("§cThat request is not for you."); return; } Movement movement = getMovementById(req.getMovementId()); - if (movement == null || movement.isFrozen()) { + if (movement == null || movement != req.getMovement() || movement.isFrozen()) { p.sendMessage("§cThat movement is no longer available."); return; } - if (req.getCauseIndex() < 0 || req.getCauseIndex() >= movement.getCauses().size()) { + Cause cause = req.getCause(); + if (cause == null || !movement.getCauses().contains(cause) + || cause.getProposal() == null || !cause.getProposal().needsTarget()) { p.sendMessage("§cSpecified cause no longer exists"); return; } - Cause cause = movement.getCauses().get(req.getCauseIndex()); - if (cause == null || cause.getProposal() == null || !cause.getProposal().needsTarget()) { - p.sendMessage("§cSpecified cause no longer exists"); + if (!cause.hasLeader() || !cause.getLeader().equalsIgnoreCase(req.getRequester())) { + p.sendMessage("§cThe requester no longer leads that cause."); return; } Faction host = movement.getFaction(); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java index 6bc4c8ca..90c84418 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java @@ -509,6 +509,7 @@ public void taxChat(Player p, AsyncPlayerChatEvent e) { double amount = 0; try { amount = Double.parseDouble(e.getMessage()); + if (!Double.isFinite(amount)) throw new NumberFormatException("Amount must be finite"); } catch (Exception ex) { p.sendMessage("§cError inputting the amount, use the format §e15.67 §cfor 15.67% tax (example)"); p.sendMessage("§4Type 'cancel' to cancel."); @@ -598,6 +599,7 @@ public void feeChat(Player p, AsyncPlayerChatEvent e) { double amount; try { amount = Double.parseDouble(e.getMessage()); + if (!Double.isFinite(amount)) throw new NumberFormatException("Amount must be finite"); } catch (Exception ex) { p.sendMessage(kind.isPercent() ? "§cError inputting the amount, use the format §e15.5 §cfor 15.5% of upkeep (example)" @@ -701,6 +703,7 @@ public void dividendChat(Player p, AsyncPlayerChatEvent e) { double amount; try { amount = Double.parseDouble(e.getMessage()); + if (!Double.isFinite(amount)) throw new NumberFormatException("Amount must be finite"); } catch (Exception ex) { p.sendMessage("§cError inputting the amount, use the format §e20 §cfor 20% (example)"); p.sendMessage("§4Type 'cancel' to cancel."); @@ -895,30 +898,34 @@ private void refreshAfterQueueCancel(Player player, QueueCancelPayload.Parsed pa } } - private boolean executeQueueCancel(QueueCancelPayload.Parsed parsed) { + private boolean executeQueueCancel(Player player, QueueCancelPayload.Parsed parsed) { return switch (parsed.type()) { case MILITARY -> { Faction f = FactionManager.getByString(parsed.ownerId()); - yield f != null && f.getMilitary().cancelQueue(parsed.index()); + yield f != null && player.getName().equalsIgnoreCase(f.getLeader()) + && f.getMilitary().cancelQueue(parsed.index()); } case GUILD_UPGRADE -> { Guild guild = FactionManager.getGuildByString(parsed.ownerId()); - yield guild != null && guild.cancelUpgradeQueue(parsed.index()); + yield guild != null && guild.isLeader(player) && guild.cancelUpgradeQueue(parsed.index()); } case INSTALLATION -> { Faction f = FactionManager.getByString(parsed.ownerId()); - yield f != null && f.getInstallationHandler().cancelPending(parsed.detail()); + yield f != null && player.getName().equalsIgnoreCase(f.getLeader()) + && f.getInstallationHandler().cancelPending(parsed.detail()); } case COMPANY_SLOT -> { Guild guild = FactionManager.getGuildByString(parsed.ownerId()); yield guild != null && guild.getCompany() != null + && guild.getCompany().isLeader(player.getName()) && guild.getCompany().cancelSlotQueue(parsed.index()); } case COMPANY_UPGRADE -> { Guild guild = FactionManager.getGuildByString(parsed.ownerId()); yield guild != null && guild.getCompany() != null + && guild.getCompany().isLeader(player.getName()) && guild.getCompany().cancelUpgradeQueue(parsed.index()); } }; @@ -1051,21 +1058,10 @@ public ItemStack createBackButton(SFGUI gui) { @EventHandler public void dragInWarGui(InventoryDragEvent e) { Inventory inv = e.getView().getTopInventory(); - if (inv.getHolder() instanceof WarInventoryHolder - || inv.getHolder() instanceof CampaignInventoryHolder - || inv.getHolder() instanceof CampaignRaidLaunchHolder - || inv.getHolder() instanceof SFCombinedInventoryHolder - || inv.getHolder() instanceof DeclareWarHolder) { - e.setCancelled(true); - } - if (inv.getHolder() instanceof SFInventoryHolder - && (((SFInventoryHolder) inv.getHolder()).getType() == SFGUI.PLAYER_LEDGER_VIEW - || EspionageView.handles(((SFInventoryHolder) inv.getHolder()).getType()))) { - e.setCancelled(true); - } - // Confirm screens have no holder; an item dragged into one is lost when it closes. - if (e.getView().getTitle().equalsIgnoreCase("§7Confirm Action") - && e.getRawSlots().stream().anyMatch(slot -> slot < inv.getSize())) { + // Menu contents are replaced on redraw. Keep player items out of every menu, + // including holderless confirmations, while allowing drags within their own inventory. + if ((isPluginMenu(inv) || e.getView().getTitle().equalsIgnoreCase("§7Confirm Action")) + && e.getRawSlots().stream().anyMatch(slot -> isTopInventoryClick(slot, inv.getSize()))) { e.setCancelled(true); } } @@ -1230,9 +1226,9 @@ public void clickButton(InventoryClickEvent e) { break; case WAR_PEACE_SELECT: if (h.getFlag()) { - Movement causeMovement = f.getGovernment().getMovementByMember(p.getName()); - if (causeMovement != null && h.getPage() >= 0 && h.getPage() < causeMovement.getCauses().size()) { - movementView.causeView(p, f, causeMovement, causeMovement.getCauses().get(h.getPage()), null); + Cause selectedCause = governmentView.displayedWarPeaceCause(p, f, inv); + if (selectedCause != null) { + movementView.causeView(p, f, selectedCause.getMovement(), selectedCause, null); } else { governmentView(p, f, null); } @@ -1274,24 +1270,18 @@ public void clickButton(InventoryClickEvent e) { break; case CAUSES_VIEW: if (movement != null && f != null) movementView(p, f, movement, null); - else if (f != null) governmentView(p, f, null); break; case CAUSE_VIEW: if (movement != null && f != null) causesView(p, f, movement, null); - else if (f != null) governmentView(p, f, null); break; case TARGET_SELECT: if (movement != null && f != null) { - if(h.getPage() != -1) { - Cause cause = movement.getCauses().get(h.getPage()); - if(cause != null) { - causeView(p, f, movement, cause, inv); - } + Cause cause = movementView.displayedCause(inv, movement); + if(cause != null) { + causeView(p, f, movement, cause, null); } else { movementView(p, f, movement, null); } - } else if (f != null) { - governmentView(p, f, null); } break; case LEDGER_VIEW: @@ -1481,7 +1471,7 @@ public void clickButton(InventoryClickEvent e) { p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); return; } - if (executeQueueCancel(cancel)) { + if (executeQueueCancel(p, cancel)) { p.sendMessage("§aQueue item cancelled."); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); } else { @@ -1514,6 +1504,11 @@ public void clickButton(InventoryClickEvent e) { data = m.getPersistentDataContainer().get(key, PersistentDataType.STRING); if(data != null) { Faction f = confirming.get(p); + if (f == null || !p.getName().equalsIgnoreCase(f.getLeader())) { + confirming.remove(p); + p.closeInventory(); + return; + } if(item.getType().equals(Material.RED_CONCRETE)) { factionView(p, f); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); @@ -1535,6 +1530,11 @@ public void clickButton(InventoryClickEvent e) { Faction f = confirming.get(p); boolean fromCommand = installationConfirmFromCommand.getOrDefault(p, false); installationConfirmFromCommand.remove(p); + if (f == null || !p.getName().equalsIgnoreCase(f.getLeader())) { + confirming.remove(p); + p.closeInventory(); + return; + } if(item.getType().equals(Material.RED_CONCRETE)) { if(fromCommand) { installationsView(null, p, f, true); @@ -1558,6 +1558,11 @@ public void clickButton(InventoryClickEvent e) { data = m.getPersistentDataContainer().get(key, PersistentDataType.STRING); if(data != null) { Faction f = confirming.get(p); + if (f == null || !p.getName().equalsIgnoreCase(f.getLeader())) { + confirming.remove(p); + p.closeInventory(); + return; + } if(item.getType().equals(Material.RED_CONCRETE)) { installationDetailView(p, f, data); return; @@ -1578,6 +1583,11 @@ public void clickButton(InventoryClickEvent e) { data = m.getPersistentDataContainer().get(key, PersistentDataType.STRING); if (data != null) { Faction f = confirming.get(p); + if (f == null || !p.getName().equalsIgnoreCase(f.getLeader())) { + confirming.remove(p); + p.closeInventory(); + return; + } if (item.getType().equals(Material.RED_CONCRETE)) { installationDetailView(p, f, data); return; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/MercenaryCommandManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/MercenaryCommandManager.java index cf17e879..ee7d2885 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/MercenaryCommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/MercenaryCommandManager.java @@ -140,7 +140,9 @@ private void draft(Player p) { p.sendMessage("§cOnly a company leader may draft a contract."); return; } - p.getInventory().addItem(ContractBook.draftBook(company)); + for (ItemStack leftover : p.getInventory().addItem(ContractBook.draftBook(company)).values()) { + p.getWorld().dropItemNaturally(p.getLocation(), leftover); + } p.sendMessage("§aDraft written. Fill in the terms page and sign it."); } @@ -157,15 +159,25 @@ private void offer(Player p, String factionName) { } ItemStack held = p.getInventory().getItemInMainHand(); if (held == null || !(held.getItemMeta() instanceof BookMeta meta) - || ContractBook.stage(meta) == null) { + || !Integer.valueOf(ContractBook.STAGE_REVIEW).equals(ContractBook.stage(meta)) + || !company.getGuild().getId().equalsIgnoreCase(ContractBook.companyGuildId(meta))) { p.sendMessage("§cHold a reviewed contract book to offer it."); return; } + Long expiry = ContractBook.expiry(meta); + if (expiry == null || expiry <= System.currentTimeMillis()) { + p.sendMessage("§cThis contract has expired! Unable to process."); + return; + } ContractTerms terms = ContractBook.parseTerms(meta); if (terms == null) { p.sendMessage("§cThe terms page could not be read."); return; } + if (!ContractBook.matchesSnapshot(meta)) { + p.sendMessage("§cThis contract has been tampered with! Unable to process."); + return; + } Faction hirer = resolveFaction(factionName); if (hirer == null) { p.sendMessage("§cNo faction by that name."); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java index 92111422..7481f4f5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/PlayerManager.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.managers; +import java.util.function.Consumer; import org.bukkit.Bukkit; import org.bukkit.Chunk; import org.bukkit.Material; @@ -56,9 +57,10 @@ public void signBook(PlayerEditBookEvent e) { if(id == null) return; Guild issuer = FactionManager.getGuildByString(id); if(issuer == null) return; + Consumer<ItemStack> replacement = bookReplacement(e); Integer contractStage = ContractBook.stage(meta); if(contractStage != null) { - signContractBook(e, p, issuer, contractStage, newMeta); + signContractBook(e, p, issuer, contractStage, newMeta, replacement); return; } Integer stage = meta.getPersistentDataContainer().get(Keys.INT, PersistentDataType.INTEGER); @@ -77,7 +79,7 @@ public void run() { Long time = newMeta.getPersistentDataContainer().get(Keys.LONG, PersistentDataType.LONG); if(time != null && time < System.currentTimeMillis()) { p.sendMessage("§cThis loan contract has expired! Unable to process."); - p.getInventory().setItemInMainHand(new ItemStack(Material.WRITABLE_BOOK)); + replacement.accept(new ItemStack(Material.WRITABLE_BOOK)); return; } Loan draft = LoanBook.createLoanFromBook(newMeta, null); @@ -85,7 +87,7 @@ public void run() { p.sendMessage(LoanBook.INVALID_TERMS_MESSAGE); return; } - p.getInventory().setItemInMainHand(LoanBook.getEstimatedBook(draft)); + replacement.accept(LoanBook.getEstimatedBook(draft)); } }.runTaskLater(SimpleFactions.plugin, 1L); } @@ -97,7 +99,7 @@ public void run() { Long time = newMeta.getPersistentDataContainer().get(Keys.LONG, PersistentDataType.LONG); if(time != null && time < System.currentTimeMillis()) { p.sendMessage("§cThis loan contract has expired! Unable to process."); - p.getInventory().setItemInMainHand(new ItemStack(Material.WRITABLE_BOOK)); + replacement.accept(new ItemStack(Material.WRITABLE_BOOK)); return; } Loan draft = LoanBook.createLoanFromBook(newMeta, null); @@ -105,7 +107,7 @@ public void run() { p.sendMessage(LoanBook.INVALID_TERMS_MESSAGE); return; } - p.getInventory().setItemInMainHand(LoanBook.getLoanBook(draft)); + replacement.accept(LoanBook.getLoanBook(draft)); } }.runTaskLater(SimpleFactions.plugin, 1L); } else if(stage == 3) { @@ -117,7 +119,7 @@ public void run() { Long time = newMeta.getPersistentDataContainer().get(Keys.LONG, PersistentDataType.LONG); if(time != null && time < System.currentTimeMillis()) { p.sendMessage("§cThis loan contract has expired! Unable to process."); - p.getInventory().setItemInMainHand(new ItemStack(Material.WRITABLE_BOOK)); + replacement.accept(new ItemStack(Material.WRITABLE_BOOK)); return; } if(offerId == null) { @@ -129,7 +131,7 @@ public void run() { new BukkitRunnable() { @Override public void run() { - p.getInventory().setItemInMainHand(new ItemStack(Material.WRITABLE_BOOK)); + replacement.accept(new ItemStack(Material.WRITABLE_BOOK)); } }.runTaskLater(SimpleFactions.plugin, 1L); return; @@ -140,7 +142,7 @@ public void run() { new BukkitRunnable() { @Override public void run() { - p.getInventory().setItemInMainHand(new ItemStack(Material.WRITABLE_BOOK)); + replacement.accept(new ItemStack(Material.WRITABLE_BOOK)); } }.runTaskLater(SimpleFactions.plugin, 1L); return; @@ -188,7 +190,7 @@ public void run() { m.setTitle("§6Loan Agreement §7"+Cache.getFantasyDate(System.currentTimeMillis())); m.setAuthor(issuer.getLeader()+" and "+p.getName()); i.setItemMeta(m); - p.getInventory().setItemInMainHand(i); + replacement.accept(i); } }.runTaskLater(SimpleFactions.plugin, 5L); } @@ -200,7 +202,7 @@ public void run() { * and a government member of the hiring faction signs the agreement to accept. */ private void signContractBook( - PlayerEditBookEvent e, Player p, Guild host, int stage, BookMeta newMeta) { + PlayerEditBookEvent e, Player p, Guild host, int stage, BookMeta newMeta, Consumer<ItemStack> replacement) { e.setCancelled(true); MercenaryCompany company = host.getCompany(); if(company == null || !company.isFormed()) { @@ -210,7 +212,7 @@ private void signContractBook( Long expiry = ContractBook.expiry(newMeta); if(expiry != null && expiry < System.currentTimeMillis()) { p.sendMessage("§cThis contract has expired! Unable to process."); - replaceHeldBook(p, new ItemStack(Material.WRITABLE_BOOK)); + replaceHeldBook(replacement, new ItemStack(Material.WRITABLE_BOOK)); return; } ContractTerms terms = ContractBook.parseTerms(newMeta); @@ -229,7 +231,7 @@ private void signContractBook( p.sendMessage("§c"+valid.message()); return; } - replaceHeldBook(p, ContractBook.reviewBook(company, terms)); + replaceHeldBook(replacement, ContractBook.reviewBook(company, terms)); return; } if(stage == ContractBook.STAGE_REVIEW) { @@ -239,11 +241,11 @@ private void signContractBook( } if(!ContractBook.matchesSnapshot(newMeta)) { p.sendMessage("§cThis contract has been tampered with! Unable to process."); - replaceHeldBook(p, new ItemStack(Material.WRITABLE_BOOK)); + replaceHeldBook(replacement, new ItemStack(Material.WRITABLE_BOOK)); return; } p.sendMessage("§7Choose who to offer this to with §e/company offer <faction>"); - replaceHeldBook(p, ContractBook.reviewBook(company, terms)); + replaceHeldBook(replacement, ContractBook.reviewBook(company, terms)); return; } if(stage == ContractBook.STAGE_AGREEMENT) { @@ -255,11 +257,11 @@ private void signContractBook( } if(!ContractBook.matchesSnapshot(newMeta)) { p.sendMessage("§cThis contract has been tampered with! Unable to process."); - replaceHeldBook(p, new ItemStack(Material.WRITABLE_BOOK)); + replaceHeldBook(replacement, new ItemStack(Material.WRITABLE_BOOK)); return; } MercenaryResult result = company.getContractHandler() - .accept(contractId, contract.getHirer(), p.getName()); + .acceptAtHall(contractId, contract.getHirer(), p); p.sendMessage((result.ok() ? "§a" : "§c")+result.message()); if(!result.ok()) return; p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); @@ -268,16 +270,31 @@ private void signContractBook( leader.sendMessage("§a"+result.message()); leader.playSound(leader, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); } - replaceHeldBook(p, signedAgreement(company, contract, p.getName())); + replaceHeldBook(replacement, signedAgreement(company, contract, p.getName())); } } + /** Keep delayed updates bound to the original inventory slot and its unchanged book. */ + private Consumer<ItemStack> bookReplacement(PlayerEditBookEvent event) { + Player player = event.getPlayer(); + int slot = event.getSlot() == -1 ? 40 : event.getSlot(); + ItemStack original = player.getInventory().getItem(slot); + ItemStack expected = original == null ? null : original.clone(); + return replacement -> { + if (!player.isOnline() || expected == null || expected.getType() != Material.WRITABLE_BOOK) return; + ItemStack current = player.getInventory().getItem(slot); + if (current != null && current.getAmount() == expected.getAmount() && current.isSimilar(expected)) { + player.getInventory().setItem(slot, replacement); + } + }; + } + /** A signed book cannot be replaced in the same tick, so this waits one. */ - private void replaceHeldBook(Player p, ItemStack book) { + private void replaceHeldBook(Consumer<ItemStack> replacement, ItemStack book) { new BukkitRunnable() { @Override public void run() { - p.getInventory().setItemInMainHand(book); + replacement.accept(book); } }.runTaskLater(SimpleFactions.plugin, 1L); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java index 1b0a11d2..81624763 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/ProvinceManager.java @@ -182,12 +182,12 @@ public void recalculateForSingleGuild(Guild g, boolean save) { dropMissingGuilds(); recalculateGuild(g); recalculateProduction(g); + recalculateProsperity(); if(save) { for(Guild guild : FactionManager.getAllGuilds()) { getIncome(guild); } } - recalculateProsperity(); } finally { InstallationAccess.endRecalculation(); } @@ -305,10 +305,10 @@ public double getIncome(Guild guild, boolean save) { } income += provinceIncome; } + income = PillageTradeHit.applyToIncome(guild, income); if(save) { guild.getTradeBreakdown().setTariffs(tariffs); guild.getTradeBreakdown().setUpkeep(upkeep); - income = PillageTradeHit.applyToIncome(guild, income); guild.getTradeBreakdown().setIncome(income); guild.getTradeBreakdown().setTradePower(getTotalTrade(guild)); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java index b4ddab75..b48153fb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java @@ -17,6 +17,7 @@ import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; public class RelationManager { + private RelationManager() {} private static int tick = 0; @@ -48,8 +49,8 @@ public static void reset(Faction origin, Faction target, boolean hostile) { public static boolean endVassalage(Faction origin, Faction target, boolean hostile) { if(isOverlord(origin, target) || isOverlord(target, origin)) { - reset(origin, target, hostile); net.tfminecraft.simplefactions.war.commitment.WarCommitmentService.onVassalageEnded(origin, target); + reset(origin, target, hostile); MercenaryLoyaltyWatcher.onRelationChanged(origin, target); return true; } @@ -184,7 +185,7 @@ public static boolean atLimit(Faction f, RelationType r) { if(!r.hasLimit()) return false; int count = 0; for(Map.Entry<String, Relation> entry : f.getRelations().entrySet()) { - if(entry.getValue().getType().getId().equalsIgnoreCase(r.getId())) count++; + if(entry.getValue().getType() != null && r.getId().equalsIgnoreCase(entry.getValue().getType().getId())) count++; } return count >= r.getLimit(); } @@ -192,7 +193,7 @@ public static boolean atLimit(Faction f, RelationType r) { public static int getRelationCount(Faction f, RelationType r) { int count = 0; for(Map.Entry<String, Relation> entry : f.getRelations().entrySet()) { - if(entry.getValue().getType().getId().equalsIgnoreCase(r.getId())) count++; + if(entry.getValue().getType() != null && r.getId().equalsIgnoreCase(entry.getValue().getType().getId())) count++; } return count; } @@ -215,6 +216,10 @@ public static boolean setRelation( if (r == null || target == null || origin == null) { return false; } + if (origin.getId().equalsIgnoreCase(target.getId())) { + if (p != null) p.sendMessage("§cYou cannot set a relation with your own faction"); + return false; + } Relation relation = new Relation(origin.getRelation(target.getId())); Relation reverse = new Relation(target.getRelation(origin.getId())); boolean reverseChange = reverseChange(target, origin, r); @@ -233,10 +238,6 @@ public static boolean setRelation( if(p != null) p.sendMessage("§cThis faction is your top overlord"); return false; } - if(isOnOverlordPath(origin, target)){ - if(p != null) p.sendMessage("§cThis relation would cause a loop"); - return false; - } } if(atLimit(origin, r)) { if(p != null) p.sendMessage("§cYou have reached the limit for this relation type"); @@ -267,7 +268,7 @@ public static boolean setRelation( sendRequest(p, target, r); return false; } - if(r.shouldUpdateMap() || relation.getType().shouldUpdateMap()) { + if(r.shouldUpdateMap() || (relation.getType() != null && relation.getType().shouldUpdateMap())) { if (FactionManager.getMap() != null) { FactionManager.getMap().enqueue("nation", origin.getRGB()); FactionManager.getMap().enqueue("nation", target.getRGB()); @@ -320,6 +321,10 @@ public static String wartimeBlock(RelationType r, Faction target, Faction origin } public static void setTradeRelation(Player p, RelationType r, Faction target, Faction origin, boolean check) { + applyTradeRelation(p, r, target, origin, check); + } + + private static boolean applyTradeRelation(Player p, RelationType r, Faction target, Faction origin, boolean check) { if(r.hasThreshold()) { Threshold h = r.getThreshold(); int opinion = origin.getRelation(target.getId()).getOpinion(); @@ -338,21 +343,26 @@ public static void setTradeRelation(Player p, RelationType r, Faction target, Fa } } if(!fulfilled) { - return; + return false; } } if(r.isMutual() && check) { sendTradeRequest(p, target, r); - return; + return false; } origin.getDiplomacyHandler().setTradeRelation(target, r); if(r.isMutual()) { target.getDiplomacyHandler().setTradeRelation(origin, r.getLink()); } if(p != null) p.sendMessage(StringFormatter.formatHex("#a89977Set trade to "+r.getName())); + return true; } public static void setTreatyRelation(Player p, RelationType r, Faction target, Faction origin, boolean check) { + applyTreatyRelation(p, r, target, origin, check); + } + + private static boolean applyTreatyRelation(Player p, RelationType r, Faction target, Faction origin, boolean check) { if(r.hasThreshold()) { Threshold h = r.getThreshold(); int opinion = origin.getRelation(target.getId()).getOpinion(); @@ -371,24 +381,25 @@ public static void setTreatyRelation(Player p, RelationType r, Faction target, F } } if(!fulfilled) { - return; + return false; } } if(r.isClearTreaty()) { origin.getDiplomacyHandler().removeTreatyRelation(target.getId()); target.getDiplomacyHandler().removeTreatyRelation(origin.getId()); if(p != null) p.sendMessage(StringFormatter.formatHex("#a89977Cleared treaty")); - return; + return true; } if(r.isMutual() && check) { sendTreatyRequest(p, target, r); - return; + return false; } origin.getDiplomacyHandler().setTreatyRelation(target, r); if(r.isMutual()) { target.getDiplomacyHandler().setTreatyRelation(origin, r.getLink()); } if(p != null) p.sendMessage(StringFormatter.formatHex("#a89977Set treaty to "+r.getName())); + return true; } public static void setTreatyRelationForced(RelationType r, Faction target, Faction origin) { @@ -420,12 +431,13 @@ public static boolean reverseChange(Faction target, Faction origin, RelationType RelationType linked = t.getLink() != null ? t.getLink() : RelationLoader.getDefaultType(); RelationType outgoing = origin.getRelation(target.getId()).getType(); RelationType incoming = target.getRelation(origin.getId()).getType(); - return (outgoing.willReset() || incoming.willReset()) && !incoming.getId().equalsIgnoreCase(linked.getId()); + return ((outgoing != null && outgoing.willReset()) || (incoming != null && incoming.willReset())) + && linked != null && (incoming == null || !linked.getId().equalsIgnoreCase(incoming.getId())); } public static String getOverlord(Faction f) { for(Map.Entry<String, Relation> entry : f.getRelations().entrySet()) { - if(entry.getValue().getType().isOverlord()) return entry.getKey(); + if(entry.getValue().getType() != null && entry.getValue().getType().isOverlord()) return entry.getKey(); } return null; } @@ -454,9 +466,6 @@ public static boolean hasNonAggressionPact(Faction a, Faction b) { } private static boolean treatyBlocksWar(Faction from, Faction to) { - if (from.getDiplomacyHandler() == null) { - return false; - } RelationType treaty = from.getDiplomacyHandler().getTreatyRelation(to.getId()); return treaty != null && treaty.blocksWar(); } @@ -536,6 +545,10 @@ public static void transferSubject(Faction subject, Faction reciever) { return; } RelationType type = relation.getType(); + // Validate every forced-transfer constraint before ending the existing relationship. + if (reciever.getId().equalsIgnoreCase(o.getId()) + || reciever.getId().equalsIgnoreCase(subject.getId()) + || atLimit(reciever, type) || isOnOverlordPath(reciever, subject)) return; endVassalage(o, subject, false); setRelationForced(type, subject, reciever); } @@ -611,6 +624,10 @@ public static void acceptRequest(Player p) { return; } Faction sender = req.getFaction(); + if (sender == null || FactionManager.getByString(sender.getId()) != sender) { + p.sendMessage("§cThis diplomacy request is no longer valid. Ask for a new request."); + return; + } Player sp = Bukkit.getPlayerExact(sender.getLeader()); // The war may have started after the request was sent String blocked = wartimeBlock(req.getType(), reciever, sender); @@ -619,8 +636,9 @@ public static void acceptRequest(Player p) { if(sp != null && sp.isOnline()) sp.sendMessage(blocked); return; } - if(sp != null && sp.isOnline()) sp.sendMessage(reciever.getName()+" §aaccepted your request and became your "+req.getType().getName()); - setRelation(p, req.getType(), reciever, sender, false); + if (setRelation(p, req.getType(), reciever, sender, false, false) && sp != null && sp.isOnline()) { + sp.sendMessage(reciever.getName()+" §aaccepted your request and became your "+req.getType().getName()); + } } private static void sendTradeRequest(Player sender, Faction f, RelationType type) { @@ -644,9 +662,14 @@ public static void acceptTradeRequest(Player p) { return; } Faction sender = req.getFaction(); + if (sender == null || FactionManager.getByString(sender.getId()) != sender) { + p.sendMessage("§cThis diplomacy request is no longer valid. Ask for a new request."); + return; + } Player sp = Bukkit.getPlayerExact(sender.getLeader()); - if(sp != null && sp.isOnline()) sp.sendMessage(reciever.getName()+" §aaccepted your request and set trade to "+req.getType().getName()); - setTradeRelation(p, req.getType(), reciever, sender, false); + if (applyTradeRelation(p, req.getType(), reciever, sender, false) && sp != null && sp.isOnline()) { + sp.sendMessage(reciever.getName()+" §aaccepted your request and set trade to "+req.getType().getName()); + } } private static void sendTreatyRequest(Player sender, Faction f, RelationType type) { @@ -670,8 +693,13 @@ public static void acceptTreatyRequest(Player p) { return; } Faction sender = req.getFaction(); + if (sender == null || FactionManager.getByString(sender.getId()) != sender) { + p.sendMessage("§cThis diplomacy request is no longer valid. Ask for a new request."); + return; + } Player sp = Bukkit.getPlayerExact(sender.getLeader()); - if(sp != null && sp.isOnline()) sp.sendMessage(reciever.getName()+" §aaccepted your request and set treaty to "+req.getType().getName()); - setTreatyRelation(p, req.getType(), reciever, sender, false); + if (applyTreatyRelation(p, req.getType(), reciever, sender, false) && sp != null && sp.isOnline()) { + sp.sendMessage(reciever.getName()+" §aaccepted your request and set treaty to "+req.getType().getName()); + } } } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/RelocationPrompt.java b/src/main/java/net/tfminecraft/simplefactions/managers/RelocationPrompt.java index 6df61dd9..ff07f77c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/RelocationPrompt.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/RelocationPrompt.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.managers; -import java.util.HashMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.Map; import org.bukkit.Bukkit; @@ -20,7 +21,7 @@ import net.tfminecraft.simplefactions.utils.DisplayNameGate.NameOperation; public class RelocationPrompt implements Listener { - private static final Map<Player, RelocationPending> pending = new HashMap<>(); + private static final Map<Player, RelocationPending> pending = new ConcurrentHashMap<>(); public static class RelocationPending { public final Guild guild; @@ -28,6 +29,9 @@ public static class RelocationPending { public final int province; public final boolean crossFaction; public final double cost; + private final Faction origin; + private final int fromCapital; + private final AtomicBoolean submitted = new AtomicBoolean(); public RelocationPending( Guild guild, @@ -40,6 +44,8 @@ public RelocationPending( this.province = province; this.crossFaction = crossFaction; this.cost = cost; + this.origin = guild.getFaction(); + this.fromCapital = guild.getCapital(); } } @@ -56,12 +62,13 @@ public static boolean begin( if (!target.getSettlementHandler().requiresFoundingName(province)) { return false; } - pending.put(player, new RelocationPending(guild, target, province, crossFaction, cost)); + RelocationPending state = new RelocationPending(guild, target, province, crossFaction, cost); + pending.put(player, state); player.sendMessage("§eEnter a name for the new city in chat:"); new BukkitRunnable() { @Override public void run() { - if (pending.remove(player) != null && player.isOnline()) { + if (pending.remove(player, state) && player.isOnline()) { player.sendMessage("§cRelocation timed out"); } } @@ -76,12 +83,27 @@ public static boolean completeIntraFactionRelocate( int province, String settlementName, double cost) { + if (!guild.isLeader(player) || guild.getFaction() != target) { + player.sendMessage("§cYour guild changed while you were deciding. Start relocation again."); + return false; + } + if (guild.getBank() == null || !Double.isFinite(cost) || cost < 0 || guild.getBank().getWealth() < cost) { + player.sendMessage("§cCannot afford to relocate"); + return false; + } + int old = guild.getCapital(); if (!target.hasProvince(province)) { - int old = guild.getCapital(); + CapitalResult validation = target.getSettlementHandler() + .validateRelocationCapital(player, province, settlementName); + if (!validation.isSuccess()) { + player.sendMessage(validation.getMessage()); + player.playSound(player, Sound.ENTITY_VILLAGER_NO, 1f, 1f); + return false; + } guild.setCapital(-1, false); FactionManager.getMap().claim(player, target, province, true); + guild.setCapital(old, false); if (!target.hasProvince(province)) { - guild.setCapital(old); player.sendMessage("§cRelocation failed, cannot claim province!"); player.playSound(player, Sound.ENTITY_VILLAGER_NO, 1f, 1f); return false; @@ -90,6 +112,7 @@ public static boolean completeIntraFactionRelocate( CapitalResult result = guild.relocateWithinFaction(player, province, settlementName); if (!result.isSuccess()) { + if (guild.getCapital() != old) guild.setCapital(old, false); player.playSound(player, Sound.ENTITY_VILLAGER_NO, 1f, 1f); return false; } @@ -105,26 +128,37 @@ public static boolean completeIntraFactionRelocate( public void onPlayerChat(AsyncPlayerChatEvent event) { Player player = event.getPlayer(); RelocationPending state = pending.get(player); - if (state == null) { + if (state == null || !state.submitted.compareAndSet(false, true)) { return; } event.setCancelled(true); String name = event.getMessage().trim(); - if (name.isBlank()) { - player.sendMessage("§cA city name is required to relocate here"); - return; - } - if (DisplayNameGate.check(player, NameOperation.SETTLEMENT_FOUND, name, true) - == DisplayNameGate.Result.NEEDS_CONFIRM) { - return; - } - pending.remove(player); - new BukkitRunnable() { @Override public void run() { + if (pending.get(player) != state) return; if (!player.isOnline()) { + pending.remove(player, state); + return; + } + if (name.isBlank()) { + player.sendMessage("§cA city name is required to relocate here"); + state.submitted.set(false); + return; + } + if (DisplayNameGate.check(player, NameOperation.SETTLEMENT_FOUND, name, true) + == DisplayNameGate.Result.NEEDS_CONFIRM) { + state.submitted.set(false); + return; + } + pending.remove(player, state); + if (!state.guild.isLeader(player) + || state.guild.getFaction() != state.origin + || state.guild.getCapital() != state.fromCapital + || FactionManager.getGuildByString(state.guild.getId()) != state.guild + || FactionManager.getByString(state.target.getId()) != state.target) { + player.sendMessage("§cYour guild changed while you were deciding. Start relocation again."); return; } if (state.crossFaction) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/RequestManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/RequestManager.java index cbf8427d..2fa1838f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/RequestManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/RequestManager.java @@ -27,7 +27,8 @@ import net.tfminecraft.simplefactions.war.campaign.runtime.BattleAutoresolveService; import net.tfminecraft.simplefactions.mercenary.company.MercenaryInvites; -public class RequestManager { +public final class RequestManager { + private RequestManager() {} private static HashMap<Player, Request> requests = new HashMap<>(); public static void start() { @@ -126,6 +127,10 @@ public static void decline(Player p) { public static void accept(Player p) { if(!hasRequest(p)) return; Request req = requests.get(p); + if (req.timedOut()) { + expireTimedOutRequests(); + return; + } if(req instanceof RelationRequest rreq) { if(rreq.isTrade()) { RelationManager.acceptTradeRequest(p); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/SessionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/SessionManager.java index d141c1d7..7723a06f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/SessionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/SessionManager.java @@ -2,6 +2,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.ArrayList; import org.bukkit.Bukkit; import org.bukkit.Material; @@ -33,7 +34,7 @@ public void start() { } public void end() { - for(Session session : sessions.values()) { + for(Session session : new ArrayList<>(sessions.values())) { session.kill(); } } @@ -42,7 +43,7 @@ public void tickCycle() { new BukkitRunnable() { @Override public void run() { - for(Session session : sessions.values()) { + for(Session session : new ArrayList<>(sessions.values())) { session.tick(); } } @@ -50,7 +51,10 @@ public void run() { } public void newSession(Player player, Faction f) { - sessions.put(f.getGovernment().getCouncil(), new Session(player, f)); + Council council = f.getGovernment().getCouncil(); + Session previous = sessions.get(council); + if (previous != null) previous.kill(); + sessions.put(council, new Session(player, f)); } public void endSession(Council council) { @@ -125,6 +129,17 @@ public void onPlayerChat(AsyncPlayerChatEvent event) { if (vote == null) return; // Not a vote command Player player = event.getPlayer(); + // Chat may be asynchronous; faction state, world access and holograms belong on the server thread. + new BukkitRunnable() { + @Override + public void run() { + recordChatVote(player, vote); + } + }.runTask(SimpleFactions.getInstance()); + } + + private void recordChatVote(Player player, Vote vote) { + if (!player.isOnline()) return; String playerName = player.getName(); Faction faction = FactionManager.getByMember(playerName); @@ -135,36 +150,25 @@ public void onPlayerChat(AsyncPlayerChatEvent event) { if (session != null && session.isStarted()) { // Check if player is within 10 blocks of the lantern - if (session.getLantern() != null && player.getLocation().distance(session.getLantern().getLocation()) > 10) { - player.sendMessage("§cYou must be within 10 blocks of the lantern to vote!"); - return; + if (session.getLantern() != null) { + org.bukkit.Location playerLocation = player.getLocation(); + org.bukkit.Location lanternLocation = session.getLantern().getLocation(); + if (!java.util.Objects.equals(playerLocation.getWorld(), lanternLocation.getWorld()) + || playerLocation.distanceSquared(lanternLocation) > 100) { + player.sendMessage("§cYou must be within 10 blocks of the lantern to vote!"); + return; + } } if (session.recordVote(playerName, vote)) { - //event.setCancelled(true); cooler if you actually say it in chat player.sendMessage("§aYour vote (" + vote.getDisplay() + ") has been recorded!"); - Sound sound; - switch (vote) { - case YAY: - sound = Sound.BLOCK_NOTE_BLOCK_CHIME; - break; - case NAY: - sound = Sound.BLOCK_NOTE_BLOCK_BASS; - break; - case ABSTAIN: - sound = Sound.ITEM_BOOK_PAGE_TURN; - break; - default: - sound = Sound.ITEM_BOOK_PAGE_TURN; - break; - } - new BukkitRunnable() { - @Override - public void run() { - player.getWorld().playSound(player.getLocation(), sound, 1f, 1f); - player.swingMainHand(); - } - }.runTask(SimpleFactions.getInstance()); + Sound sound = switch (vote) { + case YAY -> Sound.BLOCK_NOTE_BLOCK_CHIME; + case NAY -> Sound.BLOCK_NOTE_BLOCK_BASS; + case ABSTAIN -> Sound.ITEM_BOOK_PAGE_TURN; + }; + player.getWorld().playSound(player.getLocation(), sound, 1f, 1f); + player.swingMainHand(); } } } @@ -182,4 +186,4 @@ public void onPlayerQuit(PlayerQuitEvent event) { } } } -} \ No newline at end of file +} diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/TitleManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/TitleManager.java index 0a2e5849..3cfb094b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/TitleManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/TitleManager.java @@ -1,7 +1,8 @@ package net.tfminecraft.simplefactions.managers; import java.util.ArrayList; -import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.List; import org.apache.commons.lang.WordUtils; @@ -9,6 +10,8 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.bukkit.scheduler.BukkitRunnable; +import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.Cache; import net.tfminecraft.simplefactions.loaders.TierLoader; @@ -21,7 +24,7 @@ public class TitleManager implements Listener{ Formatter format = new Formatter(); - public static HashMap<Player, Tier> isFormingTitle = new HashMap<>(); + public static Map<Player, Tier> isFormingTitle = new ConcurrentHashMap<>(); public static boolean overProvinceCap(Faction f) { return f.getPrestige() < Math.max(0, f.getProvinces().size()-1)*Cache.provinceCost; @@ -32,17 +35,40 @@ public static boolean overProvinceCap(Faction f) { @EventHandler public void formTitle(AsyncPlayerChatEvent e) { Player p = e.getPlayer(); - if(!isFormingTitle.containsKey(p)) return; + Tier pending = isFormingTitle.get(p); + if (pending == null) return; e.setCancelled(true); + String message = e.getMessage().replace(" ", "_"); + new BukkitRunnable() { + @Override + public void run() { + if (isFormingTitle.remove(p, pending)) completeTitle(p, pending, message); + } + }.runTask(SimpleFactions.plugin); + } + + private void completeTitle(Player p, Tier pending, String message) { + if (!p.isOnline()) return; Faction f = FactionManager.getByLeader(p.getName()); - if(f == null) { - isFormingTitle.remove(p); - return; + Tier tier = TierLoader.getByString(pending.getId()); + if (f == null || tier != pending || !tier.canForm() + || !f.getGovernment().stateReport().status.canFormTitles()) return; + Tier required = TierLoader.getByLevel(tier.getTier() - 1); + int available; + if (tier.getId().equalsIgnoreCase("county")) { + if (f.getTitles(tier).size() >= f.getMembers().size()) return; + available = f.getUntitledProvinces().size(); + } else { + if (required == null) return; + available = f.getFreeTitles(required).size(); } - - String message = e.getMessage().replace(" ", "_"); - Tier tier = isFormingTitle.get(p); - + if (available < tier.getFormCost()) return; + String overlord = RelationManager.getOverlord(f); + if (overlord != null) { + Faction liege = FactionManager.getByString(overlord); + if (liege == null || liege.getTier().getTier() < tier.getTier()) return; + } + List<String> titleStringList = new ArrayList<>(); Tier lower = TierLoader.getByLevel(tier.getTier()-1); if(lower != null) { @@ -51,21 +77,22 @@ public void formTitle(AsyncPlayerChatEvent e) { } } String id = format.formatId(message); + if (id.isBlank()) { + p.sendMessage("§cA title name must contain letters or numbers."); + return; + } if(TitleLoader.getById(id) != null) { p.sendMessage("§cA title with that ID already exists!"); - isFormingTitle.remove(p); return; } String name = WordUtils.capitalize(format.formatId(message).replace("_", " ")); Title newTitle = TitleLoader.createNewTitle(tier, id, name, RandomRGB.similarButDistinct(f.getRGB()), f.getUntitledProvinces(), titleStringList, false); if(newTitle == null) { - isFormingTitle.remove(p); return; } p.sendMessage("§aFormed the "+tier.getName()+" §7"+name); f.addTitle(newTitle); FactionManager.getMap().enqueue(tier.getId(), newTitle.getRgb()); - isFormingTitle.remove(p); } public static double getClaimCost(Faction f) { @@ -111,11 +138,6 @@ public static List<Integer> getAllUntitledProvinces(Faction f){ for(int p : getProvinces(f)) { if(TitleLoader.getByProvince(p) == null) provinces.add(p); } - for(Faction subject : RelationManager.getSubjects(f)) { - for(int p : subject.getProvinces()) { - if(TitleLoader.getByProvince(p) == null) provinces.add(p); - } - } return provinces; } @@ -148,7 +170,7 @@ public static List<Title> getTitles(Faction f) { public static Faction getOwner(Title title){ for(Faction f : FactionManager.factions) { - if(getTitles(f).contains(title)) return f; + if(f.getTitles().contains(title)) return f; } return null; } @@ -189,7 +211,7 @@ public static boolean titleIsInRealm(Faction f, String title){ public static Title getParent(Title t) { for(Title c : TitleLoader.getTitles()) { for(String s : c.getTitles()) { - if(TitleLoader.getById(s).equals(t)) return c; + if(t.equals(TitleLoader.getById(s))) return c; } } return null; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ContractView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ContractView.java index 6d86c69a..9085b86b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ContractView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ContractView.java @@ -143,7 +143,9 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { private void clickList(InventoryClickEvent e, Player p, Guild guild, MercenaryCompany company) { if (e.getSlot() == DRAFT_BUTTON) { if (!company.isLeader(p.getName())) return; - p.getInventory().addItem(ContractBook.draftBook(company)); + for (ItemStack leftover : p.getInventory().addItem(ContractBook.draftBook(company)).values()) { + p.getWorld().dropItemNaturally(p.getLocation(), leftover); + } report(p, MercenaryResult.ok("Draft written. Fill in the terms and sign.")); return; } @@ -162,7 +164,7 @@ private void clickDetail( if (!canSign(p, contract)) return; switch (e.getSlot()) { case ACCEPT_BUTTON -> report(p, company.getContractHandler() - .accept(contractId, contract.getHirer(), p.getName())); + .acceptAtHall(contractId, contract.getHirer(), p)); case DECLINE_BUTTON -> report(p, company.getContractHandler().decline(contractId)); default -> { return; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DeclareWarCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DeclareWarCreator.java index 446857be..63972329 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DeclareWarCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DeclareWarCreator.java @@ -281,7 +281,7 @@ public ItemStack createConfirmSummaryItem(WarDeclareRequest request) { addResolvedLawLore(lore, request.getDefender(), request.getLeadershipLawId(), "Leadership"); } if (request.getGoal() == WarGoalType.PILLAGE) { - Settlement settlement = PillageEligibility.findSettlement(request.getTargetSettlementId()); + Settlement settlement = PillageEligibility.findSettlement(request.getTargetSettlementId(), request.getDefender()); if (settlement != null && settlement.getName() != null) { lore.add(StringFormatter.formatHex("#a89977Settlement: "+settlement.getName())); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DefaultCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DefaultCreator.java index 9989e86c..26ca7ad8 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DefaultCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DefaultCreator.java @@ -12,6 +12,8 @@ import net.tfminecraft.tlibs.TLibs; public class DefaultCreator { + private DefaultCreator() {} + // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public static ItemStack createNextPageButton() { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionView.java index 63f6d6a9..078bdad7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ElectionView.java @@ -24,6 +24,9 @@ import net.tfminecraft.simplefactions.keys.Keys; public class ElectionView { + private static final int PAGE_SIZE = 45; + private static final int PREVIOUS_SLOT = 45; + private static final int NEXT_SLOT = 53; public InventoryManager inv; ElectionCreator creator = new ElectionCreator(); @@ -65,12 +68,16 @@ public void votingView(Player p, Faction f, Candidate candidateType, Inventory i i.clear(); Election election = f.getGovernment().getElection(); List<String> candidates = election.getCandidates(candidateType); - int x = 0; - for(String candidateName : candidates) { - if(x >= 54) break; - i.setItem(x, creator.createCandidateItem(f, candidateType, candidateName)); - x++; + SFInventoryHolder holder = (SFInventoryHolder) i.getHolder(); + int lastPage = Math.max(0, (candidates.size() - 1) / PAGE_SIZE); + holder.setPage(Math.min(holder.getPage(), lastPage)); + int start = holder.getPage() * PAGE_SIZE; + int end = Math.min(start + PAGE_SIZE, candidates.size()); + for(int index = start; index < end; index++) { + i.setItem(index - start, creator.createCandidateItem(f, candidateType, candidates.get(index))); } + if(holder.getPage() > 0) i.setItem(PREVIOUS_SLOT, DefaultCreator.createPreviousPageButton()); + if(holder.getPage() < lastPage) i.setItem(NEXT_SLOT, DefaultCreator.createNextPageButton()); if(open) p.openInventory(i); } @@ -79,6 +86,12 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { SFInventoryHolder holder = (SFInventoryHolder) inventory.getHolder(); Faction f = FactionManager.getByString(holder.getId()); if(f == null) return; + if(holder.getType() == SFGUI.ELECTION_VOTING_VIEW + && (e.getSlot() == PREVIOUS_SLOT || e.getSlot() == NEXT_SLOT)) { + holder.setPage(holder.getPage() + (e.getSlot() == NEXT_SLOT ? 1 : -1)); + votingView(p, f, Candidate.valueOf(holder.getSecondaryId()), inventory); + return; + } ItemStack item = e.getCurrentItem(); if(item == null || item.getItemMeta() == null) return; ItemMeta meta = item.getItemMeta(); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java index 4f7b5863..8d4fc2b8 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/EspionageView.java @@ -214,8 +214,9 @@ public static ItemStack foreignPositionsItem(Player viewer, Faction faction) { ? holder == null ? "Vacant" : CharacterNames.display(viewer, holder.playerName) : report == null ? "Unknown" : report.officeHolder(office); String aptitude = EspionageService.canViewExact(viewer, faction) - ? Integer.toString(office == SpecialPosition.SPYMASTER ? EspionageService.effectiveAptitude(faction, holder) - : holder == null ? 0 : holder.aptitude) + ? Integer.toString(switch (office) { + case SPYMASTER -> EspionageService.effectiveAptitude(faction, holder); + }) : report == null ? "Unknown" : report.display(IntelligenceReport.officeAptitudeKey(office)); lore.add("\u00a77" + office.label() + ": " + name); lore.add("\u00a77Aptitude: " + aptitude + (aptitude.equals("Unknown") ? "" : "/100")); @@ -260,13 +261,10 @@ public static void spymasterOffice(Player viewer, Faction faction, InventoryMana 27, "\u00a77Spymaster's Office"); SpecialPositionAssignment holder = EspionageService.spymaster(faction); long now = System.currentTimeMillis(); - int held = EspionageService.positionsHeld(faction, holder); ItemStack head = item(Material.PLAYER_HEAD, "Spymaster: " + (holder == null ? "Vacant" : CharacterNames.display(viewer, holder.playerName)), "§7Aptitude: §e" + EspionageService.effectiveAptitude(faction, holder) + "/100", buildUpLine(holder, now), holder == null ? "§7Falls to the faction leader once they have an active character." - : held > 1 ? "§7Holds " + held + " offices: " + Math.round(EspionageService.positionMultiplier(held) * 100) - + "% aptitude in each (base " + holder.aptitude + ")." : holder.automatic ? "§7Held by the faction leader until a member is appointed." : "§7Appointed by the faction leader.", "§7Gathers foreign intelligence and guards your secrets.", diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java index 99286ac8..494b9b08 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionView.java @@ -387,6 +387,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { Faction f = FactionManager.getByString(h.getId()); if(!f.isLeader(p.getName())) return; BannerFetcher.fetch("faction:" + f.getId(), patterns -> { + if (FactionManager.getByString(f.getId()) != f || !f.isLeader(p.getName())) return; if (patterns == null) { if (p.isOnline()) p.sendMessage("§cCould not generate a banner right now. Try again later."); return; @@ -400,6 +401,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if(!(inventory.getHolder() instanceof SFInventoryHolder)) return; SFInventoryHolder h = (SFInventoryHolder) inventory.getHolder(); Faction f = FactionManager.getByString(h.getId()); + if (f == null || !f.isMemberIgnoreCase(p.getName())) return; ItemStack i = new ItemStack(f.getBanner()); p.getInventory().addItem(i); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java index 1925ef25..677ca7e6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java @@ -293,9 +293,6 @@ private static List<String> stanceLines(StabilityFacts facts, StabilityTuning tu lines.add(stanceLine(body.name, body.stance, body.members, false, room, population, tuning)); } for (Body vassal : facts.vassals) { - if (vassal == null) { - continue; - } lines.add(stanceLine(vassal.name, vassal.stance, vassal.members, true, room, population, tuning)); } return lines; @@ -404,15 +401,20 @@ static String num(double value) { } // Keep the existing legacy text representation, formatting, and exact-string comparisons. - @SuppressWarnings("deprecation") public ItemStack createStanceItem(Faction f, Guild guild) { + return createStanceItem(f, guild, true); + } + + @SuppressWarnings("deprecation") + public ItemStack createStanceItem(Faction f, Guild guild, boolean canChange) { Stance stance = guild.getStance(f); ItemStack item = new ItemStack(Material.YELLOW_CONCRETE); if(stance == Stance.OPPOSE) item = new ItemStack(Material.RED_CONCRETE); else if(stance == Stance.SUPPORT) item = new ItemStack(Material.GREEN_CONCRETE); ItemMeta m = item.getItemMeta(); List<String> lore = new ArrayList<String>(); - lore.add("#28ed70Click to change"); + lore.add(canChange ? "#28ed70Click to change" + : "#c74d32Only the guild leader can change this stance."); paint(m, stance.getDisplay(), lore); m.getPersistentDataContainer().set(Keys.STRING_KEY, PersistentDataType.STRING, guild.getId()); item.setItemMeta(m); @@ -495,12 +497,19 @@ public ItemStack createSpecificTaxItem(Player p, Faction f, String id, TaxTarget List<String> lore = new ArrayList<String>(); TaxHandler taxHandler = f.getTaxHandler(); double taxRate = taxHandler.getTaxRate(target, id, false); - if(taxHandler.hasSpecificTax(target, null)) { + TaxTarget baseTarget = switch(target) { + case GUILD_ID -> TaxTarget.GUILDS; + case VASSAL_ID -> TaxTarget.VASSALS; + case TARIFF_ID -> TaxTarget.TARIFFS; + default -> target; + }; + double baseRate = taxHandler.getTaxRate(baseTarget, null, false); + if(taxHandler.hasSpecificTax(baseTarget, id)) { lore.add(StringFormatter.formatHex("#525d5dCurrent Rate: #e3d5a1"+taxRate+"%"+ " §8(§7"+f.getTaxRate(target, id, true)+"% effective§8)")); - lore.add(StringFormatter.formatHex("#3f4040(#767a77Base Rate: #928d7a"+taxRate+"%#3f4040)")); + lore.add(StringFormatter.formatHex("#3f4040(#767a77Base Rate: #928d7a"+baseRate+"%#3f4040)")); } else { lore.add(StringFormatter.formatHex("#812222No specific "+(target == TaxTarget.TARIFF_ID ? "tariff" : "tax")+" set.")); - lore.add(StringFormatter.formatHex("#3f4040(#767a77Base Rate: #928d7a"+taxRate+"%#3f4040)")); + lore.add(StringFormatter.formatHex("#3f4040(#767a77Base Rate: #928d7a"+baseRate+"%#3f4040)")); } Government gov = f.getGovernment(); Proposal proposal = new Proposal(p.getName(), gov); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java index 004e27f1..98e1edaa 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java @@ -30,6 +30,7 @@ import net.tfminecraft.simplefactions.government.Government; import net.tfminecraft.simplefactions.government.movement.Action; import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.government.movement.cause.Cause; import net.tfminecraft.simplefactions.government.movement.PoliticalAction; import net.tfminecraft.simplefactions.government.proposal.Proposal; import net.tfminecraft.simplefactions.government.proposal.TaxTarget; @@ -81,7 +82,7 @@ public void governmentView(Player player, Faction f, Inventory i) { } Guild viewer = FactionManager.getGuildByMember(player.getName()); if(viewer != null && gov.canAffectStability(viewer)) { - i.setItem(28, creator.createStanceItem(f, viewer)); + i.setItem(28, creator.createStanceItem(f, viewer, viewer.isLeader(player))); } i.setItem(53, inv.createBackButton(SFGUI.GOVERNMENT_VIEW)); @@ -106,19 +107,28 @@ public void councilView(Player player, Faction f, Inventory i) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void councilSelect(Player player, Faction f, Inventory i, int slot) { - boolean open = i == null; - if(i == null) i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.COUNCIL_SELECT), 54, "§7Select Member"); - i.clear(); - int x = 0; - List<String> members = f.getMembers(); + int page = i != null && i.getHolder() instanceof SFInventoryHolder holder ? holder.getPage() : 0; + councilSelect(player, f, i, slot, page); + } + + private void councilSelect(Player player, Faction f, Inventory i, int slot, int page) { + List<String> members = new java.util.ArrayList<>(f.getMembers()); Council council = f.getGovernment().getCouncil(); - members.removeAll(f.getGovernment().getCouncil().getMembers()); + members.removeAll(council.getMembers()); members.addAll(f.getVassalMembers()); - for(String member : members) { - if(!council.canBeMember(member, true, false)) continue; - i.setItem(x, creator.createPotentialMemberItem(player, f, member, slot)); - x++; + members.removeIf(member -> !council.canBeMember(member, true, false)); + page = Math.max(0, Math.min(page, Math.max(0, (members.size() - 1) / 45))); + boolean open = i == null; + if(open) i = SimpleFactions.plugin.getServer().createInventory( + new SFInventoryHolder(f.getId(), SFGUI.COUNCIL_SELECT, page, false, Integer.toString(slot)), 54, "§7Select Member"); + ((SFInventoryHolder)i.getHolder()).setPage(page); + i.clear(); + int start = page * 45; + for(int x = start; x < Math.min(start + 45, members.size()); x++) { + i.setItem(x - start, creator.createPotentialMemberItem(player, f, members.get(x), slot)); } + if(page > 0) i.setItem(45, DefaultCreator.createPreviousPageButton()); + if(start + 45 < members.size()) i.setItem(52, DefaultCreator.createNextPageButton()); i.setItem(53, inv.createBackButton(SFGUI.COUNCIL_SELECT)); if(open) player.openInventory(i); } @@ -173,10 +183,10 @@ public void politicalProposalView(Player player, Faction f, Inventory i) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void warPeaceSelectView(Player player, Faction f, Action action, boolean fromCause, int causeIndex, Inventory i) { - boolean open = i == null; - if (i == null) { + boolean open = i == null || !(i.getHolder() instanceof WarPeaceHolder); + if (open) { i = SimpleFactions.plugin.getServer().createInventory( - new SFInventoryHolder(f.getId(), SFGUI.WAR_PEACE_SELECT, causeIndex, fromCause, action.name()), + new WarPeaceHolder(player, f, action, fromCause, causeIndex), 54, "§7Select War"); } @@ -336,11 +346,12 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { int slot = e.getSlot(); ItemStack item = e.getCurrentItem(); Faction f = FactionManager.getByString(((SFInventoryHolder)e.getInventory().getHolder()).getId()); + if (f == null) return; if(slot == 28 && item != null && item.getItemMeta() != null) { String id = item.getItemMeta().getPersistentDataContainer().get(Keys.STRING_KEY, PersistentDataType.STRING); if(id != null) { Guild guild = FactionManager.getGuildByString(id); - if(guild != null) { + if(guild != null && guild.isLeader(p) && f.getGovernment().canAffectStability(guild)) { guild.switchStance(); governmentView(p, f, inventory); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); @@ -367,6 +378,10 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); } else if(slot == 23) { if(!f.getGovernment().hasCouncil()) return; + if(!f.isLeader(p.getName()) || !f.getGovernment().getCouncil().canHostSession()) { + p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); + return; + } if(!f.getGovernment().getCouncil().hasEnoughValidVoters()) { p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); p.sendMessage("§cNot enough council members online! At least 75% must be present."); @@ -388,6 +403,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { e.setCancelled(true); int slot = e.getSlot(); Faction f = FactionManager.getByString(((SFInventoryHolder)e.getInventory().getHolder()).getId()); + if (f == null) return; if(slot == 0) { lawProposalView(p, f, null); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); @@ -447,7 +463,16 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { String t = meta.getPersistentDataContainer().get(Keys.SECONDARY_STRING_KEY, PersistentDataType.STRING); if(t == null) return; TaxTarget target = TaxTarget.valueOf(t); - String name = target == TaxTarget.GUILD_ID ? FactionManager.getGuildByString(id).getName() : FactionManager.getByString(id).getName(); + String name; + if(target == TaxTarget.GUILD_ID) { + Guild guild = FactionManager.getGuildByString(id); + if(guild == null) return; + name = guild.getName(); + } else { + Faction targetFaction = FactionManager.getByString(id); + if(targetFaction == null) return; + name = targetFaction.getName(); + } inv.setChanging(f, p, target, id); p.sendTitle("§aTax Change", "§eType a new tax for "+name+" §ein chat.", 20, 40, 20); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); @@ -623,7 +648,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { } catch (RuntimeException ex) { return; } - submitWarEnd(p, faction, action, warId, h.getFlag(), h.getPage()); + submitWarEnd(p, faction, action, warId, h.getFlag(), inventory); } else if (h.getType() == SFGUI.PROPOSALS) { e.setCancelled(true); } else if (h.getType() == SFGUI.COUNCIL_VIEW) { @@ -662,6 +687,11 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if(f == null) return; boolean isLeader = p.getName().equalsIgnoreCase(f.getLeader()); if(!isLeader) return; + if(e.getSlot() == 45 || e.getSlot() == 52) { + int seat = Integer.parseInt(h.getSecondaryId()); + councilSelect(p, f, null, seat, h.getPage() + (e.getSlot() == 45 ? -1 : 1)); + return; + } // Handle council member click for replace/appoint ItemMeta meta = item.getItemMeta(); if(meta == null) return; @@ -672,6 +702,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { Council council = f.getGovernment().getCouncil(); // Verify slot can be appointed/modified + if(!council.canBeMember(member, true, false)) return; List<String> members = council.getMembers(); boolean isOccupied = s < members.size() && members.get(s) != null && !members.get(s).isEmpty(); boolean isNextEmpty = s == members.size(); @@ -737,7 +768,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if(isGuilds) { Guild guild = FactionManager.getGuildByString(id); - if(guild == null) return; + if(guild == null || guild.getFaction() != f || guild.isBase()) return; if(isFavourMode) { if(gov.canFavour(guild)) { @@ -758,7 +789,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { } } else { Faction vassal = FactionManager.getByString(id); - if(vassal == null) return; + if(vassal == null || !f.getSubjects().contains(vassal)) return; Guild mainGuild = vassal.getOrCreateMainGuild(); if(isFavourMode) { @@ -782,7 +813,26 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { } } - private void submitWarEnd(Player p, Faction f, Action action, String warId, boolean fromCause, int causeIndex) { + private static final class WarPeaceHolder extends SFInventoryHolder { + private final Movement movement; + private final Cause cause; + + WarPeaceHolder(Player player, Faction faction, Action action, boolean fromCause, int index) { + super(faction.getId(), SFGUI.WAR_PEACE_SELECT, index, fromCause, action.name()); + movement = fromCause ? faction.getGovernment().getMovementByMember(player.getName()) : null; + cause = movement != null && index >= 0 && index < movement.getCauses().size() + ? movement.getCauses().get(index) : null; + } + } + + public Cause displayedWarPeaceCause(Player player, Faction faction, Inventory inventory) { + if (!(inventory.getHolder() instanceof WarPeaceHolder holder) || holder.cause == null + || faction.getGovernment().getMovementByMember(player.getName()) != holder.movement + || !holder.movement.getCauses().contains(holder.cause)) return null; + return holder.cause; + } + + private void submitWarEnd(Player p, Faction f, Action action, String warId, boolean fromCause, Inventory inventory) { Government gov = f.getGovernment(); if (!gov.canProposePolitical(p, action) && !fromCause) { p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); @@ -794,12 +844,12 @@ private void submitWarEnd(Player p, Faction f, Action action, String warId, bool return; } if (fromCause) { - Movement movement = gov.getMovementByMember(p.getName()); - if (movement == null || causeIndex < 0 || causeIndex >= movement.getCauses().size()) { + Cause cause = displayedWarPeaceCause(p, f, inventory); + if (cause == null) { p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); return; } - net.tfminecraft.simplefactions.government.movement.cause.Cause cause = movement.getCauses().get(causeIndex); + Movement movement = cause.getMovement(); if (!cause.hasLeader() || !cause.getLeader().equals(p.getName())) { p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); return; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java index 190795d6..38535d89 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java @@ -205,9 +205,10 @@ public void guildView(Player player, Guild guild, Inventory i) { tradePreview = BranchIncomePreview.prepare(manager); } int group = 0; - while(group < 10 && guild.getBranch(group) != null) { + while(group < 10) { Branch b = guild.getBranch(group); group++; + if (b == null) continue; i.setItem(group+28, creator.createBranchItem(player, guild, b)); if(guild.isLeader(player)) { int upgradeSlot = group + 19; @@ -354,6 +355,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { return; } BannerFetcher.fetch("guild:" + guild.getId(), patterns -> { + if(FactionManager.getGuildByString(guild.getId()) != guild || !guild.isLeader(p) || guild.isBase()) return; if (patterns == null) { if (p.isOnline()) p.sendMessage("§cCould not generate a banner right now. Try again later."); return; @@ -364,7 +366,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if (p.isOnline()) p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); }); } else if(e.getSlot() == 1) { - if(!(inventory.getHolder() instanceof SFInventoryHolder)) return; + if(!guild.isMember(p)) return; ItemStack i = new ItemStack(guild.getBanner()); p.getInventory().addItem(i); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); @@ -385,7 +387,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { Province prov = SimpleFactions.getInstance().getProvinceManager().get(province); if(prov == null || !prov.isValid() || prov.isSea()) return; double cost = guild.getRelocationCost(province); - if(guild.getBank().getWealth() < cost) { + if(guild.getBank() == null || !Double.isFinite(cost) || cost < 0 || guild.getBank().getWealth() < cost) { p.sendMessage("§cCannot afford to relocate"); p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); return; @@ -411,16 +413,17 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 1f); return; } + Faction originalHost = guild.getFaction(); double cost = guild.getEvictionCost(); - if(guild.getFaction().getGovernment().getPower() < cost) { + if(originalHost.getGovernment().getPower() < cost) { p.sendMessage("§cCannot afford to evict"); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BASS, 1f, 1f); return; } - StabilityModifier modifier = new StabilityModifier("Evicted Guild", guild.getStabilityEffect(), 1); // stability effect lasts 6 hours - guild.getFaction().getGovernment().addStabilityModifier(modifier); + StabilityModifier modifier = new StabilityModifier("Evicted Guild", -guild.getStabilityEffect(), 1); // stability effect lasts 6 hours + originalHost.getGovernment().addStabilityModifier(modifier); guild.toLandless(false); - guild.getFaction().getGovernment().spendPower(cost); + originalHost.getGovernment().spendPower(cost); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); p.closeInventory(); } @@ -476,15 +479,21 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if(upgrade == null) return; if(!guild.isLeader(p)) return; Branch b = guild.getBranch(data); + if(b == null) return; + if (guild.getBank() == null) { + p.sendMessage("§cYour guild needs a bank to change a branch level."); + return; + } if(!upgrade) { if(b.getLevel() == 0) return; - guild.getBank().deposit(guild.getRefund()); + double refund = guild.getRefund(); b.levelDown(); + guild.getBank().deposit(refund); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); p.sendMessage("§cDowngraded "+b.getName()+ "§c to level §e"+b.getLevel()); } else { double cost = guild.getExpansionCost(); - if(guild.getBank().getWealth() < cost) { + if(guild.getBank() == null || !Double.isFinite(cost) || cost < 0 || guild.getBank().getWealth() < cost) { p.sendMessage("§cCannot afford to upgrade"); p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); return; @@ -494,8 +503,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); guild.getBank().withdraw(cost); } - // Downgrade refunds before the level drops, so the wealth pass still - // sees the old branch prestige. Refresh once the level is final. + // Refresh prestige after the final branch and bank state has been applied. if (!upgrade && guild.getFaction() != null) { guild.getFaction().updatePrestige(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/IconGetter.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/IconGetter.java index 3fe218d2..d2b2929b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/IconGetter.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/IconGetter.java @@ -9,6 +9,8 @@ import net.tfminecraft.simplefactions.Cache; public class IconGetter { + private IconGetter() {} + public static boolean hasIcon(String s) { if(Cache.icons.containsKey(s.toLowerCase())) return true; return false; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java index df4149ed..3d6d6b49 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java @@ -68,12 +68,13 @@ public void installationsView(Inventory inventory, Player player, Faction f, boo for (int index = 0; index < installations.size(); index++) { int slot = index + 12; + if (slot >= 39) slot++; if (slot > 44) { break; } inventory.setItem(slot, creator.createInstallationIcon(installations.get(index))); } - for (int slot = 12 + installations.size(); slot <= 44; slot++) { + for (int slot = 12 + installations.size() + (installations.size() >= 27 ? 1 : 0); slot <= 44; slot++) { inventory.setItem(slot, new ItemStack(Material.AIR, 1)); } @@ -247,11 +248,11 @@ public void click(InventoryClickEvent event, Inventory inventory, Player player) player.playSound(player, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); return; } - if (slot >= 0 && slot <= 44) { + if (slot >= 0 && slot <= 44 && slot != 11) { handleBerthedVehicleClick(event, inventory, player, f); return; } - if (slot != 11) { + if (slot != 11 || !f.getLeader().equalsIgnoreCase(player.getName())) { return; } ItemStack item = event.getCurrentItem(); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdater.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdater.java index da6107f1..14c9b7d8 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdater.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdater.java @@ -31,7 +31,6 @@ import net.tfminecraft.simplefactions.government.movement.Movement; import net.tfminecraft.simplefactions.government.movement.cause.Cause; import net.tfminecraft.simplefactions.government.proposal.TaxTarget; -import net.tfminecraft.simplefactions.laws.LawGroup; public class InventoryUpdater { InventoryManager inv; @@ -129,10 +128,6 @@ private void refreshSFHolder(Player p, Inventory i, SFInventoryHolder h) { inv.factionView.populateFactionList(i, p); return; } - if (type == SFGUI.GUILD_LIST) { - inv.guildView.populateGuildList(i, p); - return; - } if (type == SFGUI.WAR_LIST) { inv.warView.populateWarList(i); return; @@ -141,10 +136,6 @@ private void refreshSFHolder(Player p, Inventory i, SFInventoryHolder h) { inv.mercenaryMarketView.populateMarketList(i, p); return; } - if (type == SFGUI.PLAYER_LEDGER_VIEW) { - inv.playerLedgerView.open(p, i); - return; - } // Guild-keyed screens if (isGuildKeyed(type)) { @@ -178,10 +169,7 @@ private void refreshSFHolder(Player p, Inventory i, SFInventoryHolder h) { private void refreshGuildHolder(Player p, Inventory i, SFInventoryHolder h, Guild guild) { switch (h.getType()) { - case GUILD_VIEW -> inv.guildView(p, guild, i); case UPGRADE_VIEW -> inv.upgradeView(p, guild, i); - case LEDGER_VIEW -> inv.ledgerView(p, guild, i); - case COMPANY_VIEW -> inv.companyView(p, guild, i); case COMPANY_SLOTS_VIEW -> inv.companySlotsView(p, guild, i); case COMPANY_ROSTER_VIEW -> inv.companyRosterView(p, guild, i); case COMPANY_UPGRADE_VIEW -> inv.companyUpgradeView(p, guild, i); @@ -192,7 +180,7 @@ private void refreshGuildHolder(Player p, Inventory i, SFInventoryHolder h, Guil if (guild.getCompany() == null || guild.getCompany().getContractHandler().getById(contractId) == null) { // Contract gone - bounce to parent list - inv.contractListView(p, guild, i); + inv.contractListView(p, guild); return; } inv.contractDetailView(p, guild, i, contractId); @@ -206,7 +194,7 @@ private void refreshGuildHolder(Player p, Inventory i, SFInventoryHolder h, Guil Loan loan = guild.getLoanHandler().getLoanById(loanId); if (loan == null) { // Loan gone - bounce to parent list - inv.loansTakenView(p, guild, i); + inv.loansTakenView(p, guild); return; } inv.loanDetailView(p, guild, loan, true, i); @@ -216,7 +204,7 @@ private void refreshGuildHolder(Player p, Inventory i, SFInventoryHolder h, Guil if (loanId == null) return; Loan loan = guild.getLoanHandler().getLoanById(loanId); if (loan == null) { - inv.loansGivenView(p, guild, i); + inv.loansGivenView(p, guild); return; } inv.loanDetailView(p, guild, loan, false, i); @@ -227,11 +215,8 @@ private void refreshGuildHolder(Player p, Inventory i, SFInventoryHolder h, Guil private void refreshFactionHolder(Player p, Inventory i, SFInventoryHolder h, Faction f) { switch (h.getType()) { - case FACTION_VIEW -> inv.factionView.factionView(p, f, i); - case FACTION_GUILDS -> inv.factionView.factionGuildsView(p, f, i); case GOVERNMENT_VIEW -> inv.governmentView(p, f, i); case COUNCIL_VIEW -> inv.governmentView.councilView(p, f, i); - case PROPOSALS -> inv.governmentView.proposalList(p, f, i); case PROPOSAL_VIEW -> inv.proposalView(p, f, i); case POLITICAL_PROPOSAL_VIEW -> inv.governmentView.politicalProposalView(p, f, i); case WAR_PEACE_SELECT -> { @@ -254,19 +239,8 @@ private void refreshFactionHolder(Player p, Inventory i, SFInventoryHolder h, Fa } catch (IllegalArgumentException ex) { /* stale */ } } case LAW_PROPOSAL_VIEW -> inv.governmentView.lawProposalView(p, f, i); - case LAW_PROPOSAL_SELECT -> { - String sid = h.getSecondaryId(); - if (sid == null) return; - LawGroup group = f.getLawHandler().getGroup(sid); - if (group == null) { - inv.governmentView.lawProposalView(p, f, i); - return; - } - inv.governmentView.lawProposalSelect(p, f, group, i); - } case FAVOUR_REPRESS_MAIN -> inv.governmentView.favourRepressMainView(p, f, i); case FAVOUR_REPRESS_TYPE -> inv.governmentView.favourRepressTypeView(p, f, h.getFlag(), i); - case FAVOUR_REPRESS_SELECT -> inv.governmentView.favourRepressSelectView(p, f, h.getFlag(), h.getPage() == 1, i); case MILITARY_VIEW -> inv.militaryView(i, p, f, false); case INSTALLATIONS_VIEW -> inv.installationsView(i, p, f, false); case INSTALLATION_DETAIL_VIEW -> { @@ -288,16 +262,6 @@ private void refreshFactionHolder(Player p, Inventory i, SFInventoryHolder h, Fa } catch (IllegalArgumentException ex) { /* stale */ } } case LAW_VIEW -> inv.lawView(p, f, i); - case LAW_SELECT -> { - String sid = h.getSecondaryId(); - if (sid == null) return; - LawGroup group = f.getLawHandler().getGroup(sid); - if (group == null) { - inv.lawView(p, f, i); - return; - } - inv.lawView.lawSelect(p, f, group, i); - } case ELECTION_VIEW -> inv.electionView.electionView(p, f, i); case ELECTION_VOTING_VIEW -> { String sid = h.getSecondaryId(); @@ -323,21 +287,13 @@ private void refreshFactionHolder(Player p, Inventory i, SFInventoryHolder h, Fa private void refreshMovementHolder(Player p, Inventory i, SFInventoryHolder h, Faction f, Movement movement) { switch (h.getType()) { case MOVEMENT_VIEW -> inv.movementView(p, f, movement, i); - case CAUSES_VIEW -> inv.causesView(p, f, movement, i); - case CAUSE_VIEW -> { - int index = h.getPage(); - java.util.List<Cause> causes = movement.getCauses(); - if (index < 0 || index >= causes.size()) { - inv.causesView(p, f, movement, i); + case TARGET_SELECT -> { + Cause cause = inv.movementView.displayedCause(i, movement); + if (cause == null) { + inv.causesView(p, f, movement, null); return; } - inv.causeView(p, f, movement, causes.get(index), i); - } - case TARGET_SELECT -> { - int index = h.getPage(); - java.util.List<Cause> causes = movement.getCauses(); - if (index < 0 || index >= causes.size()) return; - inv.movementView.targetSelectionView(p, f, movement, causes.get(index), i); + inv.movementView.targetSelectionView(p, f, movement, cause, i); } case MOVEMENT_DEMANDS -> inv.movementView.demandsView(p, f, movement, i); case MOVEMENT_CRACKDOWN -> inv.movementView.crackdownView(p, f, movement, i); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LawView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LawView.java index 2f93ed87..c47be981 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LawView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LawView.java @@ -47,10 +47,17 @@ public void lawView(Player player, Faction f, Inventory i) { boolean open = i == null; if(open) i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.LAW_VIEW), 54, "§7Laws"); i.clear(); - for(int x = 0; x<f.getLawHandler().getGroupList().size(); x++) { - LawGroup group = f.getLawHandler().getGroupList().get(x); - i.setItem(LAW_SLOTS.get(x), creator.createLawGroupItem(player, f, group)); - } + SFInventoryHolder holder = (SFInventoryHolder) i.getHolder(); + List<LawGroup> groups = f.getLawHandler().getGroupList(); + int lastPage = Math.max(0, (groups.size() - 1) / LAW_SLOTS.size()); + holder.setPage(Math.min(holder.getPage(), lastPage)); + int start = holder.getPage() * LAW_SLOTS.size(); + int end = Math.min(start + LAW_SLOTS.size(), groups.size()); + for(int index = start; index < end; index++) { + i.setItem(LAW_SLOTS.get(index - start), creator.createLawGroupItem(player, f, groups.get(index))); + } + if(holder.getPage() > 0) i.setItem(45, DefaultCreator.createPreviousPageButton()); + if(holder.getPage() < lastPage) i.setItem(52, DefaultCreator.createNextPageButton()); i.setItem(53, inv.createBackButton(SFGUI.LAW_VIEW)); if(open) player.openInventory(i); } @@ -62,11 +69,17 @@ public void lawSelect(Player player, Faction f, LawGroup group, Inventory i) { boolean open = i == null; if(open) i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.LAW_SELECT, group.getId()), 27, "§7Law View"); i.clear(); - int slot = 0; - for(Law law : group.getLaws().values()) { - i.setItem(slot, creator.createLawItem(player, f, group, law, false)); - slot++; - } + SFInventoryHolder holder = (SFInventoryHolder) i.getHolder(); + List<Law> laws = new java.util.ArrayList<>(group.getLaws().values()); + int lastPage = Math.max(0, (laws.size() - 1) / 24); + holder.setPage(Math.min(holder.getPage(), lastPage)); + int start = holder.getPage() * 24; + int end = Math.min(start + 24, laws.size()); + for(int index = start; index < end; index++) { + i.setItem(index - start, creator.createLawItem(player, f, group, laws.get(index), false)); + } + if(holder.getPage() > 0) i.setItem(24, DefaultCreator.createPreviousPageButton()); + if(holder.getPage() < lastPage) i.setItem(25, DefaultCreator.createNextPageButton()); i.setItem(26, inv.createBackButton(SFGUI.LAW_SELECT)); if(open) player.openInventory(i); } @@ -74,6 +87,24 @@ public void lawSelect(Player player, Faction f, LawGroup group, Inventory i) { public void click(InventoryClickEvent e, Inventory inventory, Player p) { if(!(inventory.getHolder() instanceof SFInventoryHolder)) return; SFInventoryHolder holder = (SFInventoryHolder) inventory.getHolder(); + if(holder.getType() == SFGUI.LAW_VIEW || holder.getType() == SFGUI.LAW_SELECT) { + boolean select = holder.getType() == SFGUI.LAW_SELECT; + int previous = select ? 24 : 45; + int next = select ? 25 : 52; + if(e.getSlot() == previous || e.getSlot() == next) { + e.setCancelled(true); + Faction faction = FactionManager.getByString(holder.getId()); + if(faction == null) return; + holder.setPage(holder.getPage() + (e.getSlot() == next ? 1 : -1)); + if(select) { + LawGroup group = faction.getLawHandler().getGroup(holder.getSecondaryId()); + if(group != null) lawSelect(p, faction, group, inventory); + } else { + lawView(p, faction, inventory); + } + return; + } + } if (holder.getType() == SFGUI.LAW_VIEW) { e.setCancelled(true); ItemStack item = e.getCurrentItem(); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanCreator.java index c594eb64..5032b5fe 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanCreator.java @@ -217,10 +217,6 @@ public ItemStack createPayOffLoanButton(Loan loan) { lore.add(StringFormatter.formatHex("#87d65cPaying off this loan now would give ")); lore.add(StringFormatter.formatHex("#87d65cyou a credit score bonus of #19be2a" + bonus)); lore.add(StringFormatter.formatHex("#454343(Currently "+loan.getBorrower().getLoanHandler().getCreditScoreString()+"#454343)")); - } else if(bonus < 0) { - lore.add(StringFormatter.formatHex("#87d65cPaying off this loan now would give ")); - lore.add(StringFormatter.formatHex("#87d65cyou a credit score penalty of #b51717" + bonus)); - lore.add(StringFormatter.formatHex("#454343(Currently "+loan.getBorrower().getLoanHandler().getCreditScoreString()+"#454343)")); } else { lore.add(StringFormatter.formatHex("#6f776aPaying off this loan now would")); lore.add(StringFormatter.formatHex("#6f776ahave no effect on your credit score")); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanView.java index 0162e455..2b422c62 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/LoanView.java @@ -216,7 +216,7 @@ else if(h.getType() == SFGUI.LOANS_TAKEN_VIEW) { String id = meta.getPersistentDataContainer().get(Keys.STRING_KEY, PersistentDataType.STRING); String gid = meta.getPersistentDataContainer().get(Keys.SECONDARY_STRING_KEY, PersistentDataType.STRING); Guild issuer = FactionManager.getGuildByString(gid); - Loan loan = issuer.getLoanHandler().getLoanById(id); + Loan loan = borrowedBy(guild, issuer, id); if(loan == null) return; loanDetailView(p, guild, loan, true); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); @@ -265,7 +265,7 @@ else if(h.getType() == SFGUI.TAKEN_LOAN_DETAIL_VIEW) { String gid = detailItem.getItemMeta().getPersistentDataContainer().get(Keys.SECONDARY_STRING_KEY, PersistentDataType.STRING); Guild issuer = FactionManager.getGuildByString(gid); Loan loan = borrowedBy(guild, issuer, id); - if(loan == null) return; + if(loan == null || loan.isPaidOff()) return; loan.setDefaulted(!loan.hasDefaulted()); if(loan.hasDefaulted()) { loan.setAutoPay(false); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MercenaryMarketView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MercenaryMarketView.java index d0c8abe1..5dc488af 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MercenaryMarketView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MercenaryMarketView.java @@ -76,6 +76,8 @@ public void populateMarketList(Inventory inventory, Player p) { inventory.clear(); int perPage = usableSlots.size(); + page = Math.max(0, Math.min(page, Math.max(0, (companies.size() - 1) / perPage))); + currentPage.put(p, page); int start = page * perPage; int end = Math.min(start + perPage, companies.size()); for (int i = start; i < end; i++) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryCreator.java index b0fbc8ec..0083c30e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryCreator.java @@ -88,12 +88,8 @@ public ItemStack createRegimentIcon(Faction f, Regiment r) { if(r.sentToOverlord() > 0) { lore.add(StringFormatter.formatHex("#877e7c("+r.sentToOverlord()+" sent as levies)")); } - if(f.getModifier(FactionModifiers.MILITARY_UPKEEP) != null){ - double mod = 1.0 + f.getModifier(FactionModifiers.MILITARY_UPKEEP).getAmount()/100.0; - lore.add("§7Current Upkeep: §e"+Formatter.formatMoney(r.getTotalUpkeep()*mod)+"d §7("+Formatter.formatMoney(r.getUpkeep()*mod)+"d per slot)"); - } else { - lore.add("§7Current Upkeep: §e"+Formatter.formatMoney(r.getTotalUpkeep())+"d §7("+Formatter.formatMoney(r.getUpkeep())+"d per slot)"); - } + double mod = 1.0 + f.getModifier(FactionModifiers.MILITARY_UPKEEP).getAmount()/100.0; + lore.add("§7Current Upkeep: §e"+Formatter.formatMoney(r.getTotalUpkeep()*mod)+"d §7("+Formatter.formatMoney(r.getUpkeep()*mod)+"d per slot)"); } else { int total = 0; for(LevyEntry e : r.getEntries()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementCreator.java index a922a9b0..434ba350 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementCreator.java @@ -220,9 +220,11 @@ public ItemStack createCauseLeaderItem(Player p, Cause cause) { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public ItemStack createCauseProposalItem(Cause cause, Player p, Faction f) { - ItemStack item = cause.getProposal().getPoliticalAction().getIcon(); + ItemStack icon = cause.getProposal().getPoliticalAction().getIcon(); + ItemStack item = icon == null || icon.isEmpty() ? new ItemStack(Material.PAPER) : icon.clone(); ItemMeta m = item.getItemMeta(); if (m == null) { + item = new ItemStack(Material.PAPER); m = item.getItemMeta(); } m.setDisplayName(StringFormatter.formatHex("#85c265Proposal")); @@ -331,9 +333,9 @@ public ItemStack createPhaseItem(Player p, Phase phase, Movement movement) { ItemStack item = new ItemStack(Material.RED_CONCRETE); if(phase == movement.getPhase()) { item = new ItemStack(Material.GREEN_CONCRETE); - } else if(phase.getIndex() == movement.getPhase().getIndex()-1 && movement.getLeader().equalsIgnoreCase(p.getName())) { + } else if(phase.getIndex() == movement.getPhase().getIndex()-1 && movement.isLeader(p.getName())) { item = new ItemStack(Material.YELLOW_CONCRETE); - } else if(phase.getIndex() < 4 && phase.getIndex() == movement.getPhase().getIndex()+1 && movement.getOrganization() >= movement.getPhase().getMaxOrganization() && movement.getLeader().equalsIgnoreCase(p.getName())) { + } else if(phase.getIndex() < 4 && phase.getIndex() == movement.getPhase().getIndex()+1 && movement.getOrganization() >= movement.getPhase().getMaxOrganization() && movement.isLeader(p.getName())) { item = new ItemStack(Material.YELLOW_CONCRETE); } ItemMeta m = item.getItemMeta(); @@ -345,7 +347,7 @@ public ItemStack createPhaseItem(Player p, Phase phase, Movement movement) { lore.add(StringFormatter.formatHex("#7a7a7aThe current phase of the movement")); lore.add(StringFormatter.formatHex("#7a7a7awhich determines its max organization level")); lore.add(""); - if(movement.getLeader().equalsIgnoreCase(p.getName())) { + if(movement.isLeader(p.getName())) { if(item.getType().equals(Material.RED_CONCRETE)) { lore.add(StringFormatter.formatHex("#c74d32Unavailable")); if(phase.getIndex() > 0 && movement.getPhase().getIndex() == phase.getIndex()-1) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementView.java index 712eb9ef..05e28d9b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MovementView.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.managers.inventory; +import java.util.ArrayList; import java.util.List; import org.bukkit.Bukkit; @@ -45,6 +46,32 @@ public class MovementView { public MovementView(InventoryManager inv) { this.inv = inv; } + + // A menu's ordinal describes the displayed snapshot, not a later reordered list. + private static final class CauseInventoryHolder extends SFInventoryHolder { + private List<Cause> causes; + + CauseInventoryHolder(String id, SFGUI type) { + super(id, type); + } + + void bind(Movement movement, int index) { + causes = new ArrayList<>(movement.getCauses()); + setPage(index); + } + } + + private Cause causeAt(Inventory inventory, Movement movement, int index) { + if (!(inventory.getHolder() instanceof CauseInventoryHolder holder) + || index < 0 || index >= holder.causes.size()) return null; + Cause cause = holder.causes.get(index); + return movement.getCauses().contains(cause) ? cause : null; + } + + public Cause displayedCause(Inventory inventory, Movement movement) { + if (!(inventory.getHolder() instanceof CauseInventoryHolder holder)) return null; + return causeAt(inventory, movement, holder.getPage()); + } // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") @@ -95,10 +122,12 @@ public void movementView(Player player, Faction f, Movement movement, Inventory // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void causesView(Player player, Faction f, Movement movement, Inventory i) { - boolean open = i == null; - if (i == null) { - i = Bukkit.createInventory(new SFInventoryHolder(movement.getId(), SFGUI.CAUSES_VIEW), 54, "Causes"); + boolean open = i == null || !(i.getHolder() instanceof CauseInventoryHolder) + || ((SFInventoryHolder) i.getHolder()).getType() != SFGUI.CAUSES_VIEW; + if (open) { + i = Bukkit.createInventory(new CauseInventoryHolder(movement.getId(), SFGUI.CAUSES_VIEW), 54, "Causes"); } + ((CauseInventoryHolder) i.getHolder()).bind(movement, 0); i.clear(); List<Cause> causes = movement.getCauses(); @@ -131,10 +160,12 @@ public void causesView(Player player, Faction f, Movement movement, Inventory i) // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void causeView(Player player, Faction f, Movement movement, Cause cause, Inventory i) { - boolean open = i == null; - if (i == null) { - i = Bukkit.createInventory(new SFInventoryHolder(movement.getId(), SFGUI.CAUSE_VIEW, cause.getIndex()), 54, "Cause Details"); + boolean open = i == null || !(i.getHolder() instanceof CauseInventoryHolder) + || ((SFInventoryHolder) i.getHolder()).getType() != SFGUI.CAUSE_VIEW; + if (open) { + i = Bukkit.createInventory(new CauseInventoryHolder(movement.getId(), SFGUI.CAUSE_VIEW), 54, "Cause Details"); } + ((CauseInventoryHolder) i.getHolder()).bind(movement, cause.getIndex()); i.clear(); // Cause leader icon @@ -163,10 +194,12 @@ public void causeView(Player player, Faction f, Movement movement, Cause cause, // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void targetSelectionView(Player player, Faction f, Movement movement, Cause cause, Inventory i) { - boolean open = i == null; - if (i == null) { - i = Bukkit.createInventory(new SFInventoryHolder(movement.getId(), SFGUI.TARGET_SELECT, cause.getIndex()), 54, "§7Select New Leader"); + boolean open = i == null || !(i.getHolder() instanceof CauseInventoryHolder) + || ((SFInventoryHolder) i.getHolder()).getType() != SFGUI.TARGET_SELECT; + if (open) { + i = Bukkit.createInventory(new CauseInventoryHolder(movement.getId(), SFGUI.TARGET_SELECT), 54, "§7Select New Leader"); } + ((CauseInventoryHolder) i.getHolder()).bind(movement, cause.getIndex()); i.clear(); int x = 0; @@ -464,7 +497,7 @@ else if (slot == 25) { // Phase buttons (slots 28-31) else if (slot >= 28 && slot <= 31) { ItemStack item = e.getCurrentItem(); - if (item != null && item.getType() == Material.YELLOW_CONCRETE && movement.getLeader().equalsIgnoreCase(p.getName())) { + if (item != null && item.getType() == Material.YELLOW_CONCRETE && movement.isLeader(p.getName())) { if (meta.getPersistentDataContainer().has(Keys.SECONDARY_STRING_KEY, PersistentDataType.STRING)) { String phaseName = meta.getPersistentDataContainer().get(Keys.SECONDARY_STRING_KEY, PersistentDataType.STRING); try { @@ -510,12 +543,10 @@ private void handleCausesViewClick(InventoryClickEvent e, Movement movement, Pla // Check if a cause was clicked if (meta.getPersistentDataContainer().has(Keys.INT, PersistentDataType.INTEGER)) { int index = meta.getPersistentDataContainer().get(Keys.INT, PersistentDataType.INTEGER); - if (index < movement.getCauses().size()) { - Cause cause = movement.getCauses().get(index); - if (cause != null) { - causeView(p, f, movement, cause, null); - p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_BIT, 1, 1); - } + Cause cause = causeAt(inventory, movement, index); + if (cause != null) { + causeView(p, f, movement, cause, null); + p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_BIT, 1, 1); } } } @@ -525,7 +556,7 @@ private void handleCauseViewClick(InventoryClickEvent e, Movement movement, Play Cause cause = null; if (meta.getPersistentDataContainer().has(Keys.INT, PersistentDataType.INTEGER)) { int index = meta.getPersistentDataContainer().get(Keys.INT, PersistentDataType.INTEGER); - cause = movement.getCauses().get(index); + cause = causeAt(inventory, movement, index); } if (cause == null) { return; @@ -560,7 +591,7 @@ else if (slot == 14) { p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1, 1); return; } - FactionManager.requestMovementJoin(p, movement, "member", null); + FactionManager.requestMovementJoin(p, movement, "member", cause); causeView(p, f, movement, cause, inventory); p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_BIT, 1, 1); } @@ -588,8 +619,7 @@ private void handleTargetSelectClick(InventoryClickEvent e, Movement movement, P String targetName = meta.getPersistentDataContainer().get(Keys.STRING_KEY, PersistentDataType.STRING); int causeIndex = meta.getPersistentDataContainer().get(Keys.INT, PersistentDataType.INTEGER); - if (causeIndex >= movement.getCauses().size()) return; - Cause cause = movement.getCauses().get(causeIndex); + Cause cause = causeAt(inventory, movement, causeIndex); if (cause == null) return; if (!cause.hasLeader() || !cause.getLeader().equals(p.getName())) return; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/RelationCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/RelationCreator.java index e62a6bad..159826f7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/RelationCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/RelationCreator.java @@ -72,6 +72,7 @@ public ItemStack createWarButton(Faction target, Faction origin) { @SuppressWarnings("deprecation") public ItemStack createRelationItem(Faction target, Faction origin) { ItemStack i = target.getBanner(); + i = i == null || i.isEmpty() ? new ItemStack(Material.WHITE_BANNER) : i.clone(); Relation r = origin.getRelation(target.getId()); Relation ofR = target.getRelation(origin.getId()); ItemMeta m = i.getItemMeta(); @@ -251,6 +252,7 @@ public ItemStack createAttitudeItem(Attitude a, Faction origin, Faction target, lore.add(StringFormatter.formatHex("#28ed70Click to change")); } m.setLore(lore); + m.getPersistentDataContainer().set(new NamespacedKey(SimpleFactions.plugin, "id"), PersistentDataType.STRING, a.getId()); i.setItemMeta(m); return i; } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/RelationView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/RelationView.java index 1ea744ce..1bfcaed8 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/RelationView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/RelationView.java @@ -126,9 +126,6 @@ private void handleDiplomacyListClick(InventoryClickEvent e, Inventory inventory return; } ItemStack item = e.getCurrentItem(); - if (item == null || item.getItemMeta() == null) { - return; - } NamespacedKey id = new NamespacedKey(SimpleFactions.plugin, "id"); String factionId = item.getItemMeta().getPersistentDataContainer().get(id, PersistentDataType.STRING); if (factionId == null) { @@ -267,6 +264,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { SFInventoryHolder h = (SFInventoryHolder) inventory.getHolder(); Faction f = FactionManager.getByString(h.getId()); if(!h.getType().equals(SFGUI.DIPLOMACY_VIEW)) return; + if(f == null || FactionManager.getByMember(p.getName()) == null) return; if(e.getSlot() == 30) { p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); attitudeView(null, p, f, true); @@ -312,7 +310,10 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { Faction f = FactionManager.getByString(h.getId()); if(!h.getType().equals(SFGUI.ATTITUDE_VIEW)) return; Faction origin = FactionManager.getByMember(p.getName()); - Attitude a = RelationLoader.getAttitudes().get(e.getSlot()-10); + if(f == null || origin == null) return; + String attitudeId = meta.getPersistentDataContainer().get(new NamespacedKey(SimpleFactions.plugin, "id"), PersistentDataType.STRING); + Attitude a = RelationLoader.getAttitude(attitudeId); + if(a == null) return; if (!RelationManager.setAttitude(p, a, f, origin)) { p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); return; @@ -332,6 +333,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { Faction origin = FactionManager.getByMember(p.getName()); RelationType r = RelationLoader.getType(rid); + if(f == null || origin == null || r == null) return; if(origin.getRelation(f.getId()).getType().hasLock()) { p.sendMessage("§cYou are bound to "+f.getName()+"§c and cannot change this relationship yourself."); @@ -384,6 +386,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { Faction origin = FactionManager.getByMember(p.getName()); RelationType r = RelationLoader.getType(rid); + if(f == null || origin == null || r == null) return; RelationType current = origin.getDiplomacyHandler().getTradeRelation(f.getId()); RelationType theirCurrent = f.getDiplomacyHandler().getTradeRelation(origin.getId()); if(current != null && current.hasLock()) { @@ -412,6 +415,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { Faction origin = FactionManager.getByMember(p.getName()); RelationType r = RelationLoader.getType(rid); + if(f == null || origin == null || r == null) return; RelationType current = origin.getDiplomacyHandler().getTreatyRelation(f.getId()); RelationType theirCurrent = f.getDiplomacyHandler().getTreatyRelation(origin.getId()); if(current != null && current.hasLock()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java index 410e5f15..ec0c37ba 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenus.java @@ -212,9 +212,16 @@ public static void navigate(org.bukkit.event.inventory.InventoryClickEvent event case INSTALLATIONS_VIEW -> { var report = EspionageService.report(viewer, faction); var entries = report == null ? List.<String>of() : report.details("installation-details", "installations"); - if (slot >= 12 && slot - 12 < entries.size()) { - String[] fields = entries.get(slot - 12).split("\n", 3); - if (fields.length == 3) manager.installationDetailView(viewer, faction, fields[0]); + if (slot >= 12 && slot <= 38) { + int position = 12; + for (String entry : entries) { + String[] fields = entry.split("\n", 3); + if (fields.length != 3) continue; + if (position++ == slot) { + manager.installationDetailView(viewer, faction, fields[0]); + break; + } + } } } case TAX_VIEW -> { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TaxView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TaxView.java index 4369cb30..3b867c5a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TaxView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TaxView.java @@ -20,6 +20,9 @@ import net.tfminecraft.simplefactions.keys.Keys; public class TaxView { + private static final int PAGE_SIZE = 45; + private static final int PREVIOUS_SLOT = 45; + private static final int NEXT_SLOT = 52; private InventoryManager inv; private GovernmentCreator creator = new GovernmentCreator(); @@ -61,29 +64,30 @@ public void specificTaxView(Player player, Faction f, TaxTarget target, Inventor boolean open = i == null; if(open) i = SimpleFactions.plugin.getServer().createInventory(new SFInventoryHolder(f.getId(), SFGUI.TAX_VIEW_SPECIFIC, target.name()), 54, "§7"+target.getDisplayName()); i.clear(); - int x = 0; + java.util.List<String> targets = new java.util.ArrayList<>(); if(target == TaxTarget.GUILD_ID) { - for(Guild g : f.getGuildHandler().getGuilds()) { - if(g.isBase()) continue; - i.setItem(x, creator.createSpecificTaxItem(player, f, g.getId(), target)); - if(x >= 53) break; - x++; + for(Guild guild : f.getGuildHandler().getGuilds()) { + if(!guild.isBase()) targets.add(guild.getId()); } } else if(target == TaxTarget.VASSAL_ID) { - for(Faction s : RelationManager.getSubjects(f)) { - i.setItem(x, creator.createSpecificTaxItem(player, f, s.getId(), target)); - if(x >= 53) break; - x++; - } + for(Faction subject : RelationManager.getSubjects(f)) targets.add(subject.getId()); } else if(target == TaxTarget.TARIFF_ID) { - for(Faction fac : FactionManager.factions) { - if(fac.getId().equalsIgnoreCase(f.getId())) continue; - if(RelationManager.sameRealm(fac, f)) continue; - if(x >= 53) break; - i.setItem(x, creator.createSpecificTaxItem(player, f, fac.getId(), target)); - x++; + for(Faction foreign : FactionManager.factions) { + if(!foreign.getId().equalsIgnoreCase(f.getId()) && !RelationManager.sameRealm(foreign, f)) { + targets.add(foreign.getId()); + } } } + SFInventoryHolder holder = (SFInventoryHolder) i.getHolder(); + int lastPage = Math.max(0, (targets.size() - 1) / PAGE_SIZE); + holder.setPage(Math.min(holder.getPage(), lastPage)); + int start = holder.getPage() * PAGE_SIZE; + int end = Math.min(start + PAGE_SIZE, targets.size()); + for(int index = start; index < end; index++) { + i.setItem(index - start, creator.createSpecificTaxItem(player, f, targets.get(index), target)); + } + if(holder.getPage() > 0) i.setItem(PREVIOUS_SLOT, DefaultCreator.createPreviousPageButton()); + if(holder.getPage() < lastPage) i.setItem(NEXT_SLOT, DefaultCreator.createNextPageButton()); i.setItem(53, inv.createBackButton(SFGUI.TAX_VIEW_SPECIFIC)); if(open) player.openInventory(i); } @@ -95,6 +99,12 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if(f == null) { p.closeInventory(); return; + } + if(holder.getType() == SFGUI.TAX_VIEW_SPECIFIC + && (e.getSlot() == PREVIOUS_SLOT || e.getSlot() == NEXT_SLOT)) { + holder.setPage(holder.getPage() + (e.getSlot() == NEXT_SLOT ? 1 : -1)); + specificTaxView(p, f, TaxTarget.valueOf(holder.getSecondaryId()), inventory); + return; } if(holder.getType() == SFGUI.TAX_VIEW) { ItemStack item = e.getCurrentItem(); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleCreator.java index aa5d210b..a5eff7f4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleCreator.java @@ -180,24 +180,29 @@ public ItemStack createNewTitleItem(Faction f, Tier t) { ItemMeta m = i.getItemMeta(); m.setDisplayName(StringFormatter.formatHex("#c49760Create a new "+t.getName())); List<String> lore = new ArrayList<String>(); - int cost = getNewTitleCost(f, TierLoader.getByLevel(t.getTier())); - int current = 0; - if(t.getId().equalsIgnoreCase("county")) { - current = f.getUntitledProvinces().size(); - int percent = (int) Math.round((current * 1.0) / (cost * 1.0) * 100); - lore.add(StringFormatter.formatHex("#d4c9aeYou need at least #28ed70"+cost+" #d4c9aeuntitled provinces to form a new "+t.getName()+"#d4c9ae!")); - lore.add(StringFormatter.formatHex("#767a77Currently: #a1aba3" + current + " #535955[" + percent + "%]")); - } else { - current = f.getFreeTitles(TierLoader.getByLevel(t.getTier()-1)).size(); - int percent = (int) Math.round((current * 1.0) / (cost * 1.0) * 100); - lore.add(StringFormatter.formatHex("#d4c9aeYou need at least #28ed70"+cost+" #d4c9aefree titles of the type "+TierLoader.getByLevel(t.getTier()-1).getName()+" #d4c9aeto form a "+t.getName()+"#d4c9ae!")); - lore.add(StringFormatter.formatHex("#767a77Currently: #a1aba3" + current + " #535955[" + percent + "%]")); - } - lore.add(""); - if(current >= cost) { - lore.add(StringFormatter.formatHex("#28ed70Click to create")); - } else { + Tier lower = TierLoader.getByLevel(t.getTier()-1); + if (!t.canForm() || (!t.getId().equalsIgnoreCase("county") && lower == null)) { lore.add(StringFormatter.formatHex("#9e4c4fUnavailable")); + } else { + int cost = getNewTitleCost(f, TierLoader.getByLevel(t.getTier())); + int current = 0; + if(t.getId().equalsIgnoreCase("county")) { + current = f.getUntitledProvinces().size(); + int percent = (int) Math.round((current * 1.0) / (cost * 1.0) * 100); + lore.add(StringFormatter.formatHex("#d4c9aeYou need at least #28ed70"+cost+" #d4c9aeuntitled provinces to form a new "+t.getName()+"#d4c9ae!")); + lore.add(StringFormatter.formatHex("#767a77Currently: #a1aba3" + current + " #535955[" + percent + "%]")); + } else { + current = f.getFreeTitles(lower).size(); + int percent = (int) Math.round((current * 1.0) / (cost * 1.0) * 100); + lore.add(StringFormatter.formatHex("#d4c9aeYou need at least #28ed70"+cost+" #d4c9aefree titles of the type "+lower.getName()+" #d4c9aeto form a "+t.getName()+"#d4c9ae!")); + lore.add(StringFormatter.formatHex("#767a77Currently: #a1aba3" + current + " #535955[" + percent + "%]")); + } + lore.add(""); + if(current >= cost) { + lore.add(StringFormatter.formatHex("#28ed70Click to create")); + } else { + lore.add(StringFormatter.formatHex("#9e4c4fUnavailable")); + } } m.setLore(lore); NamespacedKey key = new NamespacedKey(SimpleFactions.plugin, "tier"); @@ -236,6 +241,8 @@ public ItemStack createTierItem(Faction f, Tier t, int index) { m.setLore(lore); NamespacedKey key = new NamespacedKey(SimpleFactions.plugin, "index"); m.getPersistentDataContainer().set(key, PersistentDataType.INTEGER, index); + m.getPersistentDataContainer().set(new NamespacedKey(SimpleFactions.plugin, "tier"), + PersistentDataType.STRING, t.getId()); i.setItemMeta(m); return i; } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleView.java index cfee129c..72dcc32c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleView.java @@ -136,15 +136,22 @@ public void titleTypeView(Inventory i, Player player, Faction f, Tier tier, bool // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void click(InventoryClickEvent e, Inventory inventory, Player p) { + ItemStack clicked = e.getCurrentItem(); + if (clicked == null || clicked.getItemMeta() == null) return; if(e.getView().getTitle().equalsIgnoreCase("§7Tier View")) { e.setCancelled(true); - Material type = e.getCurrentItem().getType(); + Material type = clicked.getType(); if(!type.equals(Material.YELLOW_CONCRETE)) return; if(!(inventory.getHolder() instanceof SFInventoryHolder)) return; SFInventoryHolder h = (SFInventoryHolder) inventory.getHolder(); Faction f = FactionManager.getByString(h.getId()); + if (f == null || !f.isLeader(p.getName())) return; NamespacedKey key = new NamespacedKey(SimpleFactions.plugin, "index"); - int index = e.getCurrentItem().getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.INTEGER); + Integer index = clicked.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.INTEGER); + String tierId = clicked.getItemMeta().getPersistentDataContainer().get( + new NamespacedKey(SimpleFactions.plugin, "tier"), PersistentDataType.STRING); + if (!f.getTier().getId().equals(tierId) || index == null + || index < -1 || index >= f.getTier().getAliases().size()) return; f.getTier().setIndex(index); tierView(inventory, p, f, false); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); @@ -155,8 +162,9 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if(!(inventory.getHolder() instanceof SFInventoryHolder)) return; SFInventoryHolder h = (SFInventoryHolder) inventory.getHolder(); Faction f = FactionManager.getByString(h.getId()); + if (f == null) return; NamespacedKey key = new NamespacedKey(SimpleFactions.plugin, "id"); - String s = e.getCurrentItem().getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); + String s = clicked.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); Tier t = TierLoader.getByString(s); if(t == null) return; titleTypeView(null, p, f, t, true, 0); @@ -165,13 +173,21 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { } else if(inventory.getHolder() instanceof SFInventoryHolder && ((SFInventoryHolder) inventory.getHolder()).getType().equals(SFGUI.TITLE_TYPE_VIEW)) { e.setCancelled(true); - Material type = e.getCurrentItem().getType(); + Material type = clicked.getType(); SFInventoryHolder h = (SFInventoryHolder) inventory.getHolder(); Faction f = FactionManager.getByString(h.getId()); + if (f == null) return; if(type.equals(Material.WRITABLE_BOOK)) { + if (!f.isLeader(p.getName())) return; + if (!f.getGovernment().stateReport().status.canFormTitles()) { + p.sendMessage("§cA " + f.getGovernment().stateReport().status.getLabel() + " cannot form titles."); + p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); + return; + } NamespacedKey key = new NamespacedKey(SimpleFactions.plugin, "tier"); - String tierString = e.getCurrentItem().getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); + String tierString = clicked.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); Tier tier = TierLoader.getByString(tierString); + if (tier == null || !tier.canForm()) return; int needed = creator.getNewTitleCost(f, tier); int current = 0; Tier lower = TierLoader.getByLevel(tier.getTier()-1); @@ -188,7 +204,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { if(current < needed) { return; } - if(RelationManager.getOverlord(f) != null && FactionManager.getByString(RelationManager.getOverlord(f)).getTier().getTier() < f.getTier().getTier()+1) { + if(RelationManager.getOverlord(f) != null && FactionManager.getByString(RelationManager.getOverlord(f)).getTier().getTier() < tier.getTier()) { p.sendMessage("§cForming a new "+tier.getName()+" title would make you a higher tier than your liege!"); return; } @@ -198,24 +214,27 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { return; } NamespacedKey key = new NamespacedKey(SimpleFactions.plugin, "page"); - Integer page = e.getCurrentItem().getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.INTEGER); + Integer page = clicked.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.INTEGER); if(page != null) { - key = new NamespacedKey(SimpleFactions.plugin, "id"); - String s = inventory.getContents()[0].getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); - titleTypeView(inventory, p, f, TitleLoader.getById(s).getTier(), false, page); + Tier tier = TierLoader.getByString(h.getSecondaryId()); + if (tier == null) return; + titleTypeView(inventory, p, f, tier, false, page); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); // The redraw can leave the clicked slot empty, so it is no longer a title button. return; } key = new NamespacedKey(SimpleFactions.plugin, "id"); - String s = e.getCurrentItem().getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); + String s = clicked.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); Title t = TitleLoader.getById(s); if(t == null) return; key = new NamespacedKey(SimpleFactions.plugin, "type"); - String action = e.getCurrentItem().getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); + String action = clicked.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); - if(action.equalsIgnoreCase("claim")) { + if("claim".equalsIgnoreCase(action)) { if(!type.equals(Material.YELLOW_CONCRETE)) return; + if (!f.isLeader(p.getName()) || FactionManager.getTitleOwner(t) != null + || !t.canBeCreatedBy(f, TitleManager.getProvinces(f), TitleManager.getTitles(f), + f.getGovernment().deJureScore())) return; if (f.getGovernment() != null && !f.getGovernment().stateReport().status.canFormTitles()) { p.sendMessage("§cA " + f.getGovernment().stateReport().status.getLabel() + " cannot form titles."); p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f); @@ -237,14 +256,15 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { titleTypeView(inventory, p, f, t.getTier(), false, h.getPage()); p.sendMessage("§aClaimed the title "+t.getName()); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); - } else if(action.equalsIgnoreCase("grant")) { + } else if("grant".equalsIgnoreCase(action)) { if(!type.equals(Material.GREEN_CONCRETE)) return; Faction pf = FactionManager.getByLeader(p.getName()); - if(pf == null) return; + if(pf == null || !pf.getId().equalsIgnoreCase(RelationManager.getOverlord(f)) + || !TitleManager.getGrantableTitles(pf, f, t.getTier()).contains(t)) return; f.addTitle(t); pf.removeTitle(t); - if(TitleManager.getGrantableTitles(f, pf, t.getTier()).size() > 0) titleTypeView(inventory, p, f, t.getTier(), false, h.getPage()); + if(TitleManager.getGrantableTitles(pf, f, t.getTier()).size() > 0) titleTypeView(inventory, p, f, t.getTier(), false, h.getPage()); else titleView(null, p, f, true); p.sendMessage("§aGranted "+f.getName()+" the title "+t.getName()); p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/VehicleFeeView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/VehicleFeeView.java index df64cbe1..6fdca0b2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/VehicleFeeView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/VehicleFeeView.java @@ -129,7 +129,8 @@ public void back(Player player, Faction f, SFInventoryHolder h) { case FEE_PROPOSAL_VIEW -> inv.governmentView.proposalView(player, f, null); case FEE_CATEGORY_VIEW -> feeProposalView(player, f, null); case FEE_VEHICLE_VIEW -> { - FeeKind kind = parseKind(h.getSecondaryId()); + String secondary = h.getSecondaryId(); + FeeKind kind = secondary == null ? null : parseKind(secondary.split(":", 2)[0]); if (kind == null) { feeProposalView(player, f, null); } else { @@ -167,7 +168,8 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { } } case FEE_CATEGORY_VIEW -> { - FeeKind kind = parseKind(h.getSecondaryId()); + String secondary = h.getSecondaryId(); + FeeKind kind = secondary == null ? null : parseKind(secondary.split(":", 2)[0]); if (kind == null) { return; } @@ -357,9 +359,6 @@ private static String rangeText(VehicleFeeHandler handler, FeeKind kind) { } private static FeeKind parseKind(String name) { - if (name == null) { - return null; - } try { return FeeKind.valueOf(name); } catch (IllegalArgumentException e) { diff --git a/src/main/java/net/tfminecraft/simplefactions/map/MapSystem.java b/src/main/java/net/tfminecraft/simplefactions/map/MapSystem.java index b5df2bdb..94918d67 100644 --- a/src/main/java/net/tfminecraft/simplefactions/map/MapSystem.java +++ b/src/main/java/net/tfminecraft/simplefactions/map/MapSystem.java @@ -34,6 +34,7 @@ public class MapSystem { private int lastUpdate = 300; private int fullUpdate = 0; + private boolean mapUploadInProgress; private HashMap<String, List<String>> queues = new HashMap<>(); @@ -115,6 +116,7 @@ public void uploadLiveFiles() { } public void updateMap() { + if (mapUploadInProgress) return; lastUpdate = 0; compiler.exportQueue(queues); Database db = new Database(); @@ -124,11 +126,17 @@ public void updateMap() { prepareUploadFiles(); File queueFile = new File("plugins/SimpleFactions/MapAPI/queue.json"); SimpleFactions plugin = SimpleFactions.getInstance(); + // New changes belong to the next publication, including another change to the same nation. + queues = new HashMap<>(); + mapUploadInProgress = true; Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { - RestServer.upload("queue", queueFile); - uploadPreparedFiles(); - RestServer.commenceRegen("queued"); - Bukkit.getScheduler().runTask(plugin, this::clear); + try { + RestServer.upload("queue", queueFile); + uploadPreparedFiles(); + RestServer.commenceRegen("queued"); + } finally { + Bukkit.getScheduler().runTask(plugin, () -> mapUploadInProgress = false); + } }); } @@ -248,10 +256,6 @@ private void claim(Player p, Faction f, int pid, boolean sea, boolean forCapital claimProvince(p, f, owner, pid, stolen, forCapital); } - private void claimProvince(Player p, Faction f, Faction owner, int province, boolean stolen) { - claimProvince(p, f, owner, province, stolen, false); - } - private void claimProvince( Player p, Faction f, diff --git a/src/main/java/net/tfminecraft/simplefactions/map/ProvinceGrid.java b/src/main/java/net/tfminecraft/simplefactions/map/ProvinceGrid.java index c9d63618..2b9166e5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/map/ProvinceGrid.java +++ b/src/main/java/net/tfminecraft/simplefactions/map/ProvinceGrid.java @@ -52,7 +52,7 @@ public static ProvinceGrid load(File file) throws IOException { throw new IOException("invalid province_id_grid dimensions: " + width + "x" + height); } - int expectedBody = width * height * 2; + long expectedBody = (long) width * height * 2; int bodyOffset = HEADER_SIZE; if (payload.length - bodyOffset != expectedBody) { throw new IOException( @@ -66,8 +66,8 @@ public static ProvinceGrid load(File file) throws IOException { + height); } - short[] ids = new short[width * height]; - ByteBuffer body = ByteBuffer.wrap(payload, bodyOffset, expectedBody).order(ByteOrder.LITTLE_ENDIAN); + short[] ids = new short[(int) (expectedBody / 2)]; + ByteBuffer body = ByteBuffer.wrap(payload, bodyOffset, (int) expectedBody).order(ByteOrder.LITTLE_ENDIAN); for (int i = 0; i < ids.length; i++) { ids[i] = body.getShort(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/map/ProvinceSpatial.java b/src/main/java/net/tfminecraft/simplefactions/map/ProvinceSpatial.java index 99b4fdf9..891c80b9 100644 --- a/src/main/java/net/tfminecraft/simplefactions/map/ProvinceSpatial.java +++ b/src/main/java/net/tfminecraft/simplefactions/map/ProvinceSpatial.java @@ -34,14 +34,20 @@ public static boolean withinBlocksOfSea(int x, int z, int radiusBlocks) { return false; } ProvinceManager manager = SimpleFactions.getInstance().getProvinceManager(); - int radiusSq = radiusBlocks * radiusBlocks; + long radiusSq = (long) radiusBlocks * radiusBlocks; + long minX = Math.max(0L, (long) x - radiusBlocks); + long maxX = Math.min(grid.getWidth() - 1L, (long) x + radiusBlocks); + long minZ = Math.max(0L, (long) z - radiusBlocks); + long maxZ = Math.min(grid.getHeight() - 1L, (long) z + radiusBlocks); - for (int dz = -radiusBlocks; dz <= radiusBlocks; dz++) { - for (int dx = -radiusBlocks; dx <= radiusBlocks; dx++) { + for (long sampleZ = minZ; sampleZ <= maxZ; sampleZ++) { + long dz = sampleZ - z; + for (long sampleX = minX; sampleX <= maxX; sampleX++) { + long dx = sampleX - x; if (dx * dx + dz * dz > radiusSq) { continue; } - int provinceId = grid.getAt(x + dx, z + dz); + int provinceId = grid.getAt((int) sampleX, (int) sampleZ); if (provinceId <= 0) { continue; } diff --git a/src/main/java/net/tfminecraft/simplefactions/map/SeaConnectivity.java b/src/main/java/net/tfminecraft/simplefactions/map/SeaConnectivity.java index fb90b841..2dedc025 100644 --- a/src/main/java/net/tfminecraft/simplefactions/map/SeaConnectivity.java +++ b/src/main/java/net/tfminecraft/simplefactions/map/SeaConnectivity.java @@ -41,13 +41,9 @@ public static Set<Integer> sharedSeaProvinces( if (startSeas.isEmpty() || goalSeas.isEmpty()) { return Set.of(); } - Set<Integer> visited = floodSea(provinceManager, startSeas); - for (int seaId : visited) { - if (goalSeas.contains(seaId)) { - return visited; - } - } - return Set.of(); + Set<Integer> shared = floodSea(provinceManager, startSeas); + shared.retainAll(floodSea(provinceManager, goalSeas)); + return shared; } private static Set<Integer> floodSea(ProvinceManager provinceManager, Set<Integer> startSeas) { @@ -56,9 +52,6 @@ private static Set<Integer> floodSea(ProvinceManager provinceManager, Set<Intege while (!queue.isEmpty()) { int currentId = queue.poll(); Province current = provinceManager.get(currentId); - if (current == null || !current.isValid()) { - continue; - } for (int neighbourId : current.getNeighbours()) { if (visited.contains(neighbourId)) { continue; diff --git a/src/main/java/net/tfminecraft/simplefactions/map/export/ChronicleSnapshot.java b/src/main/java/net/tfminecraft/simplefactions/map/export/ChronicleSnapshot.java index 0ce079be..a15f5649 100644 --- a/src/main/java/net/tfminecraft/simplefactions/map/export/ChronicleSnapshot.java +++ b/src/main/java/net/tfminecraft/simplefactions/map/export/ChronicleSnapshot.java @@ -221,9 +221,6 @@ private static JsonArray buildGuilds(List<Faction> factions) { private static JsonObject wealthBreakdown(Faction faction) { Guild main = faction.getOrCreateMainGuild(); JsonObject object = breakdown(main != null ? main.getWealthModifiers() : null); - if (faction.getGuildHandler() == null) { - return object; - } for (Guild guild : faction.getGuildHandler().getGuilds()) { if (guild == null || guild.isBase() || guild.getWealth() == 0) continue; if (guild.getId() == null) continue; @@ -263,9 +260,6 @@ private static Set<String> belligerentIds(War war) { } private static void collect(Side side, Set<String> ids) { - if (side == null) { - return; - } for (Participant participant : side.getMainParticipants()) { for (Faction faction : participant.getAllParticipatingFactions()) { if (faction != null && faction.getId() != null) ids.add(faction.getId()); @@ -274,12 +268,7 @@ private static void collect(Side side, Set<String> ids) { } private static List<War> activeWars() { - try { - List<War> active = WarManager.getActive(); - return active != null ? active : List.of(); - } catch (Throwable t) { - return List.of(); - } + return WarManager.getActive(); } /** diff --git a/src/main/java/net/tfminecraft/simplefactions/map/export/OccupationMapExport.java b/src/main/java/net/tfminecraft/simplefactions/map/export/OccupationMapExport.java index a66b5924..7c8fdd53 100644 --- a/src/main/java/net/tfminecraft/simplefactions/map/export/OccupationMapExport.java +++ b/src/main/java/net/tfminecraft/simplefactions/map/export/OccupationMapExport.java @@ -107,7 +107,11 @@ private static void addAll(Set<Integer> ids, List<Integer> source) { if (source == null) { return; } - ids.addAll(source); + for (Integer provinceId : source) { + if (provinceId != null) { + ids.add(provinceId); + } + } } private static void addRgb(Set<String> rgbs, Faction faction) { diff --git a/src/main/java/net/tfminecraft/simplefactions/map/export/WarMapExporter.java b/src/main/java/net/tfminecraft/simplefactions/map/export/WarMapExporter.java index 4c046e09..c7d2fc88 100644 --- a/src/main/java/net/tfminecraft/simplefactions/map/export/WarMapExporter.java +++ b/src/main/java/net/tfminecraft/simplefactions/map/export/WarMapExporter.java @@ -127,9 +127,6 @@ private static JsonArray exportBelligerents(War war) { } private static void collectBelligerentIds(Side side, Set<String> ids) { - if (side == null) { - return; - } for (Participant participant : side.getMainParticipants()) { for (Faction faction : participant.getAllParticipatingFactions()) { if (faction != null && faction.getId() != null) { diff --git a/src/main/java/net/tfminecraft/simplefactions/map/fertility/FertilityProvinceResolver.java b/src/main/java/net/tfminecraft/simplefactions/map/fertility/FertilityProvinceResolver.java index d1e25ec4..42a7b7ad 100644 --- a/src/main/java/net/tfminecraft/simplefactions/map/fertility/FertilityProvinceResolver.java +++ b/src/main/java/net/tfminecraft/simplefactions/map/fertility/FertilityProvinceResolver.java @@ -26,7 +26,8 @@ public static int fertilityAt(int x, int z, ProvinceGrid grid, ProvinceManager m } public static int fertilityAt(Location location) { - if (location == null || location.getWorld() == null) { + if (location == null || location.getWorld() == null + || !location.getWorld().getName().equals(Cache.worldName)) { return 0; } SimpleFactions plugin = SimpleFactions.getInstance(); diff --git a/src/main/java/net/tfminecraft/simplefactions/map/provinces/Province.java b/src/main/java/net/tfminecraft/simplefactions/map/provinces/Province.java index b3b22e0a..fd58b9fa 100644 --- a/src/main/java/net/tfminecraft/simplefactions/map/provinces/Province.java +++ b/src/main/java/net/tfminecraft/simplefactions/map/provinces/Province.java @@ -5,6 +5,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; @@ -47,7 +48,7 @@ public Province(int id, String terrain, int fertility) { public Province(int id, String terrain, int fertility, int centerX, int centerZ) { this.id = id; try { - this.terrain = Terrain.valueOf(terrain.toUpperCase()); + this.terrain = Terrain.valueOf(terrain.toUpperCase(Locale.ROOT)); } catch (Exception e) { this.terrain = Terrain.UNKNOWN; } @@ -103,7 +104,7 @@ public void calculateTrade( ProvinceDataEntry entry = data.get(guild.getId()); // Stop if we already have equal or better trade - if (entry != null && entry.getTrade() >= amount) { + if (entry != null && getRawGuildTrade(guild) >= amount) { return; } @@ -358,19 +359,20 @@ public void addNeighbour(int provinceId) { public double getTotalTrade() { double total = 0; for (ProvinceDataEntry entry : data.values()) { - total += entry.getTrade(); + total += getGuildTrade(entry.getGuild()); } return total; } public double getGuildTrade(Guild guild) { ProvinceDataEntry entry = data.get(guild.getId()); - return entry == null ? 0 : entry.getTrade()*getModifierForGuild(guild, FactionModifiers.TRADE_POWER, getModifiersForGuild(guild)); + return entry == null ? 0 : Math.max(0, entry.getTrade()*getModifierForGuild(guild, FactionModifiers.TRADE_POWER, getModifiersForGuild(guild))); } public double getTradeShare(Guild guild) { if(!data.containsKey(guild.getId())) return 0; - return getGuildTrade(guild)/getTotalTrade(); + double total = getTotalTrade(); + return total <= 0 ? 0 : getGuildTrade(guild) / total; } public double getProsperity() { diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java index 3ac25e45..01935c1e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java @@ -317,14 +317,12 @@ public MercenaryResult adminAdjustSlots(int delta) { } return MercenaryResult.ok("Added " + delta + " slot" + (delta == 1 ? "" : "s") + "."); } - int remove = -delta; + long remove = -(long) delta; if (getSlots() < remove) { return MercenaryResult.deny("Not enough slots to remove."); } for (int i = 0; i < remove; i++) { - if (!dropSlot()) { - return MercenaryResult.deny("Not enough slots to remove."); - } + dropSlot(); } return MercenaryResult.ok("Removed " + remove + " slot" + (remove == 1 ? "" : "s") + "."); } diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyService.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyService.java index b33d20a1..200419fb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyService.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyService.java @@ -163,9 +163,7 @@ public static MercenaryResult kick(Guild guild, String actor, String player) { if (company.hasFreeSlot()) { return MercenaryResult.deny("Fill every slot before dismissing a mercenary."); } - if (!company.kick(player)) { - return MercenaryResult.deny(player + " does not serve in your company."); - } + company.kick(player); return MercenaryResult.ok(player + " was dismissed from the company."); } @@ -199,9 +197,7 @@ public static MercenaryResult removeSlot(Guild guild, String actor) { if (!company.hasFreeSlot()) { return MercenaryResult.deny("Dismiss a mercenary before removing a slot."); } - if (!company.dropSlot()) { - return MercenaryResult.deny("There is no unused slot to remove."); - } + company.dropSlot(); return MercenaryResult.ok("Removed one unused company slot."); } diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryInvites.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryInvites.java index 56961a25..6df0e846 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryInvites.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryInvites.java @@ -29,8 +29,9 @@ public static void invite(Player leader, String targetName) { return; } MercenaryCompany company = guild.getCompany(); - RequestManager.addRequest(leader, target, new MercenaryInviteRequest(company)); - if (!(RequestManager.getRequest(target) instanceof MercenaryInviteRequest)) { + MercenaryInviteRequest invitation = new MercenaryInviteRequest(company); + RequestManager.addRequest(leader, target, invitation); + if (RequestManager.getRequest(target) != invitation) { return; } leader.sendMessage("§aInvited " + target.getName() + " to " + company.getName() + "."); @@ -45,7 +46,13 @@ public static void accept(Player player) { player.sendMessage("§cYou have no company invite to accept."); return; } - MercenaryResult result = MercenaryCompanyService.join(request.getCompany(), player.getName()); + MercenaryCompany company = request.getCompany(); + if (company.getGuild().getCompany() != company) { + RequestManager.remove(player); + player.sendMessage("§cThat company invite has expired."); + return; + } + MercenaryResult result = MercenaryCompanyService.join(company, player.getName()); player.sendMessage((result.ok() ? "§a" : "§c") + result.message()); RequestManager.remove(player); if (!result.ok()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/RpCharactersMercenaryTraitProbe.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/RpCharactersMercenaryTraitProbe.java index 2155c123..84aaf2d7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/RpCharactersMercenaryTraitProbe.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/RpCharactersMercenaryTraitProbe.java @@ -28,7 +28,7 @@ public MercenaryEligibility.Status check(String player) { return MercenaryEligibility.Status.UNKNOWN; } PlayerData data = PlayerManager.get(online); - if (data == null || !data.hasActiveCharacter()) { + if (data == null) { return MercenaryEligibility.Status.INELIGIBLE; } RPCharacter character = data.getActiveCharacter(); diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/WageSettings.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/WageSettings.java index 72aa1ae7..4be1b8ae 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/WageSettings.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/WageSettings.java @@ -114,14 +114,14 @@ private static double clampPercent(double percent) { private static void put(Map<String, Double> map, String player, Double value) { if (player == null || player.isBlank()) return; if (value == null) { - map.remove(player.toLowerCase()); + map.remove(player.toLowerCase(java.util.Locale.ROOT)); return; } - map.put(player.toLowerCase(), value); + map.put(player.toLowerCase(java.util.Locale.ROOT), value); } private static Double get(Map<String, Double> map, String player) { if (player == null) return null; - return map.get(player.toLowerCase()); + return map.get(player.toLowerCase(java.util.Locale.ROOT)); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractHandler.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractHandler.java index e689fc7b..27a08629 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractHandler.java @@ -146,6 +146,12 @@ public Offer offer(Faction hirer, ContractKind kind, ContractTerms terms, long n * The hiring faction accepts. Capacity is not re-checked because the offer has * been holding those slots all along; loyalty is, because the world moves. */ + public MercenaryResult acceptAtHall(String contractId, Faction hirer, Player signer) { + MercenaryResult permission = MercenaryMarket.canSign(company, signer); + if (!permission.ok()) return permission; + return accept(contractId, hirer, signer.getName()); + } + public MercenaryResult accept(String contractId, Faction hirer, String signer) { MercenaryContract contract = getById(contractId); if (contract == null) { @@ -178,9 +184,7 @@ public MercenaryResult accept(String contractId, Faction hirer, String signer) { expire(contract); return alongside; } - if (!contract.activate()) { - return MercenaryResult.deny("That offer is no longer open."); - } + contract.activate(); return MercenaryResult.ok(company.getName() + " has entered your service."); } @@ -309,9 +313,7 @@ public MercenaryResult acceptSlots(String contractId, Faction hirer, String sign + hirer.getName() + " was dropped. The company no longer has room for it."); return MercenaryResult.deny("The company no longer has room for that many slots."); } - if (!contract.applyPendingSlots(now)) { - return MercenaryResult.deny("That slot change has lapsed."); - } + contract.applyPendingSlots(now); tell(company.getLeader(), "§a" + hirer.getName() + " accepted the slot change. The contract now hires " + slots + " slots."); return MercenaryResult.ok("The contract now hires " + slots + " slots."); diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryLoyalty.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryLoyalty.java index 4e12c4a7..65ef659e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryLoyalty.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryLoyalty.java @@ -126,14 +126,8 @@ private static boolean isBoundTo(Faction host, Faction other) { for (Faction ally : RelationManager.getAllies(host)) { if (sameFaction(ally, other)) return true; } - String overlord = RelationManager.getOverlord(host); - if (overlord != null && other.getId() != null && overlord.equalsIgnoreCase(other.getId())) { - return true; - } - for (Faction subject : RelationManager.getSubjects(host)) { - if (sameFaction(subject, other)) return true; - } - return false; + return RelationManager.isOnOverlordPath(host, other) + || RelationManager.isOnOverlordPath(other, host); } private static List<Faction> factionsOn(Side side) { diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/stat/MercenaryStatApplier.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/stat/MercenaryStatApplier.java index 5e51d29c..bfa3894d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/stat/MercenaryStatApplier.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/stat/MercenaryStatApplier.java @@ -4,11 +4,11 @@ /** Where a plan actually meets MythicLib, MMOCore and the health attribute. */ public interface MercenaryStatApplier { - /** False when the soft dependencies are missing, which makes every call a no-op. */ + /** False when the soft dependencies needed to apply a plan are missing. */ boolean isAvailable(); void apply(Player player, MercenaryStatPlan plan); - /** Must be safe to call when nothing was ever applied. */ + /** Safe even without soft dependencies; still removes owned Bukkit attributes. */ void strip(Player player); } diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/stat/MercenaryStatService.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/stat/MercenaryStatService.java index b3607d4b..c5d05179 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/stat/MercenaryStatService.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/stat/MercenaryStatService.java @@ -88,7 +88,6 @@ public static boolean apply(Player player) { public static void clear(Player player) { if (player == null) return; applied.remove(player.getUniqueId()); - if (!applier.isAvailable()) return; applier.strip(player); } diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/stat/MythicLibStatApplier.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/stat/MythicLibStatApplier.java index eb743c7e..b5add47a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/stat/MythicLibStatApplier.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/stat/MythicLibStatApplier.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.UUID; @@ -55,7 +56,7 @@ public void apply(Player player, MercenaryStatPlan plan) { @Override public void strip(Player player) { List<StatModifier> modifiers = registered.remove(player.getUniqueId()); - if (modifiers != null && !modifiers.isEmpty()) { + if (modifiers != null && !modifiers.isEmpty() && pluginEnabled("MythicLib")) { MMOPlayerData data = MMOPlayerData.getOrNull(player); if (data != null) { for (StatModifier modifier : modifiers) { @@ -68,7 +69,7 @@ public void strip(Player player) { private static StatModifier register(MMOPlayerData data, String stat, double amount) { StatModifier modifier = new StatModifier( - KEY_PREFIX + stat.toLowerCase(), stat, amount, ModifierType.FLAT); + KEY_PREFIX + stat.toLowerCase(Locale.ROOT), stat, amount, ModifierType.FLAT); modifier.register(data); return modifier; } diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java index bce3850c..5ac3e5e8 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java @@ -209,7 +209,7 @@ public Faction(Guild guild) { this.governmentType = "Community"; this.culture = guild.getFaction().getCulture(); this.religion = guild.getFaction().getReligion(); - this.wealth = 0.0; + this.wealth = guild.getWealth(); this.prestige = 0.0; this.extraNodeCapacity = 0; this.rgb = guild.getRGB(); @@ -227,6 +227,7 @@ public Faction(Guild guild) { guildHandler.addGuild(guild); guild.setHost(this); guild.convert(GuildLoader.getBaseType()); + lawHandler.apply(); createBanner(bannerPatterns); updatePrestige(); updateTier(); @@ -408,7 +409,10 @@ public double getTotalForeignTaxRate() { } public Bracket getBracket(Brackets bracket) { - return null; + if (bracket == null) return null; + FeeKind fee = FeeKind.fromBracket(bracket); + if (fee != null) return vehicleFeeHandler.getBracket(fee); + return taxHandler.getBracket(BracketToTaxTarget.convert(bracket)); } public void giveTax(String player, double amount) { @@ -1000,16 +1004,8 @@ public void updateTier() { } else if (provinceHandler.getProvinces().size() > 0 && titles.size() == 0) { temp = TierLoader.getByString("province"); } else { - Title highest = titles.stream() - .sorted((a, b) -> Integer.compare(b.getTier().getTier(), a.getTier().getTier())) - .findFirst() - .orElse(null); - - if (highest != null) { - temp = highest.getTier(); - } else { - temp = TierLoader.getLowest(); - } + // Both empty-title cases were handled above. + temp = getHighestTitle().getTier(); } if(tier == null || !tier.getId().equalsIgnoreCase(temp.getId())) tier = new Tier(temp, -1); Player p = Bukkit.getPlayerExact(leader); diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/FactionModifier.java b/src/main/java/net/tfminecraft/simplefactions/objects/FactionModifier.java index e1ae4a91..c793330c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/FactionModifier.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/FactionModifier.java @@ -20,13 +20,16 @@ public class FactionModifier { private double atStronger; public FactionModifier(String m) { - try { - amount = Double.parseDouble(m.split("\\(")[1].replace(")", "")); - type = modifierType(m.split("\\(")[0]); - atEqual = amount; - } catch(Exception e) { - e.printStackTrace(); - } + if (m == null || m.indexOf('(') < 1 || !m.endsWith(")")) { + throw new IllegalArgumentException("Invalid faction modifier: " + m); + } + int open = m.indexOf('('); + amount = Double.parseDouble(m.substring(open + 1, m.length() - 1)); + if (!Double.isFinite(amount)) { + throw new IllegalArgumentException("Modifier amount must be finite: " + m); + } + type = modifierType(m.substring(0, open)); + atEqual = amount; } public FactionModifier(FactionModifiers type, double amount) { @@ -54,7 +57,11 @@ public FactionModifier(Faction from, FactionModifiers type, double amount) { public static FactionModifier fromYamlEntry(Object entry) { if (entry instanceof String s) { - return new FactionModifier(s); + try { + return new FactionModifier(s); + } catch (IllegalArgumentException invalid) { + return null; + } } if (entry instanceof Map<?, ?> map) { return fromMap(map); @@ -68,9 +75,6 @@ public static void addFromConfig(ConfigurationSection config, String key, List<F } List<?> list = config.getList(key); if (list == null || list.isEmpty()) { - for (String s : config.getStringList(key)) { - out.add(new FactionModifier(s)); - } return; } for (Object entry : list) { @@ -109,7 +113,7 @@ private static String stringVal(Object o) { } private static FactionModifiers modifierType(String value) { - String id = value.toUpperCase(); + String id = value.toUpperCase(java.util.Locale.ROOT); if (id.equals("INFRASTRUCTURE_ACCESS")) { id = "INSTALLATION_ACCESS"; } @@ -118,11 +122,13 @@ private static FactionModifiers modifierType(String value) { private static double doubleVal(Object o, double fallback) { if (o instanceof Number n) { - return n.doubleValue(); + double value = n.doubleValue(); + return Double.isFinite(value) ? value : fallback; } if (o instanceof String s) { try { - return Double.parseDouble(s); + double value = Double.parseDouble(s); + return Double.isFinite(value) ? value : fallback; } catch (NumberFormatException ignored) { return fallback; } @@ -140,65 +146,28 @@ public void edit(double d) { } private void fix() { - amount = Math.round(amount*100)/100; + amount = Math.round(amount*100)/100.0; } private String prefix() { - String prefix = ""; - switch(type) { - case LEVY: - prefix = "#d45131Levy Contribution"; - break; - case MILITARY_UPKEEP: - prefix = "#b39088Military Upkeep"; - break; - case NODE_SPEED: - prefix = "#92d96cNode Speed"; - break; - case PRESTIGE: - prefix = "#3e7fb5Prestige to Overlord"; - break; - case PRESTIGE_BONUS: - prefix = "#409dc2Prestige Bonus"; - break; - case PRESTIGE_MALUS: - prefix = "#d46a6aPrestige Malus"; - break; - case TRIBUTE: - prefix = "#d49024Tribute"; - break; - case TAX_MULTIPLIER: - prefix = "#5acca2Tax Multiplier"; - break; - case DE_JURE: - prefix = "#7bd481De Jure Requirement"; - break; - case STABILITY_INFLUENCE: - prefix = "#d64d66Stability Influence"; - break; - case TRADE_POWER: - prefix = "#92d665Trade Power"; - break; - case PRODUCTION: - prefix = "#f2c94cProduction"; - break; - case INSTALLATION_ACCESS: - prefix = "#92d6baInstallation Access"; - break; - case DIPLOMATIC_CAPACITY_MULTIPLIER: - prefix = "#56ccf2Diplomatic Capacity Multiplier"; - break; - case ADMIN_POWER_MULTIPLIER: - prefix = "#ebde54Admin Power Multiplier"; - break; - case ADMIN_POWER_GAIN_MULTIPLIER: - prefix = "#d1b347Admin Power Gain Multiplier"; - break; - default: - prefix = "#c7b381Unknown Modifier"; - break; - } - return StringFormatter.formatHex(prefix); + return StringFormatter.formatHex(switch (type) { + case LEVY -> "#d45131Levy Contribution"; + case MILITARY_UPKEEP -> "#b39088Military Upkeep"; + case NODE_SPEED -> "#92d96cNode Speed"; + case PRESTIGE -> "#3e7fb5Prestige to Overlord"; + case PRESTIGE_BONUS -> "#409dc2Prestige Bonus"; + case PRESTIGE_MALUS -> "#d46a6aPrestige Malus"; + case TRIBUTE -> "#d49024Tribute"; + case TAX_MULTIPLIER -> "#5acca2Tax Multiplier"; + case DE_JURE -> "#7bd481De Jure Requirement"; + case STABILITY_INFLUENCE -> "#d64d66Stability Influence"; + case TRADE_POWER -> "#92d665Trade Power"; + case PRODUCTION -> "#f2c94cProduction"; + case INSTALLATION_ACCESS -> "#92d6baInstallation Access"; + case DIPLOMATIC_CAPACITY_MULTIPLIER -> "#56ccf2Diplomatic Capacity Multiplier"; + case ADMIN_POWER_MULTIPLIER -> "#ebde54Admin Power Multiplier"; + case ADMIN_POWER_GAIN_MULTIPLIER -> "#d1b347Admin Power Gain Multiplier"; + }); } private String suffix(double displayed, Region region) { diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/PrestigeRank.java b/src/main/java/net/tfminecraft/simplefactions/objects/PrestigeRank.java index afd3b77d..8578cbbb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/PrestigeRank.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/PrestigeRank.java @@ -72,10 +72,6 @@ public PrestigeRank(String key, ConfigurationSection config) { } else { this.an = false; } - if(config.contains("modifiers")) { - for(String s : config.getStringList("modifiers")) { - modifiers.add(new FactionModifier(s)); - } - } + FactionModifier.addFromConfig(config, "modifiers", modifiers); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/handler/GuildHandler.java b/src/main/java/net/tfminecraft/simplefactions/objects/handler/GuildHandler.java index 3d6d5360..f56b5c82 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/handler/GuildHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/handler/GuildHandler.java @@ -54,6 +54,7 @@ public void removeGuild(String id, boolean dissolveEmptySettlement, boolean reva if (revalidateClaims && f.getProvinceHandler() != null) { f.getProvinceHandler().revalidateClaims(); } + f.updateWealth(); } public Guild getGuildByMember(String member) { diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/handler/ProvinceHandler.java b/src/main/java/net/tfminecraft/simplefactions/objects/handler/ProvinceHandler.java index d6eb5fa7..09d9f60b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/handler/ProvinceHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/handler/ProvinceHandler.java @@ -132,12 +132,12 @@ public void provinceCap() { //claims public boolean canClaim(int provinceId, boolean sea) { - if (provinces.size() == 0) return true; if (provinces.contains(provinceId)) return false; ProvinceManager pm = SimpleFactions.getInstance().getProvinceManager(); Province target = pm.get(provinceId); if (target == null || !target.isValid()) return false; + if (provinces.isEmpty()) return true; // Effective adjacency: land, one water tile, or one sea tile from the capital's land blob Set<Integer> capitalBlob = capitalLandBlob(pm); @@ -166,12 +166,12 @@ public boolean canClaim(int provinceId, boolean sea) { * @return true if the province can be claimed without relying on the guild's capital */ public boolean canClaim(int provinceId, boolean sea, Guild guild) { - if(provinces.size() == 0) return true; if(provinces.contains(provinceId)) return false; ProvinceManager pm = SimpleFactions.getInstance().getProvinceManager(); Province target = pm.get(provinceId); if (target == null || !target.isValid()) return false; + if (provinces.isEmpty()) return true; if(guild.getCapital() == guild.getFaction().getCapital()) { // Guild capital is the faction capital - no need to exclude it @@ -181,10 +181,14 @@ public boolean canClaim(int provinceId, boolean sea, Guild guild) { // Calculate which provinces would still be legal without this guild's capital Set<Integer> legalWithoutGuildCapital = calculateLegalProvincesExcludingGuildCapital(pm, guild); - // 1) Normal land adjacency (only check against legal provinces) + // Apply the same river/coastal adjacency as normal claims, without the old guild capital. + Set<Integer> capitalBlob = new HashSet<>(); + if (hasCapital() && capital != guild.getCapital()) { + capitalBlob.add(capital); + floodLandExcluding(pm, capital, capitalBlob, guild.getCapital()); + } for (int ownedId : legalWithoutGuildCapital) { - Province owned = pm.get(ownedId); - if (owned != null && owned.getNeighbours().contains(provinceId)) { + if (isEffectivelyAdjacent(pm, ownedId, provinceId, capitalBlob)) { return true; } } @@ -454,10 +458,6 @@ private Set<Integer> computeLegalProvinces(ProvinceManager pm, int factionCapita return legal; } - private boolean isCapitalStillLegal(ProvinceManager pm, int capitalId) { - return isCapitalStillLegal(pm, capitalId, capital); - } - private boolean isCapitalStillLegal(ProvinceManager pm, int guildCapitalId, int factionCapitalId) { Province guildCapital = pm.get(guildCapitalId); if (!provinces.contains(guildCapitalId) || guildCapital == null || !guildCapital.isValid() @@ -509,6 +509,7 @@ public String getClaimDeniedReason(int provinceId, boolean sea) { if (provinces.contains(provinceId)) { return "§cThis province is already part of your realm."; } + if (provinces.isEmpty()) return "Success"; boolean adjacent = false; Set<Integer> capitalBlob = capitalLandBlob(pm); diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/handler/TaxHandler.java b/src/main/java/net/tfminecraft/simplefactions/objects/handler/TaxHandler.java index f25d9354..bfd35d65 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/handler/TaxHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/handler/TaxHandler.java @@ -198,7 +198,12 @@ public void applyBracket(TaxTarget target, Bracket bracket) { } public Bracket getBracket(TaxTarget target) { - return taxBrackets.get(target); + return taxBrackets.get(switch (target) { + case GUILD_ID -> TaxTarget.GUILDS; + case VASSAL_ID -> TaxTarget.VASSALS; + case TARIFF_ID -> TaxTarget.TARIFFS; + default -> target; + }); } public double getMin(TaxTarget target) { @@ -216,23 +221,13 @@ public double getMax(TaxTarget target) { } public boolean canCollectTax(TaxTarget target) { - switch(target) { - case CITIZENS: - return f.hasFactionRule(Rules.CITIZEN_TAX); - case GUILDS: - case GUILD_ID: - return f.hasFactionRule(Rules.GUILD_TAX); - case VASSALS: - case VASSAL_ID: - return f.hasFactionRule(Rules.VASSAL_TAX); - case DIVIDENDS: - return f.hasFactionRule(Rules.DIVIDEND_TAX); - case TARIFFS: - case TARIFF_ID: - return f.hasFactionRule(Rules.TARIFFS); - default: - return false; - } + return f.hasFactionRule(switch (target) { + case CITIZENS -> Rules.CITIZEN_TAX; + case GUILDS, GUILD_ID -> Rules.GUILD_TAX; + case VASSALS, VASSAL_ID -> Rules.VASSAL_TAX; + case DIVIDENDS -> Rules.DIVIDEND_TAX; + case TARIFFS, TARIFF_ID -> Rules.TARIFFS; + }); } public void setSpecificTax(TaxTarget target, String id, double rate) { @@ -327,7 +322,6 @@ private double getDefaultRate(TaxTarget target) { case VASSALS, VASSAL_ID -> vassalTax; case DIVIDENDS -> dividendTax; case TARIFFS, TARIFF_ID -> tariffs; - default -> 0.0; }; } diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/request/ElevateRequest.java b/src/main/java/net/tfminecraft/simplefactions/objects/request/ElevateRequest.java index 5d4aa697..1648da02 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/request/ElevateRequest.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/request/ElevateRequest.java @@ -1,10 +1,17 @@ package net.tfminecraft.simplefactions.objects.request; import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.objects.Faction; public class ElevateRequest extends Request { + private final Faction sponsoringFaction; public ElevateRequest(Guild sender) { super(sender); + sponsoringFaction = sender.getFaction(); + } + + public Faction getSponsoringFaction() { + return sponsoringFaction; } } diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/request/MovementJoinRequest.java b/src/main/java/net/tfminecraft/simplefactions/objects/request/MovementJoinRequest.java index a04e41ce..80ef0961 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/request/MovementJoinRequest.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/request/MovementJoinRequest.java @@ -1,20 +1,40 @@ package net.tfminecraft.simplefactions.objects.request; import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.government.movement.cause.Cause; public class MovementJoinRequest extends Request{ private String player; private String type; private String faction; private int causeIndex; + private final Movement movement; + private final Cause cause; + + /** Legacy request data; an ordinal alone cannot authorize joining a live movement. */ public MovementJoinRequest(Guild sender, String player, String type, String faction, int causeIndex) { + this(sender, player, type, faction, causeIndex, null, null); + } + + public MovementJoinRequest(Guild sender, String player, String type, Movement movement, Cause cause) { + this(sender, player, type, movement.getFaction().getId(), cause == null ? -1 : cause.getIndex(), movement, cause); + } + + private MovementJoinRequest(Guild sender, String player, String type, String faction, int causeIndex, Movement movement, Cause cause) { super(sender); + this.movement = movement; + this.cause = cause; this.player = player; this.type = type; this.faction = faction; this.causeIndex = causeIndex; } + public Movement getMovement() { return movement; } + + public Cause getCause() { return cause; } + public String getPlayer() { return player; } diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/request/MovementLeaderTargetRequest.java b/src/main/java/net/tfminecraft/simplefactions/objects/request/MovementLeaderTargetRequest.java index aa0a4b56..7d69d76d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/request/MovementLeaderTargetRequest.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/request/MovementLeaderTargetRequest.java @@ -1,26 +1,45 @@ package net.tfminecraft.simplefactions.objects.request; import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.government.movement.cause.Cause; public class MovementLeaderTargetRequest extends Request { private final String requester; private final String movementId; private final int causeIndex; private final String proposedName; + private final Movement movement; + private final Cause cause; + /** Legacy request data; an ordinal alone cannot authorize editing a live cause. */ public MovementLeaderTargetRequest( Guild sender, String requester, String movementId, int causeIndex, String proposedName) { + this(sender, requester, movementId, causeIndex, proposedName, null, null); + } + + public MovementLeaderTargetRequest(Guild sender, String requester, Movement movement, Cause cause, String proposedName) { + this(sender, requester, movement.getId(), cause.getIndex(), proposedName, movement, cause); + } + + private MovementLeaderTargetRequest(Guild sender, String requester, String movementId, int causeIndex, String proposedName, Movement movement, Cause cause) { super(sender); + this.movement = movement; + this.cause = cause; this.requester = requester; this.movementId = movementId; this.causeIndex = causeIndex; this.proposedName = proposedName; } + public Movement getMovement() { return movement; } + + public Cause getCause() { return cause; } + public String getRequester() { return requester; } diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/request/RelocateRequest.java b/src/main/java/net/tfminecraft/simplefactions/objects/request/RelocateRequest.java index 7bd36185..9f6a0ae1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/request/RelocateRequest.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/request/RelocateRequest.java @@ -1,17 +1,40 @@ package net.tfminecraft.simplefactions.objects.request; import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.managers.FactionManager; public class RelocateRequest extends Request{ private final int newCapital; private final String settlementName; + private final Faction originalHost; + private final String originalLeader; + private final Faction destination; public RelocateRequest(Guild sender, int newCapital, String settlementName) { + this(sender, newCapital, settlementName, null); + } + + public RelocateRequest(Guild sender, int newCapital, String settlementName, Faction destination) { super(sender); + this.originalHost = sender.getFaction(); + this.originalLeader = sender.getLeader(); + this.destination = destination; this.newCapital = newCapital; this.settlementName = settlementName; } + public boolean isCurrentFor(Faction receiver) { + return originalHost != null + && FactionManager.getByString(originalHost.getId()) == originalHost + && FactionManager.getGuildByString(sender.getId()) == sender + && sender.getFaction() == originalHost + && java.util.Objects.equals(originalLeader, sender.getLeader()) + && !sender.isBase() + && receiver != originalHost + && (destination == null || destination == receiver); + } + public int getNewCapital() { return newCapital; } diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java index bfdcc014..638eb326 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java @@ -4,6 +4,9 @@ import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader; +import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService; +import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService.Quote; /** A player offering one of their personal vehicles to another player. */ public final class VehicleHandoverRequest extends Request { @@ -12,6 +15,8 @@ public final class VehicleHandoverRequest extends Request { private final UUID ownerUuid; private final String ownerName; private final UUID recipientUuid; + private final String feeFactionId; + private final double feeAmount; /** The sender guild is null when the owner is in no faction. */ public VehicleHandoverRequest( @@ -27,6 +32,9 @@ public VehicleHandoverRequest( this.ownerUuid = ownerUuid; this.ownerName = ownerName; this.recipientUuid = recipientUuid; + Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, ownerName, vehicleTypeId); + this.feeFactionId = quote == null ? null : quote.faction().getId(); + this.feeAmount = quote == null ? 0.0 : quote.amount(); this.time = System.currentTimeMillis() + InstallationConfigLoader.getTransferRequestTimeoutSeconds() * 1000L; } @@ -50,4 +58,9 @@ public String getOwnerName() { public UUID getRecipientUuid() { return recipientUuid; } + + public boolean matchesFee(Quote quote) { + return java.util.Objects.equals(feeFactionId, quote == null ? null : quote.faction().getId()) + && Double.compare(feeAmount, quote == null ? 0.0 : quote.amount()) == 0; + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleTransferConsentRequest.java b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleTransferConsentRequest.java index 05bd79c1..09a57bc8 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleTransferConsentRequest.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleTransferConsentRequest.java @@ -13,6 +13,7 @@ public final class VehicleTransferConsentRequest extends Request { private final UUID ownerUuid; private final UUID proposerLeaderUuid; private final boolean pool; + private final String destinationFactionId; public VehicleTransferConsentRequest( Guild sender, @@ -43,6 +44,7 @@ public VehicleTransferConsentRequest( this.ownerUuid = ownerUuid; this.proposerLeaderUuid = proposerLeaderUuid; this.pool = pool; + this.destinationFactionId = sender.getFaction() == null ? null : sender.getFaction().getId(); this.time = System.currentTimeMillis() + InstallationConfigLoader.getTransferRequestTimeoutSeconds() * 1000L; } @@ -71,6 +73,10 @@ public UUID getProposerLeaderUuid() { return proposerLeaderUuid; } + public String getDestinationFactionId() { + return destinationFactionId; + } + public boolean isPool() { return pool; } diff --git a/src/main/java/net/tfminecraft/simplefactions/rest/BannerFetcher.java b/src/main/java/net/tfminecraft/simplefactions/rest/BannerFetcher.java index fee31585..4ec09037 100644 --- a/src/main/java/net/tfminecraft/simplefactions/rest/BannerFetcher.java +++ b/src/main/java/net/tfminecraft/simplefactions/rest/BannerFetcher.java @@ -69,6 +69,7 @@ public static boolean fetch(String key, Consumer<List<String>> onMainThread) { task -> Bukkit.getScheduler().runTaskAsynchronously(plugin, task), task -> { if (plugin.isEnabled()) Bukkit.getScheduler().runTask(plugin, task); + else inFlight.remove(key); }); } diff --git a/src/main/java/net/tfminecraft/simplefactions/rest/RestServer.java b/src/main/java/net/tfminecraft/simplefactions/rest/RestServer.java index d3631109..0f240897 100644 --- a/src/main/java/net/tfminecraft/simplefactions/rest/RestServer.java +++ b/src/main/java/net/tfminecraft/simplefactions/rest/RestServer.java @@ -23,6 +23,7 @@ import net.tfminecraft.simplefactions.api.GatewayClient; public class RestServer { + private RestServer() {} private static final Gson gson = new Gson(); private static final String REGEN_HASH = "47a4921f7506514aec2d1471b424d8ae"; @@ -49,11 +50,13 @@ public static List<String> fetchBannerList() { public static int getProvince(Player p) { if (!Cache.provincesEnabled || !Cache.mapEnabled) return -2; + org.bukkit.Location location = p.getLocation(); + if (location.getWorld() == null || !location.getWorld().getName().equals(Cache.worldName)) return -2; SimpleFactions plugin = SimpleFactions.getInstance(); if (plugin == null) return -2; ProvinceGrid grid = plugin.getProvinceGrid(); if (grid == null) return -2; - return grid.getAt(p.getLocation().getBlockX(), p.getLocation().getBlockZ()); + return grid.getAt(location.getBlockX(), location.getBlockZ()); } public static void upload(String mode, File file) { diff --git a/src/main/java/net/tfminecraft/simplefactions/settlement/Settlement.java b/src/main/java/net/tfminecraft/simplefactions/settlement/Settlement.java index 01cb6238..7e2aa842 100644 --- a/src/main/java/net/tfminecraft/simplefactions/settlement/Settlement.java +++ b/src/main/java/net/tfminecraft/simplefactions/settlement/Settlement.java @@ -25,7 +25,7 @@ public Settlement(String id, String name, int centerProvince, int centerX, int c } public Settlement(SettlementData data) { - if (data.id == null || data.name == null || data.centerProvince == null) { + if (data == null || data.id == null || data.name == null || data.centerProvince == null) { throw new IllegalArgumentException("Settlement data missing required fields"); } this.id = data.id; @@ -35,7 +35,7 @@ public Settlement(SettlementData data) { this.centerZ = data.centerZ != null ? data.centerZ : 0; if (data.provinces != null) { for (Number p : data.provinces) { - provinces.add(p.intValue()); + if (p != null) provinces.add(p.intValue()); } } if (!provinces.contains(centerProvince)) { diff --git a/src/main/java/net/tfminecraft/simplefactions/settlement/handler/SettlementHandler.java b/src/main/java/net/tfminecraft/simplefactions/settlement/handler/SettlementHandler.java index e0bf0bcf..5e08c754 100644 --- a/src/main/java/net/tfminecraft/simplefactions/settlement/handler/SettlementHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/settlement/handler/SettlementHandler.java @@ -127,6 +127,11 @@ public CapitalResult onGuildRelocateTo(Player player, Guild guild, int newCapita return resolveCapital(player, newCapital, nameOpt, -1, true, false); } + /** Checks the destination before relocation claims an unowned province. */ + public CapitalResult validateRelocationCapital(Player player, int province, String nameOpt) { + return resolveCapital(player, province, nameOpt, -1, true, true); + } + private CapitalResult resolveCapital( Player player, int province, @@ -250,6 +255,16 @@ public void acceptTransferred(Settlement settlement) { if (settlement == null) { return; } + String id = settlement.getId(); + int suffix = 1; + while (byId.containsKey(id) && byId.get(id) != settlement) { + id = settlement.getId() + "_" + settlement.getCenterProvince() + "_" + suffix++; + } + if (!id.equals(settlement.getId())) { + SettlementData data = settlement.toData(); + data.id = id; + settlement = new Settlement(data); + } register(settlement); } @@ -318,14 +333,8 @@ void dissolve(Settlement s) { } static void clearCapitalsPointingAt(Faction owner, Settlement destroyed) { - if (destroyed == null) { - return; - } int center = destroyed.getCenterProvince(); clearFactionCapitals(owner, destroyed, center); - if (FactionManager.factions == null) { - return; - } for (Faction other : FactionManager.getCopy()) { if (other == owner) { continue; @@ -335,9 +344,6 @@ static void clearCapitalsPointingAt(Faction owner, Settlement destroyed) { } private static void clearFactionCapitals(Faction other, Settlement destroyed, int center) { - if (other == null) { - return; - } if (other.getGuildHandler() != null) { for (Guild g : other.getGuildHandler().getGuilds()) { if (g == null || g.isBase()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/tiers/admin/TitleAdminCommand.java b/src/main/java/net/tfminecraft/simplefactions/tiers/admin/TitleAdminCommand.java index c78899a9..f28102f7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/tiers/admin/TitleAdminCommand.java +++ b/src/main/java/net/tfminecraft/simplefactions/tiers/admin/TitleAdminCommand.java @@ -99,10 +99,7 @@ else apply(sender, TitleAdminService.addProvinces(args[2], Arrays.asList(args).s } private static String usage(String sub) { - for (String line : USAGE) { - if (line.startsWith("§6/faction title " + sub + " ")) return line; - } - return USAGE[0]; + return USAGE[SUBCOMMANDS.indexOf(sub)]; } private static boolean provinceExists(int id) { diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/BracketToTaxTarget.java b/src/main/java/net/tfminecraft/simplefactions/utils/BracketToTaxTarget.java index e7aa77f8..5b3dab2d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/BracketToTaxTarget.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/BracketToTaxTarget.java @@ -4,6 +4,8 @@ import net.tfminecraft.simplefactions.government.proposal.TaxTarget; public class BracketToTaxTarget { + private BracketToTaxTarget() {} + public static TaxTarget convert(Brackets bracket) { switch (bracket) { case CITIZEN_TAX: diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/DisplayNameGate.java b/src/main/java/net/tfminecraft/simplefactions/utils/DisplayNameGate.java index 5f59189a..20be10fa 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/DisplayNameGate.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/DisplayNameGate.java @@ -52,12 +52,10 @@ private enum GateKind { private static final class Pending { private final NameOperation op; private final String raw; - private final GateKind kind; - private Pending(NameOperation op, String raw, GateKind kind) { + private Pending(NameOperation op, String raw) { this.op = op; this.raw = raw; - this.kind = kind; } } @@ -134,26 +132,24 @@ public static Result check(Player player, NameOperation op, String raw, boolean if (player == null || op == null) { return Result.OK; } + UUID id = player.getUniqueId(); + Pending prev = pending.get(id); + if (prev != null && prev.op == op && prev.raw.equals(raw)) { + pending.remove(id); + return Result.OK; + } if (looksLikeMissingSpaces(raw)) { - Result spaceResult = tryConfirmOrBlock( - player, op, raw, GateKind.MISSING_SPACES, chatConfirm, Optional.empty()); - if (spaceResult != Result.OK) { - return spaceResult; - } + return blockWithHint(player, op, raw, GateKind.MISSING_SPACES, chatConfirm, Optional.empty()); } Optional<String> uncapitalized = findUncapitalizedWord(raw); if (uncapitalized.isPresent()) { - Result capResult = tryConfirmOrBlock( - player, op, raw, GateKind.CAPITALIZATION, chatConfirm, uncapitalized); - if (capResult != Result.OK) { - return capResult; - } + return blockWithHint(player, op, raw, GateKind.CAPITALIZATION, chatConfirm, uncapitalized); } pending.remove(player.getUniqueId()); return Result.OK; } - private static Result tryConfirmOrBlock( + private static Result blockWithHint( Player player, NameOperation op, String raw, @@ -161,12 +157,7 @@ private static Result tryConfirmOrBlock( boolean chatConfirm, Optional<String> offendingWord) { UUID id = player.getUniqueId(); - Pending prev = pending.get(id); - if (prev != null && prev.op == op && prev.raw.equals(raw) && prev.kind == kind) { - pending.remove(id); - return Result.OK; - } - Pending next = new Pending(op, raw, kind); + Pending next = new Pending(op, raw); pending.put(id, next); if (kind == GateKind.MISSING_SPACES) { sendSpaceHint(player, raw, chatConfirm); diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/EconomicImpactService.java b/src/main/java/net/tfminecraft/simplefactions/utils/EconomicImpactService.java index 7a44f680..6ded8458 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/EconomicImpactService.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/EconomicImpactService.java @@ -129,10 +129,8 @@ private static void publish(Player player, long token, Pending pending, Map<Guil if (located == null) { return; } + // find() validated this item's metadata on the same server-thread turn. ItemMeta meta = located.item.getItemMeta(); - if (meta == null) { - return; - } applyImpact(meta, pending, deltas); located.item.setItemMeta(meta); if (located.inventory != null) { @@ -199,9 +197,6 @@ private static Located find(Player player, long token) { } private static Located find(Inventory inventory, long token) { - if (inventory == null) { - return null; - } for (int slot = 0; slot < inventory.getSize(); slot++) { ItemStack item = inventory.getItem(slot); if (matches(item, token)) { diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/FactionCleanup.java b/src/main/java/net/tfminecraft/simplefactions/utils/FactionCleanup.java index 227ba032..3a81f757 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/FactionCleanup.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/FactionCleanup.java @@ -83,7 +83,7 @@ public static int daysOffline(String username) { public static void ping(String username) { if (username == null || username.isBlank()) return; try { - if (!ensureLoaded()) offlineDays = new HashMap<>(); + if (!ensureLoaded()) return; offlineDays.put(username.toLowerCase(Locale.ROOT), 0); saveOfflineDays(offlineDays); } catch (IOException e) { diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/LoreWriter.java b/src/main/java/net/tfminecraft/simplefactions/utils/LoreWriter.java index 4a478b89..383a8786 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/LoreWriter.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/LoreWriter.java @@ -53,28 +53,25 @@ public static void applyProposalLore(Proposal proposal, List<String> lore, Playe String type = ""; if(target == TaxTarget.GUILD_ID) { Guild guild = FactionManager.getGuildByString(taxChange.getId()); + if (guild == null) { + unavailableTaxTarget(lore, taxChange.getId()); + return; + } name = guild.getName(); type = guild.getType().getName(); - } else if(target == TaxTarget.VASSAL_ID) { - name = FactionManager.getByString(taxChange.getId()).getName(); - type = "#4269a8Vassal"; - } else if(target == TaxTarget.TARIFF_ID) { - name = FactionManager.getByString(taxChange.getId()).getName(); - type = "#79bf6dTariff"; - } else{ - name = target.getDisplayName(); - } - String oldRate = ""; - if(target == TaxTarget.GUILD_ID || target == TaxTarget.VASSAL_ID || target == TaxTarget.TARIFF_ID) { - double rate = f.getTaxRate(target, taxChange.getId(), false); - if(rate == -1.0) { - oldRate = String.valueOf(f.getTaxRate(target, null, false)); - } else { - oldRate = String.valueOf(rate); + } else if(target == TaxTarget.VASSAL_ID || target == TaxTarget.TARIFF_ID) { + Faction targetFaction = FactionManager.getByString(taxChange.getId()); + if (targetFaction == null) { + unavailableTaxTarget(lore, taxChange.getId()); + return; } + name = targetFaction.getName(); + type = target == TaxTarget.VASSAL_ID ? "#4269a8Vassal" : "#79bf6dTariff"; } else { - oldRate = String.valueOf(f.getTaxRate(target, null, false)); + name = target.getDisplayName(); } + // getTaxRate already falls back to the general rate when no override exists. + String oldRate = String.valueOf(f.getTaxRate(target, taxChange.getId(), false)); double baseRate = f.getTaxRate(target, null, false); lore.add(StringFormatter.formatHex("#b8ae61Target: #c2bea7"+name+ (type.isEmpty() ? "" : " §7("+type+"§7)"))); @@ -124,6 +121,10 @@ public static void applyProposalLore(Proposal proposal, List<String> lore, Playe } } + private static void unavailableTaxTarget(List<String> lore, String id) { + lore.add(StringFormatter.formatHex("#d65c5cTarget " + id + " is no longer available")); + } + public static void writeEffect(Scope scope, LawEffect effect, List<String> lore) { boolean factionScope = scope == Scope.FACTION; // Scope header diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/OpinionColourMapper.java b/src/main/java/net/tfminecraft/simplefactions/utils/OpinionColourMapper.java index 11197b4c..8dfc42c6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/OpinionColourMapper.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/OpinionColourMapper.java @@ -1,6 +1,8 @@ package net.tfminecraft.simplefactions.utils; public class OpinionColourMapper { + private OpinionColourMapper() {} + public static String getOpinionColor(int opinion) { // Clamp the opinion between -80 and +80 diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/Permissions.java b/src/main/java/net/tfminecraft/simplefactions/utils/Permissions.java index 51311ef5..19c31750 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/Permissions.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/Permissions.java @@ -3,6 +3,8 @@ import org.bukkit.command.CommandSender; public class Permissions { + private Permissions() {} + public static String Permission_Admin; static { diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/PostSettlementPayouts.java b/src/main/java/net/tfminecraft/simplefactions/utils/PostSettlementPayouts.java index 5bf450f4..64cb8706 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/PostSettlementPayouts.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/PostSettlementPayouts.java @@ -53,8 +53,9 @@ private static void settleGuild( double pool = buffer.getPendingDividendPools().getOrDefault(guild, 0.0); Map<UUID, Double> payouts = buffer.getPlayerPayouts().getOrDefault(guild, Map.of()); double wages = 0.0; - for (double amount : payouts.values()) { - if (amount > 0) { + for (Map.Entry<UUID, Double> entry : payouts.entrySet()) { + Double amount = entry.getValue(); + if (entry.getKey() != null && amount != null && Double.isFinite(amount) && amount > 0) { wages += amount; } } @@ -103,8 +104,8 @@ private static void payDividends( if (uuid == null) { continue; } - if (playerBank != null) { - playerBank.depositToBank(uuid, perMember); + if (playerBank == null || !playerBank.depositToBank(uuid, perMember)) { + continue; } if (economy != null) { economy.getLedger(uuid).add(PlayerCashflow.DIVIDEND_PAYOUT, perMember); @@ -133,14 +134,16 @@ private static void payPlayerPayouts( } double withdrawn = 0.0; for (Map.Entry<UUID, Double> entry : payouts.entrySet()) { - if (entry.getKey() == null || entry.getValue() == null || entry.getValue() <= 0) { + if (entry.getKey() == null || entry.getValue() == null || !Double.isFinite(entry.getValue()) || entry.getValue() <= 0) { continue; } double amount = Formatter.formatDouble(entry.getValue() * scale); if (amount <= 0) { continue; } - playerBank.depositToBank(entry.getKey(), amount); + if (!playerBank.depositToBank(entry.getKey(), amount)) { + continue; + } // Wages are deliberately not taxed as citizen income for now; the seam is // the PlayerCashflow entry, so taxing them later means reading this one line. if (economy != null) { diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java b/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java index bce48d98..2ef4be2b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java @@ -661,7 +661,7 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le List<String> completions = new ArrayList<>(); completions.add("1"); return completions; - } else if (args.length == 2 && args[0].equalsIgnoreCase("forcejoin")) { + } else if (cmd.getName().equalsIgnoreCase("faction") && args.length == 2 && args[0].equalsIgnoreCase("forcejoin")) { List<String> completions = new ArrayList<String>(); // Suggest faction names for (Faction f : FactionManager.factions) { @@ -671,7 +671,7 @@ else if(cmd.getName().equalsIgnoreCase("faction") && args.length >= 0 && args.le } return completions; } - else if (args.length == 3 && args[0].equalsIgnoreCase("forcejoin")) { + else if (cmd.getName().equalsIgnoreCase("faction") && args.length == 3 && args[0].equalsIgnoreCase("forcejoin")) { List<String> completions = new ArrayList<String>(); // Suggest players who are valid to be forcejoined Faction f = FactionManager.getByString(args[1]); @@ -728,20 +728,6 @@ else if (args.length == 3 && args[0].equalsIgnoreCase("forcejoin")) { completions.add(f.getId()); } - return completions; - } - } else if(cmd.getName().equalsIgnoreCase("faction") && args.length == 2 && args[0].equalsIgnoreCase("addwealthmodifier")){ - if(sender instanceof Player){ - List<String> completions = new ArrayList<String>(); - completions.add("<type>"); - - return completions; - } - } else if(cmd.getName().equalsIgnoreCase("faction") && args.length == 3 && args[0].equalsIgnoreCase("addwealthmodifier")){ - if(sender instanceof Player){ - List<String> completions = new ArrayList<String>(); - completions.add("<amount>"); - return completions; } } else if(cmd.getName().equalsIgnoreCase("faction") && args.length == 2 && args[0].equalsIgnoreCase("destroytitle")){ diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattlePlacementValidator.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattlePlacementValidator.java index 1ae5cfe3..5ea585fc 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattlePlacementValidator.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattlePlacementValidator.java @@ -37,7 +37,8 @@ public static boolean canResolveBounds(Battle battle) { } public static int provinceAt(Location location) { - if (location == null || location.getWorld() == null) { + if (location == null || location.getWorld() == null + || !location.getWorld().getName().equals(net.tfminecraft.simplefactions.Cache.worldName)) { return -1; } SimpleFactions plugin = SimpleFactions.getInstance(); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/template/BattleLocation.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/template/BattleLocation.java index ddf6b007..5dfaa4e7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/template/BattleLocation.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/template/BattleLocation.java @@ -31,23 +31,19 @@ public static BattleLocation fromSection(ConfigurationSection section) { if (section == null) { return null; } + java.util.Map<String, Object> values = new java.util.HashMap<>(section.getValues(false)); String worldName = section.getString("world"); if (worldName == null || worldName.isBlank()) { - worldName = Cache.worldName; + values.put("world", Cache.worldName); } - if (!section.contains("x") || !section.contains("y") || !section.contains("z")) { - return null; - } - return new BattleLocation( - worldName, - section.getDouble("x"), - section.getDouble("y"), - section.getDouble("z"), - (float) section.getDouble("yaw", 0), - (float) section.getDouble("pitch", 0)); + return fromMap(values); } public Location toBukkitLocation() { + if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z) + || !Float.isFinite(yaw) || !Float.isFinite(pitch)) { + return null; + } if (world == null || world.isBlank()) { world = Cache.worldName; } @@ -85,13 +81,14 @@ public static BattleLocation fromMap(java.util.Map<?, ?> map) { String worldName = map.get("world") instanceof String world ? world : Cache.worldName; float yaw = map.get("yaw") instanceof Number yawNum ? yawNum.floatValue() : 0f; float pitch = map.get("pitch") instanceof Number pitchNum ? pitchNum.floatValue() : 0f; - return new BattleLocation( - worldName, - ((Number) xObj).doubleValue(), - ((Number) yObj).doubleValue(), - ((Number) zObj).doubleValue(), - yaw, - pitch); + double x = ((Number) xObj).doubleValue(); + double y = ((Number) yObj).doubleValue(); + double z = ((Number) zObj).doubleValue(); + if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z) + || !Float.isFinite(yaw) || !Float.isFinite(pitch)) { + return null; + } + return new BattleLocation(worldName, x, y, z, yaw, pitch); } public String getWorld() { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/ObjectiveProvincePicker.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/ObjectiveProvincePicker.java index 6ff40a9a..3aee62c7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/ObjectiveProvincePicker.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/ObjectiveProvincePicker.java @@ -74,7 +74,7 @@ private OptionalInt pickForTransferSubject(War war) { } private OptionalInt pickForPillage(War war) { - Settlement settlement = PillageEligibility.findSettlement(war.getTargetSettlementId()); + Settlement settlement = PillageEligibility.findSettlement(war.getTargetSettlementId(), war.getDefenders().getLeader()); if (settlement == null || settlement.getCenterProvince() <= 0) { return OptionalInt.empty(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/declare/PillageEligibility.java b/src/main/java/net/tfminecraft/simplefactions/war/declare/PillageEligibility.java index 91fb3166..ded24159 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/declare/PillageEligibility.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/declare/PillageEligibility.java @@ -36,22 +36,23 @@ public static List<PillageSettlementOption> options( } Set<Integer> realm = realmProvinces(defender); Map<String, Settlement> byId = new LinkedHashMap<>(); + Set<String> ambiguousIds = new HashSet<>(); for (Faction faction : FactionManager.factions) { - if (faction == null || faction.getSettlementHandler() == null) { + if (faction == null) { continue; } for (Settlement settlement : faction.getSettlementHandler().getAll()) { - if (settlement == null || settlement.getId() == null) { - continue; - } if (!realm.contains(settlement.getCenterProvince())) { continue; } - byId.putIfAbsent(settlement.getId(), settlement); + String id = settlement.getId(); + if (byId.putIfAbsent(id, settlement) != null) ambiguousIds.add(id); } } for (Settlement settlement : byId.values()) { - options.add(evaluate(provinceManager, attacker, defender, settlement, realm)); + options.add(ambiguousIds.contains(settlement.getId()) + ? blocked(settlement, "§cMore than one settlement in their realm uses this id.") + : evaluate(provinceManager, attacker, defender, settlement, realm)); } return options; } @@ -61,19 +62,30 @@ public static PillageSettlementOption evaluate(Faction attacker, Faction defende } public static Settlement findSettlement(String settlementId) { + return findInRealm(settlementId, null); + } + + /** Settlement IDs are unique within a faction, so war targets must be resolved in the defender's realm. */ + public static Settlement findSettlement(String settlementId, Faction defender) { + return defender == null ? null : findInRealm(settlementId, realmProvinces(defender)); + } + + private static Settlement findInRealm(String settlementId, Set<Integer> realm) { if (settlementId == null || settlementId.isBlank()) { return null; } + Settlement match = null; for (Faction faction : FactionManager.factions) { - if (faction == null || faction.getSettlementHandler() == null) { + if (faction == null) { continue; } Settlement settlement = faction.getSettlementHandler().getById(settlementId); - if (settlement != null) { - return settlement; + if (settlement != null && (realm == null || realm.contains(settlement.getCenterProvince()))) { + if (match != null) return null; + match = settlement; } } - return null; + return match; } public static Faction landOwner(int provinceId) { @@ -110,10 +122,6 @@ static PillageSettlementOption evaluate( provinceManager, attacker, settlement, owner, realm, range)) { return new PillageSettlementOption(settlement, true, null); } - if (provinceManager != null - && PillageRangeQueries.inSeaRange(provinceManager, attacker, owner, center, range)) { - return blocked(settlement, "§cThat settlement is out of pillage range."); - } OptionalInt coast = provinceManager == null ? OptionalInt.empty() : PillageRangeQueries.distanceToCoast(provinceManager, center); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidator.java b/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidator.java index 37e81c16..4c6e6b9b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidator.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidator.java @@ -193,9 +193,9 @@ private WarValidationResult validatePillage(WarDeclareRequest request) { if (settlementId == null || settlementId.isBlank()) { return WarValidationResult.fail("§cSpecify a settlement to pillage."); } - Settlement settlement = PillageEligibility.findSettlement(settlementId); + Settlement settlement = PillageEligibility.findSettlement(settlementId, request.getDefender()); if (settlement == null) { - return WarValidationResult.fail("§cThat settlement does not exist."); + return WarValidationResult.fail("§cThat settlement does not exist or its id is ambiguous in their realm."); } PillageEligibility.PillageSettlementOption option = PillageEligibility.evaluate(request.getAttacker(), request.getDefender(), settlement); @@ -228,23 +228,11 @@ private WarValidationResult validateSubjugate(WarDeclareRequest request) { return WarValidationResult.fail("§cInvalid war target."); } - String attackerOverlord = RelationManager.getOverlord(attacker); - if (attackerOverlord != null && attackerOverlord.equalsIgnoreCase(defender.getId())) { - return WarValidationResult.fail("§cYou cannot subjugate your overlord."); - } - String overlord = RelationManager.getOverlord(defender); - if (overlord != null && overlord.equalsIgnoreCase(attacker.getId())) { - return WarValidationResult.fail("§cThat faction is already your subject."); - } if (overlord != null && !InterVassalQueries.isInternalPeer(attacker, defender)) { return WarValidationResult.fail("§cThat faction is already a subject of someone else."); } - if (RelationManager.isOnOverlordPath(attacker, defender)) { - return WarValidationResult.fail("§cThis relation would cause a loop."); - } - String relationTypeId = request.getRelationTypeId(); if (relationTypeId == null || relationTypeId.isBlank()) { return WarValidationResult.fail("§cSpecify a subject type."); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/pathfinder/BelligerentTerritory.java b/src/main/java/net/tfminecraft/simplefactions/war/pathfinder/BelligerentTerritory.java index 48cfaa98..b7b9e7df 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/pathfinder/BelligerentTerritory.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/pathfinder/BelligerentTerritory.java @@ -82,9 +82,7 @@ public static BelligerentTerritory fromWar(War war, ProvinceOwnerLookup owners) private static Set<String> collectSideFactionIds(Side side) { Set<String> ids = new HashSet<>(); - if (side == null) { - return ids; - } + ids.add(normalizeId(side.getLeader().getId())); for (Participant participant : side.getMainParticipants()) { addParticipantIds(ids, participant); } @@ -93,9 +91,6 @@ private static Set<String> collectSideFactionIds(Side side) { private static Set<String> collectLeaderParticipantIds(Side side) { Set<String> ids = new HashSet<>(); - if (side == null || side.getLeader() == null) { - return ids; - } String leaderId = normalizeId(side.getLeader().getId()); Participant leaderParticipant = null; for (Participant participant : side.getMainParticipants()) { @@ -105,9 +100,8 @@ private static Set<String> collectLeaderParticipantIds(Side side) { break; } } - if (leaderParticipant == null && !side.getMainParticipants().isEmpty()) { - leaderParticipant = side.getMainParticipants().get(0); - } + // A missing saved participant row cannot make an ally the campaign objective. + ids.add(leaderId); addParticipantIds(ids, leaderParticipant); return ids; } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/resolution/PillageApplyService.java b/src/main/java/net/tfminecraft/simplefactions/war/resolution/PillageApplyService.java index 1aa049d0..de08a795 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/resolution/PillageApplyService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/resolution/PillageApplyService.java @@ -22,7 +22,7 @@ public static void apply(War war) { if (war == null || war.getTargetSettlementId() == null || war.getTargetSettlementId().isBlank()) { return; } - Settlement settlement = PillageEligibility.findSettlement(war.getTargetSettlementId()); + Settlement settlement = PillageEligibility.findSettlement(war.getTargetSettlementId(), war.getDefenders().getLeader()); if (settlement == null) { return; } diff --git a/src/test/java/com/ticxo/modelengine/api/model/ActiveModel.java b/src/test/java/com/ticxo/modelengine/api/model/ActiveModel.java new file mode 100644 index 00000000..702d7455 --- /dev/null +++ b/src/test/java/com/ticxo/modelengine/api/model/ActiveModel.java @@ -0,0 +1,4 @@ +package com.ticxo.modelengine.api.model; + +/** Optional external API type token used only to mock VehicleFramework's boundary. */ +public interface ActiveModel {} diff --git a/src/test/java/net/tfminecraft/coreprotect/CoreProtectAPI.java b/src/test/java/net/tfminecraft/coreprotect/CoreProtectAPI.java new file mode 100644 index 00000000..18e2079c --- /dev/null +++ b/src/test/java/net/tfminecraft/coreprotect/CoreProtectAPI.java @@ -0,0 +1,7 @@ +package net.tfminecraft.coreprotect; + +/** + * Test-only type token for VehicleFramework's optional CoreProtect getter signature. + * Tests do not call this API; the server provides the real integration at runtime. + */ +public class CoreProtectAPI {} diff --git a/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java new file mode 100644 index 00000000..df5c49c5 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java @@ -0,0 +1,1278 @@ +package net.tfminecraft.simplefactions; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import io.papermc.paper.plugin.configuration.PluginMeta; +import io.papermc.paper.plugin.provider.classloader.ConfiguredPluginClassLoader; +import io.papermc.paper.plugin.provider.classloader.PluginClassLoaderGroup; +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.logging.Logger; +import net.tfminecraft.simplefactions.database.BattleData; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.database.WarbandData; +import net.tfminecraft.simplefactions.diplomacy.Relation; +import net.tfminecraft.simplefactions.espionage.EspionageService; +import net.tfminecraft.simplefactions.espionage.SpecialPositionsConfigFile; +import net.tfminecraft.simplefactions.guild.hub.VehicleFrameworkTrackProvinces; +import net.tfminecraft.simplefactions.guild.network.TradeGraph; +import net.tfminecraft.simplefactions.identity.LeaderCharacterListener; +import net.tfminecraft.simplefactions.inactivity.InactivityService; +import net.tfminecraft.simplefactions.integration.rpcharacters.chat.RpCharactersChatIntegration; +import net.tfminecraft.simplefactions.loaders.BattleTemplateLoader; +import net.tfminecraft.simplefactions.loaders.BranchLoader; +import net.tfminecraft.simplefactions.loaders.CompanyUpgradeLoader; +import net.tfminecraft.simplefactions.loaders.ConfigLoader; +import net.tfminecraft.simplefactions.loaders.GuildLoader; +import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader; +import net.tfminecraft.simplefactions.loaders.ProvinceLoader; +import net.tfminecraft.simplefactions.loaders.RankLoader; +import net.tfminecraft.simplefactions.loaders.RegimentLoader; +import net.tfminecraft.simplefactions.loaders.RegionLoader; +import net.tfminecraft.simplefactions.loaders.RelationLoader; +import net.tfminecraft.simplefactions.loaders.TierLoader; +import net.tfminecraft.simplefactions.loaders.TitleLoader; +import net.tfminecraft.simplefactions.loaders.UpgradeLoader; +import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader; +import net.tfminecraft.simplefactions.managers.CommandManager; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.managers.LedgerCommandManager; +import net.tfminecraft.simplefactions.managers.ProvinceManager; +import net.tfminecraft.simplefactions.managers.RequestManager; +import net.tfminecraft.simplefactions.managers.SessionManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.map.ProvinceGrid; +import net.tfminecraft.simplefactions.map.infra.TrackProvinceCache; +import net.tfminecraft.simplefactions.map.presence.ProvincePresenceService; +import net.tfminecraft.simplefactions.map.presence.ProvincePresenceTickService; +import net.tfminecraft.simplefactions.mercenary.stat.MercenaryStatService; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.PersistenceFilesFixture; +import net.tfminecraft.simplefactions.vehicles.VehicleIntegrationListener; +import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeStore; +import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenancePersistence; +import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenanceStore; +import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; +import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; +import net.tfminecraft.simplefactions.vehicles.registry.VehicleRegistryPersistence; +import net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager; +import net.tfminecraft.simplefactions.war.battle.persistence.BattlePersistenceService; +import net.tfminecraft.simplefactions.war.battle.template.BattleTemplate; +import net.tfminecraft.simplefactions.war.battle.template.BattleTemplateService; +import net.tfminecraft.simplefactions.war.battle.ui.BattleCommandManager; +import net.tfminecraft.simplefactions.war.battle.warband.WarbandManager; +import net.tfminecraft.simplefactions.war.campaign.raid.RaidCommandManager; +import net.tfminecraft.simplefactions.war.campaign.raid.intruder.CampaignRaidIntruderService; +import net.tfminecraft.simplefactions.war.campaign.runtime.BattleScheduleTickService; +import net.tfminecraft.simplefactions.war.campaign.ui.CampaignViewRefreshService; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.freeze.VfBuildersConstructionFreeze; +import net.tfminecraft.vehicleframework.data.OwnedVehicleSummary; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.command.PluginCommand; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.event.Listener; +import org.bukkit.event.server.PluginEnableEvent; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginDescriptionFile; +import org.bukkit.plugin.PluginLoader; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +class PluginLifecycleCoverageTest { + private FactionDomainFixture domain; + private PersistenceFilesFixture disk; + private SimpleFactions plugin; + private PluginManager plugins; + private Logger logger; + private FileConfiguration oldConfig; + private boolean oldMapEnabled; + private final Map<Class<?>, MockedStatic<?>> statics = new LinkedHashMap<>(); + private final Map<String, Object> fields = new LinkedHashMap<>(); + private final Map<String, PluginCommand> commands = new LinkedHashMap<>(); + private final List<Listener> listeners = new ArrayList<>(); + + @SuppressWarnings("unchecked") + private <T> MockedStatic<T> scoped(Class<T> type) { + return (MockedStatic<T>) statics.computeIfAbsent(type, key -> mockStatic(type)); + } + + private void field(String name, Object value) throws Exception { + Field field = SimpleFactions.class.getDeclaredField(name); + field.setAccessible(true); + field.set(plugin, value); + fields.put(name, value); + } + + private <T> T dependency(String name, Class<T> type) { + return type.cast(fields.get(name)); + } + + @BeforeEach + void setup() throws Exception { + disk = new PersistenceFilesFixture(); + domain = new FactionDomainFixture(); + oldConfig = SimpleFactions.config; + oldMapEnabled = Cache.mapEnabled; + Cache.mapEnabled = false; + plugin = mock(SimpleFactions.class, CALLS_REAL_METHODS); + plugins = mock(PluginManager.class); + logger = mock(Logger.class); + doReturn(disk.root.toFile()).when(plugin).getDataFolder(); + doReturn(domain.ui.server).when(plugin).getServer(); + doReturn(logger).when(plugin).getLogger(); + doReturn("SimpleFactions").when(plugin).getName(); + doReturn("simplefactions").when(plugin).namespace(); + doReturn(new YamlConfiguration()).when(plugin).getConfig(); + doAnswer( + call -> + commands.computeIfAbsent(call.getArgument(0), name -> mock(PluginCommand.class))) + .when(plugin) + .getCommand(anyString()); + when(domain.ui.server.getPluginManager()).thenReturn(plugins); + doAnswer( + call -> { + listeners.add(call.getArgument(0)); + return null; + }) + .when(plugins) + .registerEvents(any(), eq(plugin)); + doAnswer( + call -> { + String name = call.getArgument(0); + Path destination = disk.root.resolve(name); + try (var input = SimpleFactions.class.getClassLoader().getResourceAsStream(name)) { + assertNotNull(input, "Bundled config " + name); + Files.copy(input, destination); + } + return null; + }) + .when(plugin) + .saveResource(anyString(), eq(false)); + // Substitute lifecycle boundaries, keeping the plugin's orchestration and callbacks real. + for (Field field : SimpleFactions.class.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.getType().isPrimitive()) continue; + if (Set.of( + "provinceGrid", + "trackRefreshTask", + "vehicleRegistryPersistence", + "vehicleMaintenancePersistence") + .contains(field.getName())) continue; + field(field.getName(), mock(field.getType())); + } + field("commands", new CommandManager()); + field( + "ledgerCommandManager", + new LedgerCommandManager(dependency("inventoryManager", InventoryManager.class))); + field("battleCommandManager", new BattleCommandManager()); + field("raidCommandManager", new RaidCommandManager()); + field("vehicleRegistry", new PlayerVehicleRegistry()); + field("vehicleFeeStore", new VehicleFeeStore()); + field("vehicleMaintenanceStore", new VehicleMaintenanceStore()); + for (Class<?> type : + List.of( + InactivityService.class, + EspionageService.class, + SpecialPositionsConfigFile.class, + VehiclesConfigLoader.class, + InstallationConfigLoader.class, + RegionLoader.class, + FactionManager.class, + WarManager.class, + RequestManager.class, + ProvincePresenceTickService.class, + CampaignRaidIntruderService.Tick.class, + BattlePersistenceService.class, + BattleScheduleTickService.class, + CampaignViewRefreshService.class, + BattleManager.class, + MercenaryStatService.class, + TradeGraph.class, + RpCharactersChatIntegration.class, + VfBuildersConstructionFreeze.class, + LeaderCharacterListener.class)) scoped(type); + SimpleFactions.plugin = plugin; + } + + @AfterEach + void close() throws Exception { + try { + List<MockedStatic<?>> values = new ArrayList<>(statics.values()); + for (int i = values.size() - 1; i >= 0; i--) values.get(i).close(); + SimpleFactions.config = oldConfig; + Cache.mapEnabled = oldMapEnabled; + domain.close(); + } finally { + disk.close(); + } + } + + @Test + void startupRegistersGameplayAndShutdownSavesLoadedState() throws Exception { + plugin.onEnable(); + assertSame(plugin, SimpleFactions.getInstance()); + assertSame(plugin.getConfig(), SimpleFactions.config); + for (String name : + List.of( + "faction", + "guild", + "ledger", + "company", + "mercenaries", + "warband", + "battle", + "raid", + "war", + "movement")) { + assertTrue( + commands.containsKey(name), + "Registered command " + name + "; actual " + commands.keySet()); + } + verify(commands.get("battle")) + .setExecutor(dependency("battleCommandManager", BattleCommandManager.class)); + verify(dependency("factionManager", FactionManager.class)).run(); + verify(dependency("inventoryManager", InventoryManager.class)).start(); + verify(dependency("sessionManager", SessionManager.class)).start(); + verify(dependency("battleManager", BattleManager.class)).start(); + assertTrue(listeners.contains(dependency("inventoryManager", InventoryManager.class))); + assertTrue(Files.isRegularFile(disk.root.resolve("config.yml"))); + assertTrue(Files.isRegularFile(disk.root.resolve("Guilds/guild-types.yml"))); + plugin.createFolders(); + plugin.createConfigs(); + scoped(FactionManager.class).when(FactionManager::isLoaded).thenReturn(true); + scoped(FactionManager.class).when(FactionManager::getTimer).thenReturn(100); + scoped(FactionManager.class).when(FactionManager::getDay).thenReturn(4); + Faction faction = mock(Faction.class); + FactionManager.factions.add(faction); + War war = mock(War.class); + scoped(WarManager.class).when(WarManager::get).thenReturn(List.of(war)); + SimpleFactions.getVehicleRegistry() + .register( + new PlayerVehicleRecord( + UUID.randomUUID(), "cart-1", "cart", OwnershipMode.PERSONAL, "dock")); + plugin.onDisable(); + Database db = dependency("db", Database.class); + verify(db).saveTimer(100, 4); + verify(db).saveFaction(faction); + verify(db).saveWar(war); + verify(dependency("sessionManager", SessionManager.class)).end(); + assertTrue(Files.isRegularFile(disk.root.resolve("Cache/vehicles_registry.json"))); + scoped(BattlePersistenceService.class).verify(BattlePersistenceService::saveAll); + scoped(BattlePersistenceService.class).verify(BattlePersistenceService::stopAutosave); + scoped(BattleManager.class).verify(BattleManager::shutdown); + scoped(CampaignViewRefreshService.class).verify(CampaignViewRefreshService::stop); + } + + private Map<Path, String> unloadedSavedState() throws Exception { + var faction = domain.data("prior", "PriorLeader"); + faction.titles = List.of("prior_county"); + faction.military = List.of("guard.12.3"); + BattleData battle = new BattleData(); + battle.id = "prior_battle"; + battle.battleType = "field"; + WarbandData warband = new WarbandData(); + warband.id = "prior_warband"; + warband.leaderId = UUID.randomUUID().toString(); + warband.memberIds.add(warband.leaderId); + Map<Path, String> saved = new LinkedHashMap<>(); + for (var entry : + Map.of( + "Data/prior.json", JsonUtil.GSON.toJson(faction), + "Battles/battle_prior_battle.json", JsonUtil.GSON.toJson(battle), + "Warbands/warband_prior_warband.json", JsonUtil.GSON.toJson(warband), + "Cache/data.json", "{\"time\":450,\"day\":12}") + .entrySet()) { + saved.put(disk.write(entry.getKey(), entry.getValue()), entry.getValue()); + } + return saved; + } + + private void assertSavedStateUnchanged(Map<Path, String> saved) { + assertAll( + saved.entrySet().stream() + .map( + entry -> + () -> { + assertTrue( + Files.isRegularFile(entry.getKey()), + "Startup failure removed " + entry.getKey()); + assertEquals( + entry.getValue(), + Files.readString(entry.getKey()), + "Startup failure rewrote " + entry.getKey()); + })); + } + + @ParameterizedTest + @ValueSource(strings = {"titles", "regiments"}) + void malformedInitialDefinitionsAbortBeforeGameplayAndPreserveSavedState(String catalog) + throws Exception { + Map<Path, String> saved = unloadedSavedState(); + if (catalog.equals("titles")) { + disk.write("Input/county.json", "{ malformed"); + } else { + field("regimentLoader", new RegimentLoader()); + disk.write("regiments.yml", "guard: [unterminated\n"); + } + scoped(BattlePersistenceService.class) + .when(BattlePersistenceService::saveAll) + .thenCallRealMethod(); + try (var bands = mockStatic(WarbandManager.class)) { + assertDoesNotThrow(plugin::onEnable); + assertDoesNotThrow(plugin::onDisable); + assertAll( + () -> verify(plugins).disablePlugin(plugin), + () -> + assertTrue( + listeners.isEmpty(), "Gameplay listeners must wait for valid configuration"), + () -> verify(dependency("db", Database.class), never()).loadFactions(), + () -> verify(dependency("factionManager", FactionManager.class), never()).run(), + () -> verify(dependency("inventoryManager", InventoryManager.class), never()).start(), + () -> + scoped(BattlePersistenceService.class) + .verify(BattlePersistenceService::saveAll, never()), + () -> scoped(InactivityService.class).verify(InactivityService::save, never()), + () -> assertSavedStateUnchanged(saved)); + } + assertTrue(TitleLoader.getTitles().isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = {"aptitudes", "provinces", "grid"}) + void failedStartupDisablesThePluginBeforeStartingRuntimeLoops(String step) throws Exception { + Cache.provincesEnabled = true; + Map<Path, String> saved = unloadedSavedState(); + scoped(BattlePersistenceService.class) + .when(BattlePersistenceService::saveAll) + .thenCallRealMethod(); + try (var grids = mockStatic(ProvinceGrid.class); + var bands = mockStatic(WarbandManager.class)) { + if (step.equals("aptitudes")) + scoped(EspionageService.class) + .when(() -> EspionageService.loadAptitudes(any())) + .thenThrow(new IllegalStateException("bad aptitude file")); + if (step.equals("provinces")) + when(dependency("provinceLoader", ProvinceLoader.class).loadProvinces(any(), any())) + .thenThrow(new IllegalStateException("bad province input")); + if (step.equals("grid")) + grids.when(() -> ProvinceGrid.load(any())).thenThrow(new IllegalStateException("bad grid")); + plugin.onEnable(); + verify(plugins).disablePlugin(plugin); + verify(dependency("factionManager", FactionManager.class), never()).run(); + verify(dependency("inventoryManager", InventoryManager.class), never()).start(); + plugin.onDisable(); + assertSavedStateUnchanged(saved); + verify(dependency("db", Database.class), never()).saveTimer(anyInt(), anyInt()); + verify(dependency("db", Database.class), never()).saveFaction(any()); + verify(dependency("db", Database.class), never()).saveWar(any()); + scoped(BattlePersistenceService.class).verify(BattlePersistenceService::saveAll, never()); + scoped(InactivityService.class).verify(InactivityService::save, never()); + scoped(BattlePersistenceService.class).verify(BattlePersistenceService::stopAutosave); + verify(dependency("sessionManager", SessionManager.class)).end(); + } + } + + @Test + void earlyStartupFailureCannotPruneFilesThatHaveNotBeenLoaded() throws Exception { + Map<Path, String> saved = unloadedSavedState(); + statics.remove(BattlePersistenceService.class).close(); + scoped(EspionageService.class) + .when(() -> EspionageService.loadAptitudes(any())) + .thenThrow(new IllegalStateException("bad aptitude file")); + try (var bands = mockStatic(WarbandManager.class)) { + plugin.onEnable(); + verify(plugins).disablePlugin(plugin); + plugin.onDisable(); + assertSavedStateUnchanged(saved); + } + } + + @Test + void failedTitleReloadRetainsTheLiveDefinitionAndKeepsTheRunningPluginEnabled() throws Exception { + Path title = + disk.write( + "Input/county.json", + "{\"prior_county\":{\"name\":\"Prior County\",\"provinces\":[3]}}"); + plugin.onEnable(); + var original = TitleLoader.getById("prior_county"); + assertNotNull(original); + Files.writeString(title, "{ malformed"); + var admin = domain.player("Admin"); + when(admin.hasPermission("simplefactions.admin")).thenReturn(true); + PluginCommand command = commands.get("faction"); + when(command.getName()).thenReturn("faction"); + Cache.provincesEnabled = true; + + assertTrue( + assertDoesNotThrow( + () -> + dependency("commands", CommandManager.class) + .onCommand(admin, command, "faction", new String[] {"reloadtitles"}))); + + assertSame(original, TitleLoader.getById("prior_county")); + assertEquals("{ malformed", Files.readString(title)); + verify(admin).sendMessage(contains("Could not reload titles")); + verify(admin, never()).sendMessage("§eReloaded titles!"); + verify(plugins, never()).disablePlugin(plugin); + plugin.onDisable(); + scoped(BattlePersistenceService.class).verify(BattlePersistenceService::saveAll); + } + + @Test + void failedConfigurationReloadPreservesRegimentsWithoutDisablingNormalShutdownSaving() + throws Exception { + field("regimentLoader", new RegimentLoader()); + Path regiments = + disk.write("regiments.yml", "guard:\n default-slots: 3\n item:\n material: PAPER\n"); + plugin.onEnable(); + var original = RegimentLoader.getByString("guard"); + assertNotNull(original); + Files.writeString(regiments, "guard: [unterminated\n"); + var admin = domain.player("Admin"); + when(admin.hasPermission("simplefactions.admin")).thenReturn(true); + PluginCommand command = commands.get("faction"); + when(command.getName()).thenReturn("faction"); + + assertTrue( + assertDoesNotThrow( + () -> + dependency("commands", CommandManager.class) + .onCommand(admin, command, "faction", new String[] {"reloadconfigs"}))); + + assertSame(original, RegimentLoader.getByString("guard")); + assertEquals(3, original.getCurrentSlots()); + verify(admin).sendMessage(contains("Could not reload configs")); + verify(admin, never()).sendMessage("§eReloaded configs!"); + verify(plugins, never()).disablePlugin(plugin); + plugin.onDisable(); + scoped(BattlePersistenceService.class).verify(BattlePersistenceService::saveAll); + } + + @ParameterizedTest + @ValueSource(strings = {"battle-templates.yml", "Guilds/guild-types.yml", "Input/county.json"}) + void laterCatalogFailureRestoresCanonicalRegistriesAndSuccessfulRetryRebinds(String rejectedFile) + throws Exception { + Map<String, BattleTemplate> originalTemplates = BattleTemplateLoader.getAll(); + try { + field("rankLoader", new RankLoader()); + field("relationLoader", new RelationLoader()); + field("tierLoader", new TierLoader()); + field("battleTemplateLoader", new BattleTemplateLoader()); + field("guildLoader", new GuildLoader()); + statics.remove(FactionManager.class).close(); + statics.put(FactionManager.class, mockStatic(FactionManager.class, CALLS_REAL_METHODS)); + writeIdentityCatalogs(false); + plugin.loadConfigs(); + var factionData = domain.data("realm", "Alice"); + factionData.titles = List.of("county_owned"); + Faction faction = domain.saved(factionData); + Faction neighbor = domain.saved("neighbor", "Bob"); + Relation relation = + new Relation(RelationLoader.getType("ally"), RelationLoader.getAttitude("friendly"), 9); + faction.getRelations().put(neighbor.getId(), relation); + faction + .getDiplomacyHandler() + .getTradeRelations() + .put(neighbor.getId(), RelationLoader.getType("trade")); + faction + .getDiplomacyHandler() + .getTreatyRelations() + .put(neighbor.getId(), RelationLoader.getType("treaty")); + List<List<?>> registries = identityRegistries(); + List<List<?>> entries = new ArrayList<>(); + for (List<?> registry : registries) entries.add(new ArrayList<>(registry)); + var originalRank = faction.getRank(); + var originalType = relation.getType(); + var originalAttitude = relation.getAttitude(); + var originalTitle = faction.getTitles().getFirst(); + var originalTier = originalTitle.getTier(); + var originalTierTitles = new ArrayList<>(TitleLoader.getByTier(originalTier)); + assertEquals(2, originalTierTitles.size()); + writeIdentityCatalogs(true); + Path broken = + disk.write( + rejectedFile, rejectedFile.endsWith("json") ? "{ malformed" : "bad: [unterminated\n"); + var emitted = new java.util.concurrent.atomic.AtomicReference<RuntimeException>(); + doAnswer( + call -> { + try { + return call.callRealMethod(); + } catch (RuntimeException failure) { + emitted.set(failure); + throw failure; + } + }) + .when(plugin) + .loadConfigs(); + + IllegalStateException failure = + assertThrows(IllegalStateException.class, SimpleFactions::reloadConfigs); + + assertSame(emitted.get(), failure, "The original loader exception must reach the caller"); + assertNotNull(failure.getCause()); + assertTrue(failure.getMessage().contains(broken.getFileName().toString())); + assertAll( + () -> assertRegistryIdentities(registries, entries), + () -> assertSame(originalRank, RankLoader.getByString("common")), + () -> assertSame(RankLoader.getByString("common"), faction.getRank()), + () -> assertSame(originalType, RelationLoader.getType("ally")), + () -> assertSame(RelationLoader.getType("ally"), relation.getType()), + () -> assertSame(originalAttitude, RelationLoader.getAttitude("friendly")), + () -> assertSame(RelationLoader.getAttitude("friendly"), relation.getAttitude()), + () -> assertSame(originalTitle, TitleLoader.getById("county_owned")), + () -> assertSame(TitleLoader.getById("county_owned"), faction.getTitles().getFirst()), + () -> assertSame(originalTier, TierLoader.getByString("county")), + () -> assertSame(TierLoader.getByString("county"), originalTitle.getTier()), + () -> + assertEquals( + originalTierTitles, TitleLoader.getByTier(TierLoader.getByString("county"))), + () -> + assertSame( + RelationLoader.getType("trade"), + faction.getDiplomacyHandler().getTradeRelation(neighbor.getId())), + () -> + assertSame( + RelationLoader.getType("treaty"), + faction.getDiplomacyHandler().getTreatyRelations().get(neighbor.getId())), + () -> assertEquals(9, relation.getOpinion())); + scoped(FactionManager.class).verify(FactionManager::rebindRanks, never()); + scoped(FactionManager.class).verify(FactionManager::rebindDiplomacy, never()); + scoped(FactionManager.class).verify(FactionManager::reloadTitles, never()); + scoped(FactionManager.class).verify(FactionManager::updateAllPrestigeConverged, never()); + + writeIdentityCatalogs(true); + assertDoesNotThrow(SimpleFactions::reloadConfigs); + + for (int i = 0; i < registries.size(); i++) + assertSame(registries.get(i), identityRegistries().get(i)); + assertNotSame(originalRank, faction.getRank()); + assertSame(RankLoader.getByString("common"), faction.getRank()); + assertEquals("Updated Common", faction.getRank().getName()); + assertNotSame(originalType, relation.getType()); + assertSame(RelationLoader.getType("ally"), relation.getType()); + assertEquals(25, relation.getType().getTarget()); + assertNotSame(originalAttitude, relation.getAttitude()); + assertSame(RelationLoader.getAttitude("friendly"), relation.getAttitude()); + assertEquals(35, relation.getAttitude().getTarget()); + assertNotSame(originalTitle, faction.getTitles().getFirst()); + assertSame(TitleLoader.getById("county_owned"), faction.getTitles().getFirst()); + assertNotSame(originalTier, faction.getTitles().getFirst().getTier()); + assertSame(TierLoader.getByString("county"), faction.getTitles().getFirst().getTier()); + assertEquals("Updated County", TierLoader.getByString("county").getName()); + assertEquals( + List.of("county_other", "county_owned"), + TitleLoader.getByTier(TierLoader.getByString("county")).stream() + .map(t -> t.getId()) + .toList()); + assertSame( + RelationLoader.getType("trade"), + faction.getDiplomacyHandler().getTradeRelation(neighbor.getId())); + assertSame( + RelationLoader.getType("treaty"), + faction.getDiplomacyHandler().getTreatyRelations().get(neighbor.getId())); + assertEquals(9, relation.getOpinion()); + scoped(FactionManager.class).verify(FactionManager::rebindRanks); + scoped(FactionManager.class).verify(FactionManager::rebindDiplomacy); + scoped(FactionManager.class).verify(FactionManager::reloadTitles); + scoped(FactionManager.class).verify(FactionManager::updateAllPrestigeConverged); + } finally { + BattleTemplateLoader.resetForTests(); + originalTemplates.values().forEach(BattleTemplateLoader::putForTests); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void guildConversionRetainsCapitalAfterConfigurationReload(boolean reloadSucceeds) + throws Exception { + field("guildLoader", new GuildLoader()); + statics.remove(FactionManager.class).close(); + statics.put(FactionManager.class, mockStatic(FactionManager.class, CALLS_REAL_METHODS)); + Path guildTypes = + disk.write("Guilds/guild-types.yml", "realm:\n base: true\nguild:\n default: true\n"); + plugin.loadConfigs(); + Faction ruler = domain.saved("ruler", "Alice"); + Faction source = domain.saved("subject", "Bob"); + for (int id : List.of(7, 8)) + domain.provinceData.put( + id, new net.tfminecraft.simplefactions.map.provinces.Province(id, "PLAINS", 1)); + ruler.addProvince(7); + ruler.setCapital(7); + source.addProvince(8); + source.setCapital(8); + domain.subject(ruler, source); + var mainGuild = source.getOrCreateMainGuild(); + mainGuild.addMember("Cara"); + source.getBank().deposit(20.0); + var originalBank = source.getBank(); + var originalGuildTypes = GuildLoader.get(); + var originalEntries = new LinkedHashMap<>(originalGuildTypes); + Files.writeString( + guildTypes, + "realm:\n base: true\n name: Updated Realm\nguild:\n default: true\nnew_type: {}\n"); + if (reloadSucceeds) { + assertDoesNotThrow(SimpleFactions::reloadConfigs); + assertNotSame(originalEntries.get("realm"), GuildLoader.getBaseType()); + assertEquals("Updated Realm", GuildLoader.getBaseType().getName()); + assertNotNull(GuildLoader.getByString("new_type")); + } else { + Path title = disk.write("Input/county.json", "{ malformed"); + IllegalStateException failure = + assertThrows(IllegalStateException.class, SimpleFactions::reloadConfigs); + assertTrue(failure.getMessage().contains("county.json")); + assertEquals("{ malformed", Files.readString(title)); + } + + try (var databases = mockConstruction(Database.class)) { + assertSame(ruler, source.dissolve(source.getVassals(), source.getGuildHandler().getGuilds())); + verify(databases.constructed().getFirst()).deleteFaction(source); + } + + assertAll( + () -> { + if (!reloadSucceeds) { + assertSame(originalGuildTypes, GuildLoader.get()); + assertEquals( + new ArrayList<>(originalEntries.keySet()), + new ArrayList<>(GuildLoader.get().keySet())); + originalEntries.forEach((id, type) -> assertSame(type, GuildLoader.getByString(id))); + } + }, + () -> assertSame(ruler, mainGuild.getFaction()), + () -> assertFalse(mainGuild.isBase()), + () -> + assertEquals(8, mainGuild.getCapital(), "Dissolution must retain the subject capital"), + () -> assertSame(originalBank, mainGuild.getBank()), + () -> assertEquals(20.0, mainGuild.getBank().getWealth()), + () -> assertEquals("Bob", mainGuild.getLeader()), + () -> assertTrue(mainGuild.getMembers().containsAll(List.of("Bob", "Cara"))), + () -> assertEquals(Set.of(7, 8), Set.copyOf(ruler.getProvinces())), + () -> assertFalse(FactionManager.factions.contains(source))); + } + + @ParameterizedTest + @ValueSource( + strings = { + "Guilds/branches.yml", + "Guilds/upgrades.yml", + "Guilds/company-upgrades.yml", + "Input/county.json" + }) + void rejectedReloadRetainsDependentGuildDefinitions(String rejectedFile) throws Exception { + var priorCompanyUpgrades = new LinkedHashMap<>(CompanyUpgradeLoader.get()); + try { + field("guildLoader", new GuildLoader()); + field("branchLoader", new BranchLoader()); + field("upgradeLoader", new UpgradeLoader()); + field("companyUpgradeLoader", new CompanyUpgradeLoader()); + statics.remove(FactionManager.class).close(); + statics.put(FactionManager.class, mockStatic(FactionManager.class, CALLS_REAL_METHODS)); + disk.write("Guilds/guild-types.yml", "realm:\n base: true\nguild:\n default: true\n"); + disk.write( + "Guilds/branches.yml", + """ + bureaucracy: + group: 0 + allowed-types: [realm] + guild_halls: + group: 0 + allowed-types: [guild] + workshops: + group: 1 + allowed-types: [realm, guild] + """); + disk.write("Guilds/upgrades.yml", "storehouse:\n allowed-types: [realm, guild]\n"); + disk.write("Guilds/company-upgrades.yml", "company_storehouse:\n allowed-types: [guild]\n"); + plugin.loadConfigs(); + List<Map<String, ?>> registries = + List.of( + GuildLoader.get(), + BranchLoader.get(), + UpgradeLoader.get(), + CompanyUpgradeLoader.get()); + List<Map<String, ?>> entries = new ArrayList<>(); + for (var registry : registries) entries.add(new LinkedHashMap<>(registry)); + Faction ruler = domain.saved("ruler", "Alice"); + Faction source = domain.saved("subject", "Bob"); + for (int id : List.of(7, 8)) + domain.provinceData.put( + id, new net.tfminecraft.simplefactions.map.provinces.Province(id, "PLAINS", 1)); + ruler.addProvince(7); + ruler.setCapital(7); + source.addProvince(8); + source.setCapital(8); + domain.subject(ruler, source); + var mainGuild = source.getOrCreateMainGuild(); + mainGuild.getBranches().get(0).levelUp(); + mainGuild.getBranches().get(0).levelUp(); + mainGuild.getUpgrades().getFirst().setLevel(3); + mainGuild.addMember("Cara"); + source.getBank().deposit(20.0); + var originalBank = source.getBank(); + Path broken = + disk.write( + rejectedFile, rejectedFile.endsWith("json") ? "{ malformed" : "bad: [unterminated\n"); + + IllegalStateException failure = + assertThrows(IllegalStateException.class, SimpleFactions::reloadConfigs); + assertTrue(failure.getMessage().contains(broken.getFileName().toString())); + var freshBase = new net.tfminecraft.simplefactions.guild.Guild(ruler); + var freshGuild = domain.guild(ruler, "fresh_guild", "Dana"); + assertAll( + () -> { + List<Map<String, ?>> current = + List.of( + GuildLoader.get(), + BranchLoader.get(), + UpgradeLoader.get(), + CompanyUpgradeLoader.get()); + for (int i = 0; i < registries.size(); i++) { + assertSame(registries.get(i), current.get(i)); + assertEquals( + new ArrayList<>(entries.get(i).keySet()), + new ArrayList<>(current.get(i).keySet())); + for (var entry : entries.get(i).entrySet()) + assertSame(entry.getValue(), current.get(i).get(entry.getKey())); + } + }, + () -> assertEquals(Set.of(0, 1), freshBase.getBranches().keySet()), + () -> assertEquals(Set.of(0, 1), freshGuild.getBranches().keySet()), + () -> assertNotNull(BranchLoader.getByGroup(GuildLoader.getBaseType(), 0)), + () -> assertNotNull(BranchLoader.getByGroup(GuildLoader.getDefaultType(), 0)), + () -> assertTrue(UpgradeLoader.getByString("storehouse").isAllowed(freshGuild.getType())), + () -> + assertTrue( + CompanyUpgradeLoader.getByString("company_storehouse") + .isAllowed(freshGuild.getType())), + () -> { + try (var databases = mockConstruction(Database.class)) { + assertSame( + ruler, + assertDoesNotThrow( + () -> + source.dissolve( + source.getVassals(), source.getGuildHandler().getGuilds()))); + verify(databases.constructed().getFirst()).deleteFaction(source); + } + assertSame(ruler, mainGuild.getFaction()); + assertFalse(mainGuild.isBase()); + assertEquals(8, mainGuild.getCapital()); + assertSame(originalBank, mainGuild.getBank()); + assertEquals(20.0, mainGuild.getBank().getWealth()); + assertEquals("Bob", mainGuild.getLeader()); + assertTrue(mainGuild.getMembers().containsAll(List.of("Bob", "Cara"))); + assertEquals("guild_halls", mainGuild.getBranches().get(0).getId()); + assertEquals(2, mainGuild.getBranches().get(0).getLevel()); + assertEquals("workshops", mainGuild.getBranches().get(1).getId()); + assertEquals(3, mainGuild.getUpgrades().getFirst().getLevel()); + assertEquals(Set.of(7, 8), Set.copyOf(ruler.getProvinces())); + assertFalse(FactionManager.factions.contains(source)); + }); + } finally { + CompanyUpgradeLoader.get().clear(); + CompanyUpgradeLoader.get().putAll(priorCompanyUpgrades); + } + } + + private static List<List<?>> identityRegistries() { + return List.of( + RankLoader.getRanks(), + RelationLoader.getTypes(), + RelationLoader.getAttitudes(), + TierLoader.get(), + TitleLoader.getTitles()); + } + + private static void assertRegistryIdentities(List<List<?>> references, List<List<?>> entries) { + List<List<?>> current = identityRegistries(); + for (int i = 0; i < references.size(); i++) { + assertSame(references.get(i), current.get(i), "Registry collection " + i); + assertEquals(entries.get(i).size(), current.get(i).size(), "Registry size " + i); + for (int j = 0; j < entries.get(i).size(); j++) + assertSame(entries.get(i).get(j), current.get(i).get(j), "Registry " + i + " entry " + j); + } + } + + private void writeIdentityCatalogs(boolean updated) throws Exception { + String common = + "common:\n name: " + + (updated ? "Updated Common" : "Common") + + "\n level: 1\n minimum-prestige: 0\n"; + String renowned = "renowned:\n name: Renowned\n level: 2\n minimum-prestige: 100000\n"; + disk.write("ranks.yml", updated ? renowned + common : common + renowned); + disk.write( + "diplomacy.yml", + """ + types: + neutral: + default: true + ally: + target: %d + trade: + trade-agreement: true + treaty: + treaty: true + attitudes: + neutral: + default: true + friendly: + target: %d + """ + .formatted(updated ? 25 : 5, updated ? 35 : 10)); + disk.write( + "tiers.yml", + """ + landless: + name: Landless + tier: 0 + province: + name: Province + tier: 1 + prestige: 10 + county: + name: %s + tier: 2 + prestige: 50 + duchy: + name: Duchy + tier: 3 + prestige: 100 + """ + .formatted(updated ? "Updated County" : "County")); + disk.write("battle-templates.yml", "field_default:\n type: field\n"); + disk.write("Guilds/guild-types.yml", "realm:\n base: true\nguild:\n default: true\n"); + String owned = + "\"county_owned\":{\"name\":\"" + + (updated ? "Updated Title" : "Old Title") + + "\",\"provinces\":[12]}"; + String other = "\"county_other\":{\"name\":\"Other Title\",\"provinces\":[13]}"; + disk.write( + "Input/county.json", "{" + (updated ? other + "," + owned : owned + "," + other) + "}"); + } + + @Test + void enabledProvinceGridSchedulesRefreshAndCancelsItAtShutdown() throws Exception { + Cache.provincesEnabled = true; + Cache.mapEnabled = true; + ProvinceGrid grid = mock(ProvinceGrid.class); + when(grid.getWidth()).thenReturn(20); + when(grid.getHeight()).thenReturn(10); + TrackProvinceCache cache = new TrackProvinceCache(); + try (var grids = mockStatic(ProvinceGrid.class); + var tracks = mockStatic(TrackProvinceCache.class)) { + grids.when(() -> ProvinceGrid.load(any())).thenReturn(grid); + tracks.when(TrackProvinceCache::live).thenReturn(cache); + plugin.onEnable(); + assertSame(grid, plugin.getProvinceGrid()); + verify(logger).info("Loaded province_id_grid 20x10"); + verify(logger).severe(contains("TFMCWeb is not loaded")); + verify(logger).info(contains("VehicleFramework is not enabled")); + scoped(ProvincePresenceTickService.class).verify(ProvincePresenceTickService::start); + assertEquals(1, domain.ui.repeatingTasks.size()); + domain.ui.repeatingTasks.getFirst().run(); + plugin.onDisable(); + var taskField = SimpleFactions.class.getDeclaredField("trackRefreshTask"); + taskField.setAccessible(true); + verify((BukkitTask) taskField.get(plugin)).cancel(); + } + } + + @Test + void loadAndReloadPreserveConfigurationOrderAndRefreshDerivedFactionState() throws Exception { + plugin.createFolders(); + plugin.createConfigs(); + plugin.loadConfigs(); + verify(dependency("configLoader", ConfigLoader.class)) + .loadConfig(disk.root.resolve("config.yml").toFile()); + verify(dependency("configLoader", ConfigLoader.class)) + .loadWar(disk.root.resolve("war.yml").toFile()); + verify(dependency("guildLoader", GuildLoader.class)) + .load(disk.root.resolve("Guilds/guild-types.yml").toFile()); + SimpleFactions.reloadConfigs(); + scoped(FactionManager.class).verify(FactionManager::rebindRanks); + scoped(FactionManager.class).verify(FactionManager::rebindDiplomacy); + scoped(FactionManager.class).verify(FactionManager::reloadTitles); + scoped(FactionManager.class).verify(FactionManager::updateAllPrestigeConverged); + SimpleFactions.reloadTitles(); + scoped(RegionLoader.class).verify(RegionLoader::loadAll, times(3)); + } + + @Test + void paperClassLoaderConstructsTheRealPluginAndItsServiceGraph() throws Exception { + PluginDescriptionFile description = + new PluginDescriptionFile("SimpleFactions", "test", SimpleFactions.class.getName()); + try (var services = mockStatic(net.kyori.adventure.util.Services.class, CALLS_REAL_METHODS)) { + when(Bukkit.getUnsafe()).thenReturn(mock(org.bukkit.UnsafeValues.class, RETURNS_DEEP_STUBS)); + services + .when(() -> net.kyori.adventure.util.Services.service(PluginLoader.class)) + .thenReturn(Optional.of(mock(PluginLoader.class))); + TestPluginLoader loader = + new TestPluginLoader(description, domain.ui.server, logger, disk.root); + JavaPlugin constructed = + (JavaPlugin) + loader.loadClass(SimpleFactions.class.getName()).getConstructor().newInstance(); + assertSame(constructed, loader.getPlugin()); + assertEquals("SimpleFactions", constructed.getName()); + assertSame(description, constructed.getPluginMeta()); + assertEquals(disk.root.resolve("data").toFile(), constructed.getDataFolder()); + SessionManager sessions = + (SessionManager) + constructed.getClass().getMethod("getSessionManager").invoke(constructed); + assertNotNull(sessions); + assertSame( + sessions, constructed.getClass().getMethod("getSessionManager").invoke(constructed)); + constructed.onDisable(); + scoped(BattleManager.class).verify(BattleManager::shutdown); + } + } + + @Test + void enabledIntegrationsRegisterOnceAndHandleLateDependencyEvents() throws Exception { + Plugin items = mock(Plugin.class); + when(plugins.getPlugin("ItemsAdder")).thenReturn(items); + when(plugins.isPluginEnabled("RPCharacters")).thenReturn(true); + when(plugins.isPluginEnabled("VehicleFramework")).thenReturn(true); + when(plugins.isPluginEnabled("VFBuilders")).thenReturn(true); + plugin.onEnable(); + verify(plugins) + .registerEvents( + dependency("vehicleIntegrationListener", VehicleIntegrationListener.class), plugin); + long office = + listeners.stream() + .filter( + net.tfminecraft.simplefactions.espionage.OfficeCharacterDeathListener.class + ::isInstance) + .count(); + assertEquals(1, office); + assertTrue( + listeners.stream() + .anyMatch( + net.tfminecraft.simplefactions.managers.VotingBoothListener.class::isInstance)); + assertTrue( + listeners.stream() + .anyMatch( + net.tfminecraft.simplefactions.vehicles.fees.VehicleRegistrationFeeListener.class + ::isInstance)); + scoped(VfBuildersConstructionFreeze.class) + .verify(() -> VfBuildersConstructionFreeze.register(plugin)); + for (String dependency : + List.of("Unrelated", "RPCharacters", "VFBuilders", "VehicleFramework")) { + Plugin enabled = mock(Plugin.class); + when(enabled.getName()).thenReturn(dependency); + for (Listener listener : new ArrayList<>(listeners)) { + if (listener.getClass().getEnclosingClass() != SimpleFactions.class) continue; + var method = listener.getClass().getMethod("onPluginEnable", PluginEnableEvent.class); + method.setAccessible(true); + method.invoke(listener, new PluginEnableEvent(enabled)); + } + } + verify(plugins, times(1)) + .registerEvents( + dependency("vehicleIntegrationListener", VehicleIntegrationListener.class), plugin); + assertEquals( + 1, + listeners.stream() + .filter( + net.tfminecraft.simplefactions.espionage.OfficeCharacterDeathListener.class + ::isInstance) + .count()); + domain.ui.runTasks(); + scoped(LeaderCharacterListener.class) + .verify(() -> LeaderCharacterListener.refresh(null), atLeastOnce()); + scoped(RpCharactersChatIntegration.class) + .verify(RpCharactersChatIntegration::register, atLeastOnce()); + } + + @Test + void ownerAndMaintenancePersistenceUsesTheExposedLiveStores() throws Exception { + assertTrue(plugin.saveVehicleRegistry()); + plugin.onEnable(); + plugin.getVehicleMaintenanceStore().markUnpaid("cart-1", 1234L); + plugin.recordVehicleOwner("cart-1", "Alice"); + plugin + .recordVehicleOwners(); // A disabled VehicleFramework leaves existing owner history intact. + plugin.saveVehicleFees(); + assertTrue(plugin.saveVehicleRegistry()); + VehicleFeeStore fees = new VehicleFeeStore(); + fees.bind(disk.root.resolve("Cache").toFile()); + fees.load(); + assertEquals("Alice", fees.getLastOwner("cart-1")); + VehicleMaintenanceStore restored = new VehicleMaintenanceStore(); + new VehicleMaintenancePersistence(disk.root.resolve("Cache").toFile(), restored).load(); + assertTrue(restored.isUnpaid("cart-1")); + PlayerVehicleRegistry registry = SimpleFactions.getVehicleRegistry(); + registry.register( + new PlayerVehicleRecord( + UUID.randomUUID(), "berthed", "cart", OwnershipMode.PERSONAL, "dock")); + plugin.recordVehicleOwner("berthed", "PreviousOwner"); + when(plugins.isPluginEnabled("VehicleFramework")).thenReturn(true); + try (var queries = mockStatic(VehicleOwnershipQueries.class, CALLS_REAL_METHODS)) { + OwnedVehicleSummary personal = mock(OwnedVehicleSummary.class), + unowned = mock(OwnedVehicleSummary.class); + when(personal.getUuid()).thenReturn("personal"); + when(personal.getOwner()).thenReturn("player_Bob"); + when(unowned.getUuid()).thenReturn("unowned"); + when(unowned.getOwner()).thenReturn("player_none"); + queries + .when(() -> VehicleOwnershipQueries.allPersonalVehicles(registry)) + .thenReturn(List.of(personal, unowned)); + plugin.recordVehicleOwners(); + fees.load(); + assertEquals("Bob", fees.getLastOwner("personal")); + assertNull(fees.getLastOwner("berthed")); + queries + .when(() -> VehicleOwnershipQueries.allPersonalVehicles(registry)) + .thenThrow(new LinkageError("old VehicleFramework")); + assertDoesNotThrow(plugin::recordVehicleOwners); + verify(logger).warning(contains("Could not record vehicle owners")); + } + var persistence = mock(VehicleRegistryPersistence.class); + when(persistence.save()).thenReturn(false); + field("vehicleRegistryPersistence", persistence); + assertFalse(plugin.saveVehicleRegistry()); + } + + @Test + void publicServicesExposeTheSameInstancesUsedByStartupListeners() { + assertSame(fields.get("provinceManager"), plugin.getProvinceManager()); + assertSame(fields.get("provinceSnapshot"), plugin.getProvinceSnapshot()); + assertSame(fields.get("sessionManager"), plugin.getSessionManager()); + assertSame( + fields.get("vehicleTransferSessionManager"), plugin.getVehicleTransferSessionManager()); + assertSame( + fields.get("vehicleReleaseSessionManager"), plugin.getVehicleReleaseSessionManager()); + assertSame( + fields.get("vehicleMaintenancePaySessionManager"), + plugin.getVehicleMaintenancePaySessionManager()); + assertSame( + fields.get("vehicleTransferConsentService"), plugin.getVehicleTransferConsentService()); + assertSame(fields.get("factionVehicleGiveService"), plugin.getFactionVehicleGiveService()); + assertSame( + fields.get("vehicleHandoverSessionManager"), plugin.getVehicleHandoverSessionManager()); + assertSame(fields.get("vehicleHandoverService"), plugin.getVehicleHandoverService()); + assertSame( + fields.get("installationVehicleUnberthService"), + plugin.getInstallationVehicleUnberthService()); + assertSame(fields.get("playerEconomyManager"), SimpleFactions.getPlayerEconomyManager()); + assertSame(fields.get("vehicleUpkeepService"), plugin.getVehicleUpkeepService()); + assertSame(ProvincePresenceService.getInstance(), plugin.getProvincePresenceService()); + assertSame(BattleTemplateService.getInstance(), plugin.getBattleTemplateService()); + assertEquals(Cache.maxExtraNodeCapacity, SimpleFactions.getMaxExtraNodeCapacity()); + } + + @Test + void trackChangesInvalidateRoutesBeforeRecalculationAndAReadFailureClearsTheSnapshot() + throws Exception { + TrackProvinceCache cache = new TrackProvinceCache(); + ProvinceGrid grid = mock(ProvinceGrid.class); + var province = mock(net.tfminecraft.simplefactions.map.provinces.Province.class); + when(province.getId()).thenReturn(17); + ProvinceManager manager = plugin.getProvinceManager(); + when(manager.getProvinces()).thenReturn(List.of(province)); + when(plugins.isPluginEnabled("VehicleFramework")).thenReturn(true); + try (var tracks = mockStatic(TrackProvinceCache.class); + var source = mockStatic(VehicleFrameworkTrackProvinces.class)) { + tracks.when(TrackProvinceCache::live).thenReturn(cache); + assertFalse(plugin.refreshTrackProvinces()); + field("provinceGrid", grid); + source + .when( + () -> + VehicleFrameworkTrackProvinces.sample( + eq(Cache.worldName), same(grid), eq(Map.of(17, province)))) + .thenReturn(Set.of(17)); + doAnswer( + call -> { + scoped(TradeGraph.class).verify(TradeGraph::forgetRoutes, atLeastOnce()); + scoped(TradeGraph.class).verify(() -> TradeGraph.refresh(manager), atLeastOnce()); + return null; + }) + .when(manager) + .recalculate(); + assertTrue(plugin.refreshTrackProvinces()); + assertEquals(Set.of(17), cache.provinces()); + assertFalse(plugin.refreshTrackProvinces()); + verify(manager, times(1)).recalculate(); + source + .when(() -> VehicleFrameworkTrackProvinces.sample(any(), same(grid), any())) + .thenThrow(new LinkageError("old tracks API")); + assertTrue(plugin.refreshTrackProvinces()); + assertTrue(cache.provinces().isEmpty()); + assertFalse(plugin.refreshTrackProvinces()); + verify(manager, times(2)).recalculate(); + verify(logger, times(1)).warning(contains("Could not read VehicleFramework tracks")); + } + } + + @Test + void lateVehicleAndCharacterEnableEventsRegisterTheIntegrationsAndRefreshTheMap() + throws Exception { + plugin.onEnable(); + verify(plugins, never()) + .registerEvents( + dependency("vehicleIntegrationListener", VehicleIntegrationListener.class), plugin); + scoped(FactionManager.class).when(FactionManager::getMap).thenReturn(domain.map); + when(plugins.isPluginEnabled("VehicleFramework")).thenReturn(true); + when(plugins.isPluginEnabled("RPCharacters")).thenReturn(true); + for (String name : List.of("VehicleFramework", "RPCharacters")) { + Plugin enabled = mock(Plugin.class); + when(enabled.getName()).thenReturn(name); + for (Listener listener : new ArrayList<>(listeners)) { + if (listener.getClass().getEnclosingClass() != SimpleFactions.class) continue; + var method = listener.getClass().getMethod("onPluginEnable", PluginEnableEvent.class); + method.setAccessible(true); + method.invoke(listener, new PluginEnableEvent(enabled)); + } + } + verify(plugins) + .registerEvents( + dependency("vehicleIntegrationListener", VehicleIntegrationListener.class), plugin); + verify(logger).info("[SimpleFactions] VehicleFramework vehicle integration enabled"); + domain.ui.runTasks(); + verify(domain.map).markLeaderNamesChanged(); + } + + @Test + void olderVfBuildersWithoutOptionalApisStillAllowsThePluginToStart() throws Exception { + PluginDescriptionFile description = + new PluginDescriptionFile("SimpleFactions", "test", SimpleFactions.class.getName()); + try (var services = mockStatic(net.kyori.adventure.util.Services.class, CALLS_REAL_METHODS)) { + when(Bukkit.getUnsafe()).thenReturn(mock(org.bukkit.UnsafeValues.class, RETURNS_DEEP_STUBS)); + services + .when(() -> net.kyori.adventure.util.Services.service(PluginLoader.class)) + .thenReturn(Optional.of(mock(PluginLoader.class))); + TestPluginLoader loader = + new TestPluginLoader(description, domain.ui.server, logger, disk.root); + loader.unavailable = + Set.of( + "net.tfminecraft.vfbuilders.api.ConstructionFreeze", + "net.tfminecraft.vfbuilders.events.VehicleConstructionCancelEvent"); + Class<?> type = loader.loadClass(SimpleFactions.class.getName()); + JavaPlugin legacy = spy((JavaPlugin) type.getConstructor().newInstance()); + doReturn(disk.root.toFile()).when(legacy).getDataFolder(); + doReturn(new YamlConfiguration()).when(legacy).getConfig(); + doAnswer( + call -> + commands.computeIfAbsent(call.getArgument(0), name -> mock(PluginCommand.class))) + .when(legacy) + .getCommand(anyString()); + doAnswer( + call -> { + plugin.saveResource(call.getArgument(0), false); + return null; + }) + .when(legacy) + .saveResource(anyString(), eq(false)); + for (var entry : fields.entrySet()) { + Field target = type.getDeclaredField(entry.getKey()); + target.setAccessible(true); + target.set(legacy, entry.getValue()); + } + when(plugins.isPluginEnabled("VFBuilders")).thenReturn(true); + when(plugins.isPluginEnabled("VehicleFramework")).thenReturn(true); + legacy.onEnable(); + verify(dependency("factionManager", FactionManager.class)).run(); + verify(plugins, never()).disablePlugin(legacy); + verify(logger).warning(contains("VFBuilders is older than 2.1.0")); + verify(logger).warning(contains("VFBuilders has no ConstructionFreeze API")); + scoped(VfBuildersConstructionFreeze.class).verifyNoInteractions(); + legacy.onDisable(); + } + } + + /** Loads unchanged plugin bytes through Paper's real JavaPlugin initialization contract. */ + private static final class TestPluginLoader extends ClassLoader + implements ConfiguredPluginClassLoader { + private final PluginDescriptionFile description; + private final Server server; + private final Logger logger; + private final Path directory; + private JavaPlugin plugin; + private Set<String> unavailable = Set.of(); + + TestPluginLoader( + PluginDescriptionFile description, Server server, Logger logger, Path directory) { + super(SimpleFactions.class.getClassLoader()); + this.description = description; + this.server = server; + this.logger = logger; + this.directory = directory; + } + + @Override + protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (unavailable.contains(name)) throw new ClassNotFoundException(name); + if (!name.equals(SimpleFactions.class.getName()) + && !name.startsWith(SimpleFactions.class.getName() + "$")) + return super.loadClass(name, resolve); + Class<?> loaded = findLoadedClass(name); + if (loaded == null) { + try (var input = getParent().getResourceAsStream(name.replace('.', '/') + ".class")) { + if (input == null) throw new ClassNotFoundException(name); + byte[] bytes = input.readAllBytes(); + loaded = + defineClass(name, bytes, 0, bytes.length, SimpleFactions.class.getProtectionDomain()); + } catch (IOException error) { + throw new ClassNotFoundException(name, error); + } + } + if (resolve) resolveClass(loaded); + return loaded; + } + + @Override + public PluginMeta getConfiguration() { + return description; + } + + @Override + public Class<?> loadClass(String name, boolean resolve, boolean global, boolean libraries) + throws ClassNotFoundException { + return loadClass(name, resolve); + } + + @Override + public void init(JavaPlugin value) { + plugin = value; + value.init( + server, + description, + directory.resolve("data").toFile(), + directory.resolve("plugin.jar").toFile(), + this, + description, + logger); + } + + @Override + public JavaPlugin getPlugin() { + return plugin; + } + + @Override + public PluginClassLoaderGroup getGroup() { + return null; + } + + @Override + public void close() {} + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/database/DatabaseCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/database/DatabaseCoverageTest.java new file mode 100644 index 00000000..ebde031e --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/database/DatabaseCoverageTest.java @@ -0,0 +1,279 @@ +package net.tfminecraft.simplefactions.database; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; +import java.util.*; +import net.tfminecraft.simplefactions.government.proposal.*; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.loans.Loan; +import net.tfminecraft.simplefactions.guild.upgrade.Upgrade; +import net.tfminecraft.simplefactions.loaders.UpgradeLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.PersistenceFilesFixture; +import net.tfminecraft.simplefactions.war.battle.engine.core.*; +import net.tfminecraft.simplefactions.war.battle.enums.BattleType; +import net.tfminecraft.simplefactions.war.battle.warband.Warband; +import net.tfminecraft.simplefactions.war.core.*; +import net.tfminecraft.simplefactions.war.resolution.WarReparationsObligation; +import org.bukkit.Bukkit; +import org.bukkit.boss.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class DatabaseCoverageTest { + FactionDomainFixture fixture; + PersistenceFilesFixture disk; + Database database; + Map<Field,Object> oldQueues=new LinkedHashMap<>(); + + @BeforeEach void setUp() throws Exception { + disk=new PersistenceFilesFixture(); + fixture=new FactionDomainFixture();database=new Database(); + when(fixture.ui.world.getName()).thenReturn("world"); + for(String name:List.of("dbRelations","dbTradeRelations","dbTreatyRelations","loans")) { + Field field=FactionManager.class.getDeclaredField(name);field.setAccessible(true); + oldQueues.put(field,field.get(null)); + field.set(null,name.equals("loans") ? new ArrayList<>() : new HashMap<>()); + } + when(Bukkit.createBossBar(anyString(),any(BarColor.class),any(BarStyle.class),any(BarFlag[].class))) + .thenAnswer(call -> mock(BossBar.class)); + } + + @AfterEach void close() throws Exception { + try { + for(var entry:oldQueues.entrySet()) entry.getKey().set(null,entry.getValue()); + fixture.close(); + } finally { disk.close(); } + } + + @Test void timerAndDayRoundTripAndMissingNullOrMalformedDataUseZero() throws Exception { + assertEquals(0,database.getTimer());assertEquals(0,database.getDay()); + database.saveTimer(543,7);assertEquals(543,database.getTimer());assertEquals(7,database.getDay()); + disk.write("Cache/data.json","null");assertEquals(0,database.getTimer());assertEquals(0,database.getDay()); + disk.write("Cache/data.json","not-json{");assertEquals(0,database.getTimer());assertEquals(0,database.getDay()); + disk.remove("Cache/data.json");Files.createDirectory(disk.root.resolve("Cache/data.json")); + assertDoesNotThrow(() -> database.saveTimer(10,2)); + assertTrue(Files.isDirectory(disk.root.resolve("Cache/data.json"))); + } + + @Test void aNonDirectoryFactionStoreFailsWithoutLeavingPrestigeLoadingSuppressed() throws Exception { + disk.write("Data","not a directory"); + assertThrows(RuntimeException.class,database::loadFactions); + assertFalse(FactionManager.loading,"A failed load must not permanently suppress prestige refreshes"); + assertTrue(FactionManager.factions.isEmpty()); + } + + @Test void missingStoresAreCreatedAndUnrelatedOrEmptyFactionFilesAreIgnored() throws Exception { + database.loadFactions();assertTrue(Files.isDirectory(disk.root.resolve("Data"))); + disk.write("Data/readme.txt","operator notes");disk.write("Data/empty.json","null"); + disk.write("Data/no-id.json","{}"); + Path broken = disk.write("Data/broken.json", "not-json{"); + database.loadFactions();assertTrue(FactionManager.factions.isEmpty());assertFalse(FactionManager.loading); + assertEquals("not-json{", Files.readString(broken)); + } + + private GuildData guild(String id,String type,String leader) { + GuildData data=new GuildData();data.id=id;data.name=id;data.type=type;data.leader=leader; + data.members=new ArrayList<>(List.of(leader));data.banner=new ArrayList<>(List.of("white")); + data.rgb="4,5,6";data.capital=-1;data.bank="true";data.world="world";data.xPos=0.0;data.zPos=0.0;data.balance=75.0; + return data; + } + + private FactionData richFaction() { + fixture.regiment("guards",false,2,5);fixture.regiment("levy",true,1,0); + fixture.lawGroup("customs",Map.of("effects.faction.regiments",List.of("guards 2"))); + fixture.title("county","county",1,2); + YamlConfiguration yaml=new YamlConfiguration();yaml.set("warehouse.allowed-types",List.of("realm","guild")); + UpgradeLoader.map.put("warehouse",new Upgrade("warehouse",yaml.getConfigurationSection("warehouse"))); + FactionData data=fixture.data("realm","Alice"); + data.provinces=List.of(1,2);data.titles=List.of("county","deleted-title"); + data.laws=List.of("customs:current");data.lawChangedAt=new HashMap<>(Map.of("customs",12345L,"deleted",42L));data.lawChangedAt.put("missing",null); + data.prestigeModifiers=List.of("Historic capital(12.5)"); + data.rank="renowned";data.foundedAt=1234L;data.capitalMoves=2;data.tierIndex=3.0; + data.military=List.of("guards.5.2","deleted.3");data.militaryQueue=List.of("guards.7"); + data.specificTaxes.put("GUILDS",new HashMap<>(Map.of("merchants",3.5)));data.specificTaxes.put("VASSALS",null); + data.vehicleFees=new HashMap<>(Map.of("REGISTRATION_FEE",2.0)); + data.vehicleTypeFees=new HashMap<>(Map.of("TRANSFER_FEE",new HashMap<>(Map.of("cart",3.0)))); + GuildData base=guild("realm","realm","Alice"),merchants=guild("merchants","guild","Bob"); + merchants.bank="false";merchants.balance=null;merchants.casinoProfit=5.0;merchants.vehicleFeeIncome=7.0; + merchants.citizenTaxes=Map.of("Bob",1.5);merchants.ledgerLastDay=Map.of("income",Map.of("tax",3.0)); + merchants.ledgerLifetime=Map.of("income",Map.of("tax",8.0));merchants.depositsToday=Map.of("Bob",4.0); + GuildBranchData branch=new GuildBranchData();branch.id="commerce";branch.level=2; + merchants.branches.add(branch);GuildBranchData upgrade=new GuildBranchData();upgrade.id="warehouse";upgrade.level=1;merchants.upgrades.add(upgrade); + UpgradeExpansionData queued=new UpgradeExpansionData();queued.upgrade="warehouse";queued.timeLeft=9; + UpgradeExpansionData removed=new UpgradeExpansionData();removed.upgrade="deleted";removed.timeLeft=4; + merchants.upgradeQueue=List.of(queued,removed);merchants.wealthModifiers=List.of("Grant(25.0)"); + StabilityModifierData hit=new StabilityModifierData();hit.name="Raid";hit.modifier=-4;hit.decay=1;merchants.pillageHits.add(hit); + data.guilds=List.of(base,merchants); + WarReparationsObligationData payment=new WarReparationsObligationData();payment.payeeFactionId="other";payment.incomePercent=10.0;payment.daysRemaining=4; + WarReparationsObligationData invalid=new WarReparationsObligationData();invalid.payeeFactionId=" "; + WarReparationsObligationData expired=new WarReparationsObligationData();expired.payeeFactionId="other";expired.incomePercent=10.0;expired.daysRemaining=0; + data.warReparationsObligations=Arrays.asList(payment,invalid,null,expired); + return data; + } + + @Test void realFactionStateRoundTripsTreasuryRulesRegimentsQueuesTaxesAndLedgerHistory() throws Exception { + FactionData data=richFaction(); + Path path=disk.write("Data/realm.json",JsonUtil.GSON.toJson(data)); + database.loadFactions();Faction faction=FactionManager.getByString("realm");assertNotNull(faction); + assertEquals(400.0,faction.getWealth());assertEquals(75.0,faction.getBank().getWealth());assertEquals(1234L,faction.getFoundedAt()); + assertEquals(2,faction.getCapitalMoves());assertEquals("renowned",faction.getRank().getId()); + assertEquals(3.5,faction.getTaxRate(TaxTarget.GUILD_ID,"merchants",false)); + assertEquals(12345L,faction.getLawHandler().getGroup("customs").getChangedAt()); + assertEquals(5,faction.getMilitary().getRegiment("guards").getCurrentSlots()); + assertEquals(7,faction.getMilitary().getQueue().getFirst().getTimeLeft()); + Guild merchants=faction.getGuildHandler().getGuild("merchants"); + assertEquals(2,merchants.getBranch("commerce").getLevel());assertEquals(1,merchants.getUpgrade("warehouse").getLevel()); + assertEquals(1,merchants.getUpgradeQueue().size());assertEquals(9,merchants.getUpgradeQueue().getFirst().getTimeLeft()); + assertEquals(5.0,merchants.getLedger().getCasinoProfit());assertEquals(Map.of("Bob",1.5),merchants.getLedger().getCitizenTaxesCopy()); + assertEquals(1,faction.getWarReparationsObligations().size()); + assertTrue(database.saveFactionChecked(faction)); + FactionData saved=JsonUtil.readJson(path.toFile(),FactionData.class); + assertEquals(List.of("county"),saved.titles);assertEquals(List.of("guards.5.2"),saved.military); + assertEquals(data.laws,saved.laws);assertEquals(12345L,saved.lawChangedAt.get("customs")); + assertEquals(3.5,saved.specificTaxes.get("GUILDS").get("merchants")); + assertEquals(1,saved.warReparationsObligations.size()); + assertEquals(25.0,Database.loadModifiers(saved.guilds.stream().filter(g->g.id.equals("merchants")).findFirst().orElseThrow().wealthModifiers).getFirst().getAmount()); + FactionManager.factions.clear();database.loadFactions();Faction reloaded=FactionManager.getByString("realm"); + assertEquals(faction.getWealth(),reloaded.getWealth());assertEquals(75.0,reloaded.getBank().getWealth()); + database.deleteFaction(reloaded);assertFalse(Files.exists(path));database.deleteFaction(reloaded); + } + + @Test void legacyMissingOptionalValuesUseDefaultsWithoutDiscardingTheFaction() throws Exception { + FactionData data=fixture.data("legacy","Alice"); + data.capital=null;data.extraNodeCapacity=null;data.citizenTax=null;data.guildTax=null;data.vassalTax=null;data.dividendTax=null;data.tariffs=null; + data.specificTaxes=null;data.guilds=null;data.relations=null;data.tradeRelations=null;data.treatyRelations=null; + data.settlements=null;data.installations=null;data.installationQueue=null;data.lawChangedAt=null;data.warReparationsObligations=null; + data.rank="removed-rank"; + disk.write("Data/legacy.json",new com.google.gson.GsonBuilder().serializeNulls().create().toJson(data));database.loadFactions(); + Faction faction=FactionManager.getByString("legacy");assertNotNull(faction); + assertEquals(-1,faction.getCapital());assertEquals(0,faction.getExtraNodeCapacity()); + for(TaxTarget target:TaxTarget.values()) assertEquals(5.0,faction.getTaxRate(target,null,false)); + assertTrue(faction.getFoundedAt()>0);assertEquals(0,faction.getCapitalMoves()); + } + + @Test void relationsLoansConstructionAndRemovedBanksSurviveASaveAndReload() throws Exception { + Files.createDirectories(disk.root.resolve("Data")); + Faction realm=fixture.saved("realm","Alice"), liege=fixture.saved("liege","Bob"); + Guild issuer=realm.getOrCreateMainGuild(), borrower=liege.getOrCreateMainGuild(); + fixture.subject(liege,realm); + var trade=fixture.relationType("trading",Map.of("trade-agreement",true)); + var treaty=fixture.relationType("peace",Map.of("treaty",true)); + realm.getDiplomacyHandler().setTradeRelation(liege,trade); + realm.getDiplomacyHandler().setTreatyRelation(liege,treaty); + Loan source=new Loan("agreement",100.0,issuer,borrower,1000L,2,0.1,0.5,true); + issuer.getLoanHandler().issueLoan(source); + realm.setBank(null); + realm.addWarReparationsObligation(new WarReparationsObligation("liege",5.0,0)); + InstallationConstructionData construction=new InstallationConstructionData(); + construction.id="new-fort";construction.name="North Fort";construction.kind="fort"; + construction.province=10;construction.timeLeft=6;construction.startedAt=1234L; + realm.getInstallationHandler().loadConstruction(construction); + assertTrue(database.saveFactionChecked(realm)); + FactionData saved=JsonUtil.readJson(disk.root.resolve("Data/realm.json").toFile(),FactionData.class); + assertEquals(List.of("liege(trading)"),saved.tradeRelations); + assertEquals(List.of("liege(peace)"),saved.treatyRelations); + assertEquals("false",saved.guilds.getFirst().bank); + assertEquals("agreement",saved.guilds.getFirst().loans.getFirst().id); + assertTrue(saved.warReparationsObligations.isEmpty()); + assertEquals(6,saved.installationQueue.timeLeft); + FactionManager.factions.remove(realm); + database.loadFactions();FactionManager.loadRelations();FactionManager.loadDBLoans(); + Faction reloaded=FactionManager.getByString("realm");assertNotNull(reloaded); + assertNull(reloaded.getBank(), "An explicitly removed bank must stay removed after reload"); + assertSame(liege,reloaded.getOverlord()); + assertSame(trade,reloaded.getDiplomacyHandler().getTradeRelation("liege")); + assertSame(treaty,reloaded.getDiplomacyHandler().getTreatyRelation("liege")); + Loan restored=reloaded.getOrCreateMainGuild().getLoanHandler().getLoansGiven().getFirst(); + assertEquals("agreement",restored.getId());assertSame(borrower,restored.getBorrower()); + assertEquals(100.0,restored.getAmount());assertEquals(source.getDueDate(),restored.getDueDate()); + assertEquals("new-fort",reloaded.getInstallationHandler().getPendingConstruction().getId()); + assertEquals(6,reloaded.getInstallationHandler().getPendingConstruction().getTimeLeft()); + } + + @Test void legacyTwoPartRegimentsRetainTheirConfiguredFreeSlots() throws Exception { + fixture.regiment("guards",false,2,5); + fixture.lawGroup("customs",Map.of("effects.faction.regiments",List.of("guards 2"))); + FactionData data=fixture.data("legacy","Alice");data.laws=List.of("customs:current");data.military=List.of("guards.5"); + disk.write("Data/legacy.json",JsonUtil.GSON.toJson(data));database.loadFactions(); + var regiment=FactionManager.getByString("legacy").getMilitary().getRegiment("guards"); + assertEquals(5,regiment.getCurrentSlots());assertEquals(2,regiment.getFreeSlots()); + } + + @ParameterizedTest @ValueSource(strings={"Wars","Battles","Warbands"}) + void unreadableDirectoriesDoNotProduceObjects(String kind) throws Exception { + Path directory=Files.createDirectories(disk.root.resolve(kind)); + Assumptions.assumeTrue(Files.getFileAttributeView(directory,PosixFileAttributeView.class)!=null); + Set<PosixFilePermission> original=Files.getPosixFilePermissions(directory); + try { + Files.setPosixFilePermissions(directory,Set.of()); + Assumptions.assumeFalse(Files.isReadable(directory),"This user can read directories despite POSIX permissions"); + assertTrue(load(kind).isEmpty()); + } finally {Files.setPosixFilePermissions(directory,original);} + } + + @Test void failedFactionSaveReportsFailureAndPreservesThePreviousBytes() throws Exception { + Faction faction=fixture.saved("realm","Alice"); + Path file=disk.write("Data/realm.json","previous saved state"); + try(var writes=mockStatic(JsonUtil.class,CALLS_REAL_METHODS)) { + writes.when(() -> JsonUtil.writeJsonAtomic(any(File.class),any())).thenThrow(new IOException("simulated full disk")); + assertFalse(database.saveFactionChecked(faction));assertEquals("previous saved state",Files.readString(file)); + assertDoesNotThrow(() -> database.saveFaction(faction)); + } + } + + @Test void savingGuildStateSkipsEmptyEntriesInThePublicPillageHistory() throws Exception { + Files.createDirectories(disk.root.resolve("Data")); + Faction realm=fixture.saved("realm","Alice"); + Guild guild=realm.getOrCreateMainGuild(); + guild.getPillageHits().add(null); + assertTrue(database.saveFactionChecked(realm)); + FactionData saved=JsonUtil.readJson(disk.root.resolve("Data/realm.json").toFile(),FactionData.class); + assertEquals("realm",saved.id);assertTrue(saved.guilds.getFirst().pillageHits.isEmpty()); + } + + @Test void warsBattlesAndWarbandsRoundTripThroughRealJsonAndDeleteIdempotently() throws Exception { + Faction attacker=fixture.saved("attacker","Alice"),defender=fixture.saved("defender","Bob"); + War war=new War(7,attacker,defender);database.saveWar(war); + assertEquals(7,database.loadWars().getFirst().getId()); + Battle battle=BattleFactory.createBlank(BattleType.FIELD,"training");database.saveBattle(battle); + assertEquals("training",database.loadBattles().getFirst().getId()); + Warband band=Warband.createWithMemberIds("scouts",UUID.randomUUID(),true);database.saveWarband(band); + assertEquals(band.getMemberIds(),database.loadWarbands().getFirst().getMemberIds()); + database.saveBattle(null);database.saveWarband(null);database.deleteBattleFile(null);database.deleteWarbandFile(null); + database.deleteWar(war);database.deleteWar(war);assertTrue(database.loadWars().isEmpty()); + database.deleteBattleFile(battle.getId());database.deleteBattleFile(battle.getId());assertTrue(database.loadBattles().isEmpty()); + database.deleteWarbandFile(band.getId());database.deleteWarbandFile(band.getId());assertTrue(database.loadWarbands().isEmpty()); + } + + @ParameterizedTest @ValueSource(strings={"Wars","Battles","Warbands"}) + void absentNonDirectoryAndMalformedStoresDoNotProduceObjects(String kind) throws Exception { + assertTrue(load(kind).isEmpty());disk.write(kind,"not directory");assertTrue(load(kind).isEmpty());disk.remove(kind); + disk.write(kind+"/readme.txt","ignore");disk.write(kind+"/empty.json","null");disk.write(kind+"/broken.json","not-json{"); + assertTrue(load(kind).isEmpty()); + } + + @Test void writeFailuresInBattleWarbandAndWarStoresLeaveExistingEntriesAlone() throws Exception { + Faction attacker=fixture.saved("attacker","Alice"),defender=fixture.saved("defender","Bob"); + War war=new War(7,attacker,defender);Battle battle=BattleFactory.createBlank(BattleType.FIELD,"training"); + Warband band=Warband.createWithMemberIds("scouts",UUID.randomUUID(),true); + for(String dir:List.of("Wars","Battles","Warbands")) disk.write(dir,"occupied"); + assertDoesNotThrow(() -> database.saveWar(war));assertDoesNotThrow(() -> database.saveBattle(battle));assertDoesNotThrow(() -> database.saveWarband(band)); + for(String dir:List.of("Wars","Battles","Warbands")) assertEquals("occupied",Files.readString(disk.root.resolve(dir))); + } + + private List<?> load(String kind) { return switch(kind) {case "Wars" -> database.loadWars();case "Battles" -> database.loadBattles();default -> database.loadWarbands();}; } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/diplomacy/RealmRelationsBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/diplomacy/RealmRelationsBoundaryCoverageTest.java new file mode 100644 index 00000000..737658b5 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/diplomacy/RealmRelationsBoundaryCoverageTest.java @@ -0,0 +1,658 @@ +package net.tfminecraft.simplefactions.diplomacy; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.gson.Gson; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import net.tfminecraft.simplefactions.army.LevyEntry; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.database.WarData; +import net.tfminecraft.simplefactions.enums.FactionModifiers; +import net.tfminecraft.simplefactions.loaders.RelationLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.RelationManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.war.civilwar.CivilWarSnapshot; +import net.tfminecraft.simplefactions.war.commitment.LevySnapshotCalculator; +import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.core.WarCommitment; +import net.tfminecraft.simplefactions.war.core.WarMapper; +import net.tfminecraft.simplefactions.war.enums.WarGoalType; +import net.tfminecraft.simplefactions.war.pathfinder.BelligerentTerritory; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class RealmRelationsBoundaryCoverageTest { + private static final int WAR_ID = 551091; + private FactionDomainFixture domain; + private List<WarCommitment> previousCommitments; + private Locale previousLocale; + + @BeforeEach + void setUp() { + previousLocale = Locale.getDefault(); + previousCommitments = new ArrayList<>(WarCommitmentService.getCommitmentsForWar(WAR_ID)); + WarCommitmentService.clearCommitments(WAR_ID); + domain = new FactionDomainFixture(); + } + + @AfterEach + void tearDown() { + try { + domain.close(); + } finally { + WarCommitmentService.restoreCommitments(WAR_ID, previousCommitments); + Locale.setDefault(previousLocale); + } + } + + @Test + void lateSubjectLeviesUseTheSameCaseIndependentIdentityAsWarCommitments() { + domain.lawGroup("conscription", Map.of("effects.faction.modifiers", List.of("LEVY(100)"))); + Faction holder = domain.saved("IRON", "IronLeader"); + Faction enemy = domain.saved("enemy", "EnemyLeader"); + War war = new War(WAR_ID, holder, enemy); + WarManager.get().add(war); + Faction source = domain.saved("IRIS", "IrisLeader"); + domain.subject(holder, source); + Regiment guard = regiment(source, "guard", 3); + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + + List<WarCommitment> rows = WarCommitmentService.snapshotLevyForFighter(war, holder); + + assertEquals(1, rows.size(), "A late subject must still contribute to its uppercase-ID holder"); + assertEquals(holder.getId(), rows.getFirst().factionId()); + assertEquals(source.getId(), rows.getFirst().sourceFactionId()); + assertEquals(1, rows.getFirst().count(), "The one actual member caps the contribution"); + assertEquals(3, guard.getCurrentSlots(), "Snapshotting must not consume the live army"); + assertTrue(WarCommitmentService.snapshotLevyForFighter(war, holder).isEmpty()); + } + + @Test + void releasingAnUppercaseSubjectRemovesItsCommittedLevyUnderTurkishLocale() { + domain.lawGroup("conscription", Map.of("effects.faction.modifiers", List.of("LEVY(100)"))); + Faction holder = domain.saved("holder", "HolderLeader"); + War war = new War(WAR_ID, holder, domain.saved("enemy", "EnemyLeader")); + WarManager.get().add(war); + Faction source = domain.saved("IRIS", "IrisLeader"); + domain.subject(holder, source); + Regiment guard = regiment(source, "guard", 2); + assertEquals(1, WarCommitmentService.snapshotLevyForFighter(war, holder).size()); + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + + WarCommitmentService.removeLevySubtree(source); + + assertTrue(WarCommitmentService.getCommitmentsForWar(WAR_ID).isEmpty()); + assertEquals(2, guard.getCurrentSlots(), "Releasing a levy cannot erase its source soldiers"); + } + + @Test + void incompleteSavedParticipantsCannotPromoteAnAllyToMainDefender() { + Faction attacker = domain.saved("attacker", "AttackerLeader"); + Faction defender = domain.saved("defender", "DefenderLeader"); + Faction ally = domain.saved("ally", "AllyLeader"); + War war = new War(WAR_ID, attacker, defender); + war.getDefenders().addNewParticipant(ally, war.getDefenders().getMainParticipants().getFirst()); + WarData saved = WarMapper.toData(war); + saved.defenders.participants.removeIf(row -> row.leader.equals(defender.getId())); + Gson gson = new Gson(); + War restored = WarMapper.fromData(gson.fromJson(gson.toJson(saved), WarData.class)); + assertNotNull(restored); + assertSame(defender, restored.getDefenders().getLeader()); + Map<Integer, String> owners = + Map.of(10, attacker.getId(), 20, defender.getId(), 30, ally.getId()); + + BelligerentTerritory territory = BelligerentTerritory.fromWar(restored, owners::get); + + assertTrue(territory.isDefenderSide(20), "The saved side leader remains a belligerent"); + assertTrue(territory.isMainDefenderRealm(20), "The war objective stays in the defender realm"); + assertTrue(territory.isDefenderSide(30), "A valid ally is still on the defending side"); + assertFalse(territory.isMainDefenderRealm(30), "An ally cannot inherit the missing main row"); + } + + @Test + void reloadedCivilWarSuppressesRebelLawGrantsWithoutTakingTheLoyalArmyGrants() { + domain.regiment("guard", false, 0, 0); + domain.lawGroup("government", Map.of("effects.faction.regiments", List.of("guard 4"))); + Faction rebels = domain.saved("rebels", "RebelLeader"); + Faction host = domain.saved("host", "HostLeader"); + rebels.refreshLawSlots(); + host.refreshLawSlots(); + Regiment rebelGuard = rebels.getMilitary().getRegiment("guard"); + Regiment loyalGuard = host.getMilitary().getRegiment("guard"); + assertEquals(4, rebelGuard.getFreeSlots()); + assertEquals(4, loyalGuard.getFreeSlots()); + rebelGuard.setCurrentSlots(6); + loyalGuard.setCurrentSlots(7); + CivilWarSnapshot snapshot = new CivilWarSnapshot(); + snapshot.setTempRebelFactionId(rebels.getId()); + snapshot.setHostFactionId(host.getId()); + War war = new War(WAR_ID, rebels, host); + war.setGoal(WarGoalType.CHANGE_LAW); + war.setMovementId("rebel-law-movement"); + war.setCivilWarSnapshot(snapshot); + Gson gson = new Gson(); + War restored = + WarMapper.fromData(gson.fromJson(gson.toJson(WarMapper.toData(war)), WarData.class)); + assertNotNull(restored); + WarManager.get().add(restored); + + rebels.refreshLawSlots(); + host.refreshLawSlots(); + + assertEquals(0, rebelGuard.getFreeSlots(), "Rebels retain only their paid split-off troops"); + assertEquals(2, rebelGuard.getCurrentSlots()); + assertEquals( + 4, loyalGuard.getFreeSlots(), "The loyal faction still receives its lawful grants"); + assertEquals(7, loyalGuard.getCurrentSlots(), "The loyal faction keeps its paid troops too"); + assertEquals(3, loyalGuard.getPaidSlots()); + } + + @Test + void tradeAndTreatyPartnersConsumeCapacityAndConvergeOnTheirCombinedOpinion() { + Faction origin = domain.saved("origin", "OriginLeader"); + Faction trader = domain.saved("trader", "TraderLeader"); + Faction treatyPartner = domain.saved("treaty_partner", "TreatyLeader"); + Faction removed = domain.saved("removed", "RemovedLeader"); + RelationType trade = + domain.relationType("trade", Map.of("trade-agreement", true, "cost", 6.0, "target", 4)); + RelationType treaty = + domain.relationType("peace", Map.of("treaty", true, "cost", 3.0, "target", -2)); + DiplomacyHandler handler = origin.getDiplomacyHandler(); + handler.setTradeRelation(trader, trade); + handler.setTreatyRelation(trader, treaty); + handler.setTreatyRelation(treatyPartner, treaty); + handler.setTradeRelation(removed, trade); + handler.setTreatyRelation(removed, treaty); + FactionManager.factions.remove(removed); + double expectedCost = + RelationManager.getDiplomaticCost(origin, trader, trade) + + RelationManager.getDiplomaticCost(origin, trader, treaty) + + RelationManager.getDiplomaticCost(origin, treatyPartner, treaty); + + handler.updateRelations(); + + Relation tradingRelation = handler.getRelation(trader.getId()); + assertTrue(tradingRelation.isDefault()); + assertEquals(1, tradingRelation.getOpinion()); + assertEquals(-1, handler.getRelation(treatyPartner.getId()).getOpinion()); + assertEquals(expectedCost, handler.getUsedDiplomaticCapacity(), 0.00001); + for (int tick = 0; tick < 5; tick++) handler.updateRelations(); + assertSame(tradingRelation, handler.getRelation(trader.getId())); + assertEquals(2, tradingRelation.getOpinion()); + assertEquals(-2, handler.getRelation(treatyPartner.getId()).getOpinion()); + handler.removeTradeRelation(trader.getId()); + handler.updateRelations(); + assertEquals( + 1, + tradingRelation.getOpinion(), + "Ending trade lowers the target without resetting history"); + } + + @Test + void relationModifiersAndThresholdsDriveTheConfiguredDecision() { + RelationType pact = + domain.relationType( + "pact", + Map.of( + "name", + "Pact", + "prefix", + "Our ", + "give-modifiers", + List.of("LEVY(25)"), + "recieve-modifiers", + List.of("TRADE_POWER(7)"), + "threshold.mode", + "higher_than_or_equal_to", + "threshold.amount", + 10)); + Relation relation = new Relation(pact, RelationLoader.getDefaultAttitude(), 9); + Faction origin = domain.saved("origin", "OriginLeader"); + Faction target = domain.saved("target", "TargetLeader"); + origin.setRelation(target, relation); + assertFalse(pact.fulfilled(relation.getOpinion())); + assertEquals("higher than or equal to", pact.getFormattedThresholdType()); + assertEquals(">=", pact.getThreshold().getFormattedShort()); + assertEquals("Our Pact", pact.getFull()); + assertEquals( + "Our ", pact.getPrefix(), "Configured diplomatic labels retain their display prefix"); + assertEquals(25.0, relation.getGiveModifier(FactionModifiers.LEVY)); + assertEquals(0.0, relation.getGiveModifier(FactionModifiers.TRADE_POWER)); + assertEquals(7.0, relation.getRecieveModifier(FactionModifiers.TRADE_POWER)); + assertEquals(0.0, relation.getRecieveModifier(FactionModifiers.LEVY)); + RelationType cooling = + domain.relationType( + "cooling", Map.of("threshold.mode", "lower_than_or_equal_to", "threshold.amount", -10)); + assertFalse(cooling.fulfilled(-9)); + assertTrue(cooling.fulfilled(-10)); + assertTrue(cooling.fulfilled(-11)); + assertEquals("<=", cooling.getThreshold().getFormattedShort()); + } + + @Test + void anUnknownThresholdModeCannotSilentlyAuthorizeEveryOpinion() { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("mode", "greater_then"); + yaml.set("amount", 20); + IllegalArgumentException failure = + assertThrows(IllegalArgumentException.class, () -> new Threshold(yaml)); + assertTrue(failure.getMessage().contains("greater_then")); + } + + @Test + void diplomacyDirectoryKeepsPartnersSeparatedAndSkipsMissingRegistryEntries() { + Faction origin = domain.saved("origin", "OriginLeader"); + Faction partner = domain.saved("partner", "PartnerLeader"); + Faction stranger = domain.saved("stranger", "StrangerLeader"); + RelationType trade = domain.relationType("trade", Map.of("trade-agreement", true)); + origin.getDiplomacyHandler().setTradeRelation(partner, trade); + Faction broken = domain.saved("broken", "BrokenLeader"); + broken.setId(null); + FactionManager.factions.add(1, null); + List<DiplomacyQueries.DiplomacyListEntry> directory = DiplomacyQueries.ownDirectory(origin); + assertEquals(20, directory.size()); + assertSame(partner, directory.getFirst().getFaction()); + assertEquals(DiplomacyQueries.DiplomacyListEntry.Kind.FACTION, directory.getFirst().getKind()); + assertTrue( + directory.subList(1, 19).stream() + .allMatch( + entry -> + entry.getKind() == DiplomacyQueries.DiplomacyListEntry.Kind.SEPARATOR + && entry.getFaction() == null)); + assertSame(stranger, directory.getLast().getFaction()); + assertTrue(DiplomacyQueries.officialPartners(null).isEmpty()); + assertTrue(DiplomacyQueries.otherFactions(null).isEmpty()); + broken.setId("broken"); + } + + @Test + void nestedLevyRatesCapContributionsAndTheDisplayedLedgerTracksAdjustments() { + domain.lawGroup("conscription", Map.of("effects.faction.modifiers", List.of("LEVY(100)"))); + Faction holder = domain.saved("holder", "HolderLeader"); + Faction middle = domain.saved("middle", "MiddleLeader"); + Faction source = domain.saved("source", "SourceLeader"); + source.getOrCreateMainGuild().getMembers().add("SecondSoldier"); + domain.subject(holder, middle); + domain.subject(middle, source); + var half = + domain.law( + "conscription", "half", Map.of("effects.faction.modifiers", List.of("LEVY(50)"))); + middle.getLawHandler().getGroup("conscription").setCurrent(half); + Regiment guard = regiment(source, "guard", 5); + assertEquals(2, LevySnapshotCalculator.directLevyContribution(source)); + assertEquals(1, LevySnapshotCalculator.levyContribution(source, holder)); + assertSame( + middle, + LevySnapshotCalculator.findNearestFighterHolder(source, Set.of("middle", "holder"))); + List<LevyEntry> entries = holder.getMilitary().getLevies(); + assertEquals( + 1, + entries.stream() + .filter(entry -> entry.getFrom() == source) + .mapToInt(LevyEntry::getAmount) + .sum()); + YamlConfiguration levyConfig = new YamlConfiguration(); + levyConfig.set("item.material", "PAPER"); + levyConfig.set("item.model-data", 19); + levyConfig.set("description", List.of("Realm contributions")); + levyConfig.set("levy", true); + Regiment levy = new Regiment("levy", levyConfig); + holder.getMilitary().getRegiments().add(levy); + assertEquals(1, holder.getMilitary().getManpower(false)); + levy.setLevyEntries(new ArrayList<>(entries)); + assertEquals(1, levy.getLevyTotal()); + LevyEntry contribution = levy.getEntry(source); + contribution.decrease(); + contribution.decrease(); + assertEquals(0, levy.getLevyTotal(), "Losses cannot take a displayed contribution below zero"); + contribution.increase(); + contribution.setAmount(3); + assertEquals(3, levy.getLevyTotal()); + Faction reinforcement = domain.saved("reinforcement", "ReinforcementLeader"); + levy.addLevyEntry(new LevyEntry(reinforcement, 2)); + assertEquals(5, levy.getLevyTotal()); + assertNull(levy.getEntry(holder)); + assertEquals(List.of("Realm contributions"), new Regiment(levy).getDescription()); + assertEquals(19, levy.getIcon().getItemMeta().getCustomModelData()); + assertEquals( + 5, + guard.getCurrentSlots(), + "Calculating and adjusting a ledger must preserve source strength"); + } + + @Test + void levyQueriesRefuseMissingPathsAndZeroIntermediateContributions() { + domain.lawGroup("conscription", Map.of("effects.faction.modifiers", List.of("LEVY(100)"))); + Faction holder = domain.saved("holder", "HolderLeader"); + Faction middle = domain.saved("middle", "MiddleLeader"); + Faction source = domain.saved("source", "SourceLeader"); + regiment(source, "guard", 2); + assertEquals(0, LevySnapshotCalculator.levyContribution(source, holder)); + assertNull(LevySnapshotCalculator.findNearestFighterHolder(source, Set.of("holder"))); + domain.subject(holder, middle); + domain.subject(middle, source); + var none = + domain.law("conscription", "none", Map.of("effects.faction.modifiers", List.of("LEVY(0)"))); + middle.getLawHandler().getGroup("conscription").setCurrent(none); + assertEquals(0, LevySnapshotCalculator.levyContribution(source, holder)); + assertTrue( + LevySnapshotCalculator.collectLevyRowsForFighter(holder, Set.of("holder")).isEmpty()); + assertTrue( + LevySnapshotCalculator.collectLevyRowsForFighter(holder, Set.of("unrelated_fighter")) + .isEmpty()); + FactionManager.factions.remove(middle); + assertNull(LevySnapshotCalculator.findNearestFighterHolder(source, Set.of("holder"))); + assertEquals(0, LevySnapshotCalculator.levyContribution(source, holder)); + assertEquals(0, LevySnapshotCalculator.directLevyContribution(null)); + assertEquals(0, LevySnapshotCalculator.levyContribution(source, null)); + assertNull(LevySnapshotCalculator.findNearestFighterHolder(source, Set.of())); + assertTrue(LevySnapshotCalculator.collectLevyRows(null).isEmpty()); + assertTrue(LevySnapshotCalculator.collectLevyRowsForFighter(holder, Set.of()).isEmpty()); + assertTrue(LevySnapshotCalculator.collectSubjectSubtreeIds(null).isEmpty()); + } + + @Test + void internalWarTransitRecognizesTheLiegeAndNeutralPeersWithoutExpandingObjectives() { + Faction liege = domain.saved("liege", "LiegeLeader"); + Faction attacker = domain.saved("attacker", "AttackerLeader"); + Faction defender = domain.saved("defender", "DefenderLeader"); + Faction peer = domain.saved("peer", "PeerLeader"); + domain.subject(liege, attacker); + domain.subject(liege, defender); + domain.subject(liege, peer); + War war = new War(WAR_ID, attacker, defender); + Map<Integer, String> owners = + Map.of(1, "attacker", 2, "defender", 3, "liege", 4, "peer", 5, "removed"); + BelligerentTerritory territory = BelligerentTerritory.fromWar(war, owners::get); + assertTrue(territory.isLiegeTransit(3)); + assertTrue(territory.isLiegeTransit(4)); + assertFalse(territory.isForeignNation(4)); + assertFalse(territory.isLiegeTransit(1)); + assertFalse(territory.isLiegeTransit(2)); + assertFalse(territory.isLiegeTransit(5)); + assertTrue(territory.isForeignNation(5)); + assertFalse(territory.isLiegeTransit(6)); + BelligerentTerritory noLookup = + new BelligerentTerritory( + Set.of("attacker"), Set.of("defender"), Set.of("defender"), owners::get, "liege", null); + assertTrue(noLookup.isLiegeTransit(3)); + assertFalse(noLookup.isLiegeTransit(4)); + BelligerentTerritory empty = new BelligerentTerritory(null, null, owners::get); + assertFalse(empty.isAttackerSide(1)); + assertFalse(empty.isDefenderSide(2)); + assertTrue(empty.isForeignNation(1)); + assertFalse(territory.isAdjacentToSea(domain.provinces, 999)); + } + + @ParameterizedTest + @ValueSource(strings = {"missing", "malformed", "scalar", "missing_sections"}) + void failedDiplomacyReloadPreservesBothLiveRegistries(String failure, @TempDir Path temp) + throws Exception { + List<RelationType> oldTypes = new ArrayList<>(RelationLoader.types); + List<Attitude> oldAttitudes = new ArrayList<>(RelationLoader.attitudes); + Path config = temp.resolve("diplomacy.yml"); + if (failure.equals("missing_sections")) Files.writeString(config, "unrelated: true\n"); + if (failure.equals("malformed")) Files.writeString(config, "types: [unterminated\n"); + if (failure.equals("scalar")) + Files.writeString( + config, + """ + types: + first: + name: New + broken: true + attitudes: + first: + name: New + broken: true + """); + RelationLoader loader = new RelationLoader(); + assertAll( + () -> assertThrows(IllegalStateException.class, () -> loader.loadRelationTypes(config.toFile())), + () -> assertThrows(IllegalStateException.class, () -> loader.loadAttitudes(config.toFile())), + () -> assertEquals(oldTypes, RelationLoader.types), + () -> assertEquals(oldAttitudes, RelationLoader.attitudes)); + } + + @Test + void invalidThresholdCannotPublishEarlierValidRelationTypes(@TempDir Path temp) throws Exception { + List<RelationType> oldTypes = new ArrayList<>(RelationLoader.types); + Path config = temp.resolve("diplomacy.yml"); + Files.writeString( + config, + """ + types: + first: + name: New + broken: + threshold: + mode: greater_then + amount: 20 + """); + assertThrows(IllegalStateException.class, () -> new RelationLoader().loadRelationTypes(config.toFile())); + assertEquals(oldTypes, RelationLoader.types); + } + + @Test + void aValidDiplomacyReloadRecoversAfterFailureAndReplacesTheOldDefinitions(@TempDir Path temp) + throws Exception { + Path config = temp.resolve("diplomacy.yml"); + RelationLoader loader = new RelationLoader(); + RelationType previous = RelationLoader.getDefaultType(); + assertThrows(IllegalStateException.class, () -> loader.loadRelationTypes(config.toFile())); + assertSame(previous, RelationLoader.getDefaultType()); + Files.writeString( + config, + """ + types: + recovered: + name: Recovered + default: true + threshold: + mode: higher_than_or_equal_to + amount: 7 + attitudes: + friendly: + name: Friendly + default: true + target: 4 + """); + loader.loadRelationTypes(config.toFile()); + loader.loadAttitudes(config.toFile()); + assertEquals(1, RelationLoader.getTypes().size()); + assertEquals(1, RelationLoader.getAttitudes().size()); + assertEquals("recovered", RelationLoader.getDefaultType().getId()); + assertNotSame(previous, RelationLoader.getDefaultType()); + assertFalse(RelationLoader.getDefaultType().fulfilled(6)); + assertTrue(RelationLoader.getDefaultType().fulfilled(7)); + assertEquals("friendly", RelationLoader.getDefaultAttitude().getId()); + assertEquals(4, RelationLoader.getDefaultAttitude().getTarget()); + Files.writeString(config, Files.readString(config).replace("default: true", "default: false")); + loader.loadRelationTypes(config.toFile()); + loader.loadAttitudes(config.toFile()); + assertEquals( + "recovered", + RelationLoader.getDefaultType().getId(), + "Legacy lists without a default flag use their first definition"); + assertEquals("friendly", RelationLoader.getDefaultAttitude().getId()); + } + + @Test + void missingAndCompanyOwnedRegimentsCannotBeExpandedThroughFactionAdministration() { + Faction faction = domain.saved("faction", "Leader"); + var military = faction.getMilitary(); + assertFalse(military.enqueue(null)); + assertTrue(military.getQueue().isEmpty()); + YamlConfiguration companyConfig = new YamlConfiguration(); + companyConfig.set("item.material", "IRON_SWORD"); + companyConfig.set("mercenary", true); + companyConfig.set("default-slots", 3); + Regiment companyRegiment = new Regiment("company", companyConfig); + military.getRegiments().add(companyRegiment); + var result = military.adminAdjustSlots("company", 1); + assertFalse(result.allowed()); + assertEquals(3, companyRegiment.getCurrentSlots()); + assertTrue(military.getQueue().isEmpty()); + } + + @ParameterizedTest + @ValueSource(ints = {0, -25}) + void endingALevyObligationClearsTheOldSentLedgerWithoutDeletingSoldiers(int nextRate) { + domain.lawGroup("conscription", Map.of("effects.faction.modifiers", List.of("LEVY(100)"))); + Faction holder = domain.saved("holder", "HolderLeader"); + Faction source = domain.saved("source", "SourceLeader"); + domain.subject(holder, source); + Regiment guard = regiment(source, "guard", 3); + assertEquals(1, holder.getMilitary().getLevies().stream().mapToInt(LevyEntry::getAmount).sum()); + assertEquals(1, guard.sentToOverlord()); + var none = + domain.law( + "conscription", + "none", + Map.of("effects.faction.modifiers", List.of("LEVY(" + nextRate + ")"))); + source.getLawHandler().getGroup("conscription").setCurrent(none); + + assertEquals(0, holder.getMilitary().getLevies().stream().mapToInt(LevyEntry::getAmount).sum()); + + assertEquals( + 0, + guard.sentToOverlord(), + "A released contribution cannot remain available to levy losses or army lore"); + assertEquals(3, guard.getCurrentSlots()); + assertEquals(List.of("SourceLeader"), source.getMembers()); + } + + @Test + void reciprocalModifiersRemainAttributedToTheirActualPartner() { + Faction origin = domain.saved("origin", "OriginLeader"); + Faction partner = domain.saved("partner", "PartnerLeader"); + RelationType outgoing = + domain.relationType("outgoing", Map.of("recieve-modifiers", List.of("LEVY(5)"))); + RelationType incoming = + domain.relationType("incoming", Map.of("give-modifiers", List.of("TRADE_POWER(7)"))); + YamlConfiguration attitudeConfig = new YamlConfiguration(); + attitudeConfig.set("recieve-modifiers", List.of("PRESTIGE(2)")); + Attitude attitude = new Attitude("supportive", attitudeConfig); + origin.setRelation(partner, new Relation(outgoing, attitude)); + partner.setRelation(origin, new Relation(incoming, RelationLoader.getDefaultAttitude())); + var modifiers = origin.getDiplomacyHandler().getModifiers(); + assertEquals(3, modifiers.size()); + assertTrue(modifiers.stream().allMatch(modifier -> modifier.getFrom() == partner)); + Map<FactionModifiers, Double> amounts = + modifiers.stream() + .collect( + java.util.stream.Collectors.toMap( + modifier -> modifier.getType(), modifier -> modifier.getAmount())); + assertEquals( + Map.of( + FactionModifiers.LEVY, + 5.0, + FactionModifiers.TRADE_POWER, + 7.0, + FactionModifiers.PRESTIGE, + 2.0), + amounts); + RelationType trade = + domain.relationType( + "trade", Map.of("trade-agreement", true, "trade-effects-them", List.of("TRIBUTE(3)"))); + origin.getDiplomacyHandler().setTradeRelation(partner, trade); + var tradeEffects = origin.getDiplomacyHandler().getTradeModifiersFor(partner.getId()); + assertEquals(1, tradeEffects.size()); + assertEquals(FactionModifiers.TRIBUTE, tradeEffects.getFirst().getType()); + assertEquals(3.0, tradeEffects.getFirst().getAmount()); + assertSame(partner, tradeEffects.getFirst().getFrom()); + assertTrue(origin.getDiplomacyHandler().getTradeModifiersFor("unknown").isEmpty()); + } + + @Test + void loadedVassalOptionsKeepElevationAndWarChoiceSeparate() { + assertNull(RelationLoader.getElevationTarget()); + RelationType elevated = + domain.relationType( + "elevated", + Map.of( + "vassal", + true, + "elevation-target", + true, + "can-pick-for-war", + false, + "link", + "overlord")); + assertSame(elevated, RelationLoader.getElevationTarget()); + assertEquals("overlord", elevated.getLinkString()); + assertFalse(RelationLoader.isWarPickableVassal(elevated)); + assertTrue(RelationLoader.isWarPickableVassal(RelationLoader.getType("vassal"))); + assertFalse(RelationLoader.isWarPickableVassal(null)); + RelationLoader.types.remove(elevated); + assertNull(RelationLoader.getElevationTarget()); + } + + @Test + void restoredExpansionQueueAdvancesOnlyItsHeadAndHonoursTheThreeEntryLimit() { + Faction faction = domain.saved("faction", "Leader"); + Regiment guard = regiment(faction, "guard", 0); + var military = faction.getMilitary(); + military.addQueueItem(guard, 2); + military.addQueueItem(guard, 1); + military.addQueueItem(guard, 1); + military.addQueueItem(guard, 1); + assertEquals(3, military.getQueue().size()); + military.tick(); + assertEquals(0, guard.getCurrentSlots()); + assertEquals(1, military.getQueue().getFirst().getTimeLeft()); + assertEquals(1, military.getQueue().get(1).getTimeLeft()); + military.tick(); + assertEquals(1, guard.getCurrentSlots()); + assertEquals(2, military.getQueue().size()); + military.tick(); + military.tick(); + assertEquals(3, guard.getCurrentSlots()); + assertTrue(military.getQueue().isEmpty()); + military.tick(); + assertEquals(3, guard.getCurrentSlots()); + } + + @Test + void defendingSubjectsAndJoinedBackersRemainBelligerentsInTerritoryQueries() { + Faction attacker = domain.saved("attacker", "AttackerLeader"); + Faction defender = domain.saved("defender", "DefenderLeader"); + Faction subject = domain.saved("subject", "SubjectLeader"); + Faction backer = domain.saved("backer", "BackerLeader"); + domain.subject(defender, subject); + War war = new War(WAR_ID, attacker, defender); + assertTrue(war.getDefenders().getMainParticipants().getFirst().addBacker(backer)); + Map<Integer, String> owners = Map.of(1, "attacker", 2, "defender", 3, "subject", 4, "backer"); + BelligerentTerritory territory = BelligerentTerritory.fromWar(war, owners::get); + assertTrue(territory.isDefenderSide(3)); + assertTrue(territory.isMainDefenderRealm(3)); + assertTrue(territory.isDefenderSide(4)); + assertFalse(territory.isNeutral(4)); + assertFalse(territory.isAttackerSide(4)); + assertTrue(territory.isAttackerSide(1)); + } + + private Regiment regiment(Faction faction, String id, int slots) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("item.material", "PAPER"); + yaml.set("default-slots", slots); + Regiment regiment = new Regiment(id, yaml); + faction.getMilitary().getRegiments().add(regiment); + return regiment; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterBoundaryCoverageTest.java new file mode 100644 index 00000000..1b21dc66 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/CharacterBoundaryCoverageTest.java @@ -0,0 +1,300 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.CopyOption; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import net.Indyuce.mmocore.api.player.attribute.PlayerAttributes; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.objects.attributes.AttributeData; +import net.tfminecraft.rpcharacters.objects.attributes.AttributeModifier; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.loaders.RegimentLoader; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class CharacterBoundaryCoverageTest { + @TempDir Path directory; + + @Test + void mmocoreSuppliesPermanentAttributesAndRoleplayFillsMissingInstances() { + try (var fixture = new FactionDomainFixture(); + var roleplay = mockStatic(net.tfminecraft.rpcharacters.managers.PlayerManager.class); + var mmocore = mockStatic(net.Indyuce.mmocore.api.player.PlayerData.class)) { + var player = fixture.player("Spy"); + when(Bukkit.getPluginManager().isPluginEnabled("RPCharacters")).thenReturn(true); + when(Bukkit.getPluginManager().isPluginEnabled("MMOCore")).thenReturn(true); + var roleplayData = mock(net.tfminecraft.rpcharacters.objects.PlayerData.class); + var character = mock(RPCharacter.class); + var roleplayAttributes = mock(AttributeData.class); + roleplay + .when(() -> net.tfminecraft.rpcharacters.managers.PlayerManager.get(player)) + .thenReturn(roleplayData); + when(roleplayData.getActiveCharacter()).thenReturn(character); + when(character.getAttributeData()).thenReturn(roleplayAttributes); + when(roleplayAttributes.getAmount(any(AttributeModifier.class))).thenReturn(8); + var mmoData = mock(net.Indyuce.mmocore.api.player.PlayerData.class); + var mmoAttributes = mock(PlayerAttributes.class); + var intelligence = mock(PlayerAttributes.AttributeInstance.class); + when(intelligence.getBase()).thenReturn(15); + when(intelligence.getTotal()).thenReturn(99); + when(mmoData.getAttributes()).thenReturn(mmoAttributes); + when(mmoAttributes.getInstance("intelligence")).thenReturn(intelligence); + mmocore.when(() -> net.Indyuce.mmocore.api.player.PlayerData.get(player)).thenReturn(mmoData); + Map<String, Integer> values = OfficeCharacters.attributes(player); + assertEquals(EspionageConfig.DEFAULT_WEIGHTS.keySet(), values.keySet()); + assertEquals(15, values.get("intelligence")); + for (String attribute : EspionageConfig.DEFAULT_WEIGHTS.keySet()) { + if (!attribute.equals("intelligence")) assertEquals(8, values.get(attribute)); + } + verify(intelligence, never()).getTotal(); + verify(mmoAttributes, times(EspionageConfig.DEFAULT_WEIGHTS.size())).getInstance(anyString()); + } + } + + @Test + void aptitudeRegistryFallsBackToRegularMoveWithoutLosingEarlierCharacters() throws Exception { + Path file = directory.resolve("aptitudes.json"); + Path pending = directory.resolve("aptitudes.json.tmp"); + var registry = new CharacterAptitudes(file); + assertEquals(34, registry.aptitude("first", () -> 34)); + AtomicInteger atomicAttempts = new AtomicInteger(); + AtomicInteger fallbackAttempts = new AtomicInteger(); + try (var files = + mockStatic( + Files.class, + call -> { + if (call.getMethod().getName().equals("move")) { + Object[] args = call.getRawArguments(); + if (pending.equals(args[0]) && file.equals(args[1])) { + CopyOption[] options = (CopyOption[]) args[2]; + if (Arrays.asList(options).contains(StandardCopyOption.ATOMIC_MOVE)) { + atomicAttempts.incrementAndGet(); + throw new AtomicMoveNotSupportedException( + pending.toString(), file.toString(), "filesystem boundary"); + } + fallbackAttempts.incrementAndGet(); + } + } + return call.callRealMethod(); + })) { + assertEquals(78, registry.aptitude("second", () -> 78)); + } + assertEquals(1, atomicAttempts.get()); + assertEquals(1, fallbackAttempts.get()); + assertFalse(Files.exists(pending)); + var restarted = new CharacterAptitudes(file); + restarted.load(); + assertEquals(34, restarted.aptitude("first", () -> fail("Existing identity rerolled"))); + assertEquals(78, restarted.aptitude("second", () -> fail("Fallback write lost new identity"))); + assertEquals( + Map.of("first", 34.0, "second", 78.0), + JsonUtil.GSON.fromJson(Files.readString(file), Map.class)); + } + + @Test + void failedFallbackDoesNotRetainAnUnpersistedAptitude() throws Exception { + Path file = directory.resolve("aptitudes.json"); + Path pending = directory.resolve("aptitudes.json.tmp"); + var registry = new CharacterAptitudes(file); + assertEquals(34, registry.aptitude("existing", () -> 34)); + String original = Files.readString(file); + try (var files = + mockStatic( + Files.class, + call -> { + if (call.getMethod().getName().equals("move")) { + Object[] args = call.getRawArguments(); + if (pending.equals(args[0]) && file.equals(args[1])) { + CopyOption[] options = (CopyOption[]) args[2]; + if (Arrays.asList(options).contains(StandardCopyOption.ATOMIC_MOVE)) { + throw new AtomicMoveNotSupportedException( + pending.toString(), file.toString(), "filesystem boundary"); + } + throw new IOException("replacement refused"); + } + } + return call.callRealMethod(); + })) { + assertEquals( + "replacement refused", + assertThrows(IOException.class, () -> registry.aptitude("new", () -> 88)).getMessage()); + } + assertEquals(original, Files.readString(file)); + assertEquals(34, registry.aptitude("existing", () -> fail("Existing aptitude changed"))); + assertEquals( + 51, + registry.aptitude("new", () -> 51), + "Failed save must permit a later durable first roll"); + var restarted = new CharacterAptitudes(file); + restarted.load(); + assertEquals(51, restarted.aptitude("new", () -> fail("Successful retry was not persisted"))); + } + + @Test + void unknownFutureCashflowRetainsASignedUsefulRangeWithoutChangingTheSnapshot() { + String metric = "Guild:traders:Cashflow:FUTURE_CATEGORY"; + var raw = new EspionageMath.Estimate(-80, -40); + assertFalse(IntelligenceRanges.nonnegative(metric)); + assertEquals(raw, IntelligenceRanges.reasonable(metric, raw)); + var report = new IntelligenceReport(); + report.quality = IntelligenceTier.RELIABLE.key(); + report.estimates.put(metric, raw); + assertEquals("-80 to -40", report.display(metric)); + assertSame(raw, report.estimates.get(metric)); + assertNull(IntelligenceRanges.reasonable(metric, new EspionageMath.Estimate(-80, 40))); + } + + @Test + void trainingDetailsRemainTheDailySnapshotAfterTheLiveQueueChanges() { + try (var fixture = new FactionDomainFixture()) { + var first = trainingRegiment("spearmen"); + var second = trainingRegiment("archers"); + var third = trainingRegiment("cavalry"); + fixture.lawGroup("tariffs", Map.of()); + var target = fixture.saved("target", "Leader"); + assertTrue(target.getMilitary().enqueue(first)); + assertTrue(target.getMilitary().enqueue(second)); + assertTrue(target.getMilitary().enqueue(third)); + var report = new IntelligenceReport(); + report.quality = IntelligenceTier.RELIABLE.key(); + ReportDetails.capture(report, target); + assertEquals( + List.of("spearmen", "archers", "cavalry"), report.details("training", "training")); + assertEquals(List.of("Current tariffs"), report.details("laws", "law:tariffs")); + var changedLaw = fixture.law("tariffs", "tax_cut", Map.of()); + target.getLawHandler().getGroup("tariffs").setCurrent(changedLaw); + assertTrue(target.getMilitary().cancelQueue(0)); + assertEquals(2, target.getMilitary().getQueue().size()); + var restarted = + JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(report), IntelligenceReport.class); + assertEquals( + List.of("spearmen", "archers", "cavalry"), restarted.details("training", "training")); + assertEquals(List.of("Current tariffs"), restarted.details("laws", "law:tariffs")); + assertEquals("tax_cut", target.getLawHandler().getGroup("tariffs").getCurrent().getName()); + var lowerQuality = new IntelligenceReport(); + lowerQuality.quality = IntelligenceTier.RUMOURS.key(); + ReportDetails.capture(lowerQuality, target); + assertFalse(lowerQuality.details.containsKey("training")); + assertTrue(lowerQuality.details("training", "training").isEmpty()); + } + } + + @Test + void ownRosterCombinesCurrentGuildLeadershipAndOfficeWithoutDuplicatingTheFactionLeader() { + try (var fixture = new FactionDomainFixture()) { + var leader = fixture.player("Alice"); + var home = fixture.saved("home", "Alice"); + home.addMember("Ordinary"); + var artisans = fixture.guild(home, "Artisans", "Boris"); + artisans.addMember("Clerk"); + var appointment = new SpecialPositionAssignment(); + appointment.playerName = "Boris"; + appointment.playerId = fixture.player("Boris").getUniqueId(); + appointment.characterId = "boris-character"; + home.getEspionage().appoint(appointment, 80); + var lines = RosterLore.faction(leader, home); + assertEquals(1, lines.stream().filter(line -> line.contains("Alice")).count()); + assertTrue( + lines.stream() + .anyMatch( + line -> line.contains("Guild Leader / Spymaster") && line.endsWith("Boris"))); + assertTrue(lines.stream().anyMatch(line -> line.endsWith("- Ordinary"))); + assertTrue(lines.stream().anyMatch(line -> line.endsWith("- Clerk"))); + var guildLines = RosterLore.guild(leader, artisans); + int boris = + java.util.stream.IntStream.range(0, guildLines.size()) + .filter(i -> guildLines.get(i).endsWith("Boris")) + .findFirst() + .orElseThrow(); + int clerk = + java.util.stream.IntStream.range(0, guildLines.size()) + .filter(i -> guildLines.get(i).endsWith("Clerk")) + .findFirst() + .orElseThrow(); + assertTrue(boris < clerk); + assertFalse(guildLines.stream().anyMatch(line -> line.contains("Ordinary"))); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void nestedGuiCommandsRestoreTheOuterObserversIntelligenceContext(boolean nestedFails) { + try (var fixture = new FactionDomainFixture(); + var database = mockConstruction(Database.class)) { + var alice = fixture.player("Alice"); + var bob = fixture.player("Bob"); + var first = fixture.saved("first", "Alice"); + var second = fixture.saved("second", "Bob"); + appointLeader(first, alice); + appointLeader(second, bob); + assertNull(EspionageService.report(alice, second)); + assertTrue( + CommandIntelligence.execute( + alice, + () -> { + java.util.function.Supplier<Boolean> inner = + () -> { + new SFInventoryHolder(second.getId(), SFGUI.FACTION_VIEW); + if (nestedFails) throw new IllegalArgumentException("inner command rejected"); + return true; + }; + if (nestedFails) + assertThrows( + IllegalArgumentException.class, + () -> CommandIntelligence.execute(bob, inner)); + else assertTrue(CommandIntelligence.execute(bob, inner)); + assertNotNull(EspionageService.report(bob, first)); + assertNull(EspionageService.report(alice, second)); + new SFInventoryHolder(first.getId(), SFGUI.FACTION_VIEW); + assertNotNull(EspionageService.report(alice, second)); + return true; + })); + assertFalse(database.constructed().isEmpty()); + first.getEspionage().resetReportsAndRolls(); + new SFInventoryHolder(first.getId(), SFGUI.FACTION_VIEW); + assertNull( + EspionageService.report(alice, second), + "A later click outside either command cannot regenerate reports"); + } + } + + private void appointLeader(Faction faction, Player leader) { + var appointment = new SpecialPositionAssignment(); + appointment.playerId = leader.getUniqueId(); + appointment.playerName = leader.getName(); + appointment.characterId = leader.getName() + "-character"; + faction.getEspionage().assignFounder(SpecialPosition.SPYMASTER, appointment, 80); + } + + private Regiment trainingRegiment(String name) { + YamlConfiguration config = new YamlConfiguration(); + config.set("name", name); + config.set("item.material", "PAPER"); + config.set("levy", true); + config.set("default-slots", 1); + Regiment regiment = new Regiment(name, config); + RegimentLoader.oList.add(regiment); + return regiment; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java new file mode 100644 index 00000000..c08e91a8 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageMenusCoverageTest.java @@ -0,0 +1,415 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.nio.file.Path; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.*; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.enums.*; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.*; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.managers.inventory.EspionageView; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemCreator; +import org.bukkit.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +/** Public foreign reports and office workflows using actual faction, report and office state. */ +class EspionageMenusCoverageTest { + @TempDir Path directory; + private FactionDomainFixture fixture; + private Faction home, target; + private Player leader, spy, foreignLeader, visitor; + private Guild guild; + private InventoryManager manager; + private IntelligenceReport report; + private Field aptitudeField; + private Object oldAptitudes; + private MockedStatic<OfficeCharacters> characters; + private MockedStatic<TLibs> items; + private MockedStatic<net.tfminecraft.rpcharacters.calendar.FantasyCalendar> calendar; + private MockedConstruction<Database> databases; + + @BeforeEach + void setup() throws Exception { + fixture = new FactionDomainFixture(); + home = fixture.saved("home", "Leader"); + home.getOrCreateMainGuild(); + home.addMember("Spy"); + target = fixture.saved("target", "ForeignLeader"); + target.getOrCreateMainGuild(); + guild = fixture.guild(target, "merchants", "Baron"); + leader = fixture.player("Leader"); + spy = fixture.player("Spy"); + foreignLeader = fixture.player("ForeignLeader"); + visitor = fixture.player("Visitor"); + characters = mockStatic(OfficeCharacters.class); + characters + .when(() -> OfficeCharacters.activeCharacterId(any())) + .thenAnswer( + call -> + call.getArgument(0) == null + ? null + : "character-" + ((Player) call.getArgument(0)).getName()); + characters.when(() -> OfficeCharacters.attributes(any())).thenReturn(Map.of()); + databases = + mockConstruction( + Database.class, + (database, context) -> when(database.saveFactionChecked(any())).thenReturn(true)); + aptitudeField = EspionageService.class.getDeclaredField("characterAptitudes"); + aptitudeField.setAccessible(true); + oldAptitudes = aptitudeField.get(null); + EspionageService.loadAptitudes(directory.resolve("aptitudes.json")); + assign(home, spy, false, 0); + assign(target, foreignLeader, false, 0); + report = new IntelligenceReport(); + report.quality = "detailed"; + home.getEspionage() + .report( + target.getId(), + target.getFoundedAt(), + LocalDate.now(ZoneOffset.UTC).toEpochDay(), + () -> report); + manager = spy(new InventoryManager()); + doNothing().when(manager).factionView(any(), any()); + doNothing().when(manager).guildView(any(), any()); + items = mockStatic(TLibs.class); + ItemAPI api = mock(ItemAPI.class); + ItemCreator creator = mock(ItemCreator.class); + when(api.getCreator()).thenReturn(creator); + items.when(TLibs::getItemAPI).thenReturn(api); + when(creator.getItemFromPath(anyString())).thenAnswer(call -> new ItemStack(Material.PAPER)); + calendar = mockStatic(net.tfminecraft.rpcharacters.calendar.FantasyCalendar.class); + calendar + .when(() -> net.tfminecraft.rpcharacters.calendar.FantasyCalendar.formatDate(anyLong())) + .thenReturn("7 Harvest, 322 AE"); + when(Bukkit.getOfflinePlayer(any(UUID.class))) + .thenAnswer( + call -> { + OfflinePlayer p = mock(OfflinePlayer.class); + when(p.getUniqueId()).thenReturn(call.getArgument(0)); + return p; + }); + EspionageConfig.load(new YamlConfiguration()); + } + + @AfterEach + void close() throws Exception { + try { + calendar.close(); + items.close(); + characters.close(); + databases.close(); + aptitudeField.set(null, oldAptitudes); + EspionageConfig.load(new YamlConfiguration()); + } finally { + fixture.close(); + } + } + + private SpecialPositionAssignment assign( + Faction faction, Player player, boolean automatic, long time) { + faction.getEspionage().removeSpymaster(); + SpecialPositionAssignment assignment = new SpecialPositionAssignment(); + assignment.playerId = player.getUniqueId(); + assignment.playerName = player.getName(); + assignment.characterId = "character-" + player.getName(); + assignment.automatic = automatic; + faction.getEspionage().appoint(assignment, 80, time); + return assignment; + } + + private Inventory top(Player player) { + return player.getOpenInventory().getTopInventory(); + } + + private SFGUI kind(Player player) { + return ((SFInventoryHolder) top(player).getHolder()).getType(); + } + + private String text(ItemStack item) { + if (item == null) return ""; + var meta = item.getItemMeta(); + return ChatColor.stripColor( + meta.getDisplayName() + + "\n" + + String.join("\n", meta.getLore() == null ? List.of() : meta.getLore())); + } + + private void click(Player player, int slot) { + InventoryClickEvent e = fixture.ui.click(player, slot); + EspionageView.click(e, player, (SFInventoryHolder) top(player).getHolder(), manager); + assertTrue(e.isCancelled()); + } + + private Inventory inventory(String id, SFGUI type, int size) { + return fixture.ui.inventory(new SFInventoryHolder(id, type), size, "test"); + } + + @Test + void foreignFactionAndGuildMenusUseTheDatedReportWithoutRevealingCurrentBalances() { + target.getBank().deposit(1234567.0); + guild.getBank().deposit(7654321.0); + report.estimates.put("Wealth", new EspionageMath.Estimate(100, 150)); + report.estimates.put( + IntelligenceLedger.key(guild, "Wealth"), new EspionageMath.Estimate(200, 250)); + Inventory factionMenu = inventory(target.getId(), SFGUI.FACTION_VIEW, 54); + EspionageView.foreign(factionMenu, leader, target, manager); + assertTrue(text(factionMenu.getItem(4)).contains("7 Harvest")); + assertTrue(text(factionMenu.getItem(4)).contains("Spy")); + assertFalse(text(factionMenu.getItem(12)).contains("1234567")); + assertTrue(text(factionMenu.getItem(12)).contains("100 to 150")); + Inventory guildMenu = inventory(guild.getId(), SFGUI.GUILD_VIEW, 54); + EspionageView.guild(guildMenu, leader, guild, manager); + assertFalse(text(guildMenu.getItem(12)).contains("7654321")); + assertTrue(text(guildMenu.getItem(17)).contains("Dividends")); + assertNotNull(EspionageView.directoryItem(leader, target)); + assertEquals(Material.ENDER_EYE, EspionageView.positionButton().getType()); + assertSame(report, EspionageService.report(leader, target)); + } + + @Test + void missingReportsAndMissingObserverSpymastersHaveDistinctExplanations() { + home.getEspionage().resetReportsAndRolls(); + Inventory menu = inventory(target.getId(), SFGUI.FACTION_VIEW, 54); + EspionageView.foreign(menu, leader, target, manager); + assertTrue(text(menu.getItem(4)).contains("No dated account")); + assertFalse(text(menu.getItem(4)).contains("Absent")); + EspionageView.foreign(menu, visitor, target, manager); + assertTrue(text(menu.getItem(4)).contains("Absent")); + EspionageView.guild(menu, visitor, target.getOrCreateMainGuild(), manager); + assertNull(menu.getItem(17)); + } + + @Test + void allPrivateFactionCardsUseOnlyPermittedReportedMetrics() { + for (MenuItemType type : + List.of( + MenuItemType.WEALTH, + MenuItemType.PRESTIGE, + MenuItemType.MEMBERS, + MenuItemType.MODIFIERS, + MenuItemType.TAX, + MenuItemType.LAWS, + MenuItemType.MILITARY, + MenuItemType.INSTALLATIONS, + MenuItemType.DIPLOMACY)) { + ItemStack card = EspionageView.factionItem(leader, target, type); + assertNotNull(card); + assertFalse(text(card).isBlank()); + } + assertThrows( + IllegalArgumentException.class, + () -> EspionageView.factionItem(leader, target, MenuItemType.BANNER)); + report.estimates.put("Members", new EspionageMath.Estimate(3, 4)); + assertTrue( + text(EspionageView.factionItem(leader, target, MenuItemType.MEMBERS)).contains("3 to 4")); + } + + @Test + void foreignLedgersKeepPersonalGuildHistoryHiddenAndNavigateBackToTheRightOwner() { + EspionageView.foreignLedger(leader, guild, manager); + assertNull(top(leader).getItem(10)); + click(leader, 26); + verify(manager).guildView(leader, guild); + Guild base = target.getOrCreateMainGuild(); + EspionageView.foreignLedger(leader, base, manager); + assertNotNull(top(leader).getItem(10)); + assertTrue(text(top(leader).getItem(15)).contains("Unknown")); + click(leader, 26); + verify(manager).factionView(leader, target); + leader.openInventory(inventory("deleted", SFGUI.FOREIGN_LEDGER_VIEW, 27)); + click(leader, 26); + verify(manager, times(1)).guildView(any(), any()); + } + + @Test + void foreignOfficeViewsUseReportsWhileOwnMembersCanSeeTheExactHolder() { + report.officeHolders.put(SpecialPosition.SPYMASTER, "Recorded Spy"); + report.estimates.put( + IntelligenceReport.officeAptitudeKey(SpecialPosition.SPYMASTER), + new EspionageMath.Estimate(60, 65)); + EspionageView.positions(leader, target, manager); + assertEquals(SFGUI.FOREIGN_POSITIONS_VIEW, kind(leader)); + assertTrue(text(top(leader).getItem(13)).contains("Recorded Spy")); + click(leader, 13); + assertEquals(SFGUI.FOREIGN_POSITIONS_VIEW, kind(leader)); + click(leader, 26); + verify(manager).factionView(leader, target); + EspionageView.foreignPositions(foreignLeader, target, manager); + assertTrue(text(top(foreignLeader).getItem(4)).contains("Exact information")); + assertTrue(text(top(foreignLeader).getItem(13)).contains("80/100")); + home.getEspionage().resetReportsAndRolls(); + assertTrue(text(EspionageView.foreignPositionsItem(leader, target)).contains("Unknown")); + target.getEspionage().removeSpymaster(); + assertTrue(text(EspionageView.foreignPositionsItem(foreignLeader, target)).contains("Vacant")); + FactionManager.factions.remove(target); + click(leader, 13); + verify(leader).closeInventory(); + } + + @Test + void officeNavigationSeparatesPublicOfficeAndPrivateConduct() { + EspionageView.positions(leader, home, manager); + assertEquals(SFGUI.SPECIAL_POSITIONS, kind(leader)); + click(leader, 10); + assertEquals(SFGUI.SPYMASTER_VIEW, kind(leader)); + assertNotNull(top(leader).getItem(11)); + assertNotNull(top(leader).getItem(15)); + assertTrue(text(top(leader).getItem(13)).contains("Spymaster: Spy")); + Inventory publicOffice = top(leader); + click(leader, 13); + assertSame(publicOffice, top(leader)); + EspionageView.spymasterOffice(spy, home, manager); + assertNull(top(spy).getItem(11)); + click(spy, 13); + assertEquals(SFGUI.SPYMASTER_SETTINGS, kind(spy)); + click(spy, 26); + assertEquals(SFGUI.SPYMASTER_VIEW, kind(spy)); + click(spy, 26); + assertEquals(SFGUI.SPECIAL_POSITIONS, kind(spy)); + click(spy, 26); + verify(manager).factionView(spy, home); + EspionageView.spymasterOffice(visitor, home, manager); + verify(visitor, never()).openInventory(any(Inventory.class)); + } + + @Test + void privateConductCyclesEachSettingAndPreservesReportsAndTheOtherSetting() { + EspionageView.settings(spy, home, manager); + SpecialPositionAssignment assignment = home.getEspionage().getSpymaster(); + for (int expected : new int[] {25, 50, 75, 100, 0}) { + click(spy, 11); + assertEquals(expected, assignment.offenseReduction); + assertEquals(0, assignment.defenseReduction); + } + click(spy, 15); + assertEquals(25, assignment.defenseReduction); + assertEquals(0, assignment.offenseReduction); + assertSame(report, EspionageService.report(leader, target)); + assertEquals(Material.RED_DYE, top(spy).getItem(15).getType()); + assertEquals(Material.LIME_DYE, top(spy).getItem(11).getType()); + } + + @Test + void staleOfficeOwnershipAndFactionMembershipClosePrivateMenus() { + EspionageView.settings(spy, home, manager); + assign(home, leader, false, 0); + click(spy, 11); + verify(spy).closeInventory(); + assertEquals(0, home.getEspionage().getSpymaster().offenseReduction); + EspionageView.positions(leader, home, manager); + FactionManager.factions.remove(home); + click(leader, 10); + verify(leader).closeInventory(); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void officeStatusExplainsNetworkBuildUpAndTheAppointmentCooldown(boolean building) { + YamlConfiguration config = new YamlConfiguration(); + config.set("espionage.appointments.build-up-days", building ? 2 : 0); + EspionageConfig.load(config); + assign(home, spy, false, building ? System.currentTimeMillis() : 0); + EspionageView.spymasterOffice(leader, home, manager); + assertTrue(text(top(leader).getItem(13)).contains(building ? "building" : "established")); + assertTrue( + text(top(leader).getItem(11)) + .contains(building ? "Next appointment" : "appointment can be made")); + } + + @ParameterizedTest + @ValueSource(strings = {"stability-penalty", "penalty-days"}) + void appointmentTermsReflectTheFirstAppointmentAndDisabledUnrest(String disabledSetting) { + home.setEspionage(new EspionageState()); + EspionageView.spymasterOffice(leader, home, manager); + assertTrue(text(top(leader).getItem(11)).contains("first appointment brings no unrest")); + YamlConfiguration config = new YamlConfiguration(); + config.set("espionage.appointments." + disabledSetting, 0); + EspionageConfig.load(config); + assign(home, spy, false, 0); + EspionageView.spymasterOffice(leader, home, manager); + assertTrue(text(top(leader).getItem(11)).contains("Changing Spymaster brings no unrest")); + } + + @Test + void candidatePagesClampAndRevalidateLeadershipWhenTheMenuIsClicked() { + for (int i = 0; i < 50; i++) home.addMember("Member" + i); + EspionageView.spymasterOffice(leader, home, manager); + click(leader, 11); + assertEquals(SFGUI.SPYMASTER_SELECT, kind(leader)); + assertNotNull(top(leader).getItem(52)); + click(leader, 52); + assertEquals(1, ((SFInventoryHolder) top(leader).getHolder()).getPage()); + assertNotNull(top(leader).getItem(45)); + click(leader, 45); + assertEquals(0, ((SFInventoryHolder) top(leader).getHolder()).getPage()); + click(leader, 53); + assertEquals(SFGUI.SPYMASTER_VIEW, kind(leader)); + click(leader, 11); + home.setLeader("Spy"); + click(leader, 0); + verify(leader).closeInventory(); + } + + @Test + void selectingAnOnlineCharacterAppointsAndRemovalReturnsTheOfficeToTheLeader() { + home.addMember("NewSpy"); + Player candidate = fixture.player("NewSpy"); + EspionageView.spymasterOffice(leader, home, manager); + click(leader, 11); + int candidateSlot = -1; + for (int i = 0; i < 45; i++) + if (text(top(leader).getItem(i)).contains("NewSpy")) candidateSlot = i; + assertTrue(candidateSlot >= 0); + click(leader, candidateSlot); + assertEquals("NewSpy", home.getEspionage().getSpymaster().playerName); + assertEquals(SFGUI.SPYMASTER_VIEW, kind(leader)); + click(leader, 15); + assertEquals("Leader", home.getEspionage().getSpymaster().playerName); + assertTrue(home.getEspionage().getSpymaster().automatic); + assertTrue(text(top(leader).getItem(13)).contains("Held by the faction leader")); + } + + @Test + void unownedOfficeAndUnknownMenuTypesDoNotExposePrivateControls() { + characters.when(() -> OfficeCharacters.activeCharacterId(any())).thenReturn(null); + home.getEspionage().removeSpymaster(); + EspionageView.spymasterOffice(leader, home, manager); + assertTrue(text(top(leader).getItem(13)).contains("Vacant")); + Inventory office = top(leader); + click(leader, 13); + assertSame(office, top(leader)); + click(leader, 15); + assertSame(office, top(leader)); + for (SFGUI type : SFGUI.values()) + assertEquals( + Set.of( + SFGUI.SPECIAL_POSITIONS, + SFGUI.SPYMASTER_VIEW, + SFGUI.SPYMASTER_SETTINGS, + SFGUI.SPYMASTER_SELECT, + SFGUI.FOREIGN_LEDGER_VIEW, + SFGUI.FOREIGN_POSITIONS_VIEW) + .contains(type), + EspionageView.handles(type)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageOperationsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageOperationsCoverageTest.java new file mode 100644 index 00000000..0ce68c55 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/espionage/EspionageOperationsCoverageTest.java @@ -0,0 +1,660 @@ +package net.tfminecraft.simplefactions.espionage; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import net.tfminecraft.rpcharacters.enums.Status; +import net.tfminecraft.rpcharacters.objects.PlayerData; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.objects.attributes.AttributeData; +import net.tfminecraft.simplefactions.army.LevyEntry; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.upgrade.Upgrade; +import net.tfminecraft.simplefactions.installation.Installation; +import net.tfminecraft.simplefactions.installation.InstallationKind; +import net.tfminecraft.simplefactions.loaders.UpgradeLoader; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemCreator; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +/** Real office/report workflows with optional character storage and Paper at the boundary. */ +class EspionageOperationsCoverageTest { + @TempDir Path directory; + private FactionDomainFixture fixture; + private Map<Object, Object> names; + private Map<Object, Object> originalNames; + private final Map<Field, Object> originalConfig = new LinkedHashMap<>(); + private final Map<Map<Object, Object>, Map<Object, Object>> originalConfigMaps = + new java.util.IdentityHashMap<>(); + private Field aptitudeField; + private Object originalAptitudes; + private Path aptitudeFile; + private MockedConstruction<Database> databases; + private MockedStatic<net.tfminecraft.rpcharacters.managers.PlayerManager> roleplayPlayers; + private MockedStatic<TLibs> items; + private final Map<UUID, PlayerData> characterData = new LinkedHashMap<>(); + private final Map<String, RPCharacter> characters = new LinkedHashMap<>(); + private final AtomicBoolean saveSucceeds = new AtomicBoolean(true); + private Faction home; + private Player leader; + private Player candidate; + private Player visitor; + + @BeforeEach + @SuppressWarnings("unchecked") + void setup() throws Exception { + fixture = new FactionDomainFixture(); + Field cache = CharacterNames.class.getDeclaredField("cache"); + cache.setAccessible(true); + names = (Map<Object, Object>) cache.get(null); + originalNames = new LinkedHashMap<>(names); + names.clear(); + PluginManager plugins = Bukkit.getPluginManager(); + Plugin roleplay = mock(Plugin.class); + when(plugins.isPluginEnabled("RPCharacters")).thenReturn(true); + when(plugins.getPlugin("RPCharacters")).thenReturn(roleplay); + when(roleplay.getDataFolder()).thenReturn(directory.toFile()); + when(Bukkit.getOfflinePlayer(anyString())) + .thenAnswer( + call -> { + OfflinePlayer player = mock(OfflinePlayer.class); + when(player.getUniqueId()).thenReturn(accountId(call.getArgument(0))); + return player; + }); + } + + @AfterEach + void close() throws Exception { + try { + if (items != null) items.close(); + if (roleplayPlayers != null) roleplayPlayers.close(); + if (databases != null) databases.close(); + if (aptitudeField != null) aptitudeField.set(null, originalAptitudes); + for (var entry : originalConfig.entrySet()) entry.getKey().set(null, entry.getValue()); + originalConfigMaps.forEach( + (map, original) -> { + map.clear(); + map.putAll(original); + }); + names.clear(); + names.putAll(originalNames); + } finally { + fixture.close(); + } + } + + @ParameterizedTest + @ValueSource(strings = {"{", "{\"active\":true,\"name\":null}"}) + void malformedOldCharacterDoesNotHideALaterValidActiveCharacter(String malformed) + throws Exception { + Path folder = directory.resolve("data/characterdata").resolve(accountId("Offline").toString()); + Files.createDirectories(folder); + Files.writeString(folder.resolve("00-old.json"), malformed); + String active = "{\"active\":true,\"name\":\"Lady Rowan\"}"; + Files.writeString(folder.resolve("01-active.json"), active); + + assertEquals("Lady Rowan", CharacterNames.forForeign("Offline")); + assertEquals("Lady Rowan", CharacterNames.of("Offline")); + assertEquals(malformed, Files.readString(folder.resolve("00-old.json"))); + assertEquals(active, Files.readString(folder.resolve("01-active.json"))); + } + + @Test + void quotedCharacterResolutionRejectsAmbiguityAndExactAccountsStillTakePrecedence() + throws Exception { + offices(); + when(characters.get("Candidate").getName()).thenReturn("§aLady Rowan"); + assertSame(candidate, CharacterNames.resolveOnline(leader, " \"lady rowan\" ")); + Player duplicate = characterPlayer("Duplicate"); + when(characters.get("Duplicate").getName()).thenReturn("Lady Rowan"); + assertNull(CharacterNames.resolveOnline(leader, "Lady Rowan")); + verify(leader).sendMessage(contains("Several characters")); + assertSame(candidate, CharacterNames.resolveOnline(leader, "candidate")); + assertSame(duplicate, CharacterNames.resolveOnline(leader, "Duplicate")); + assertNull(CharacterNames.resolveOnline(leader, "No Such Player")); + verify(leader).sendMessage(contains("No online player")); + assertEquals("§aLady Rowan", CharacterNames.forForeign("Candidate")); + } + + @Test + void offlineNamesIgnoreInactiveAndNonJsonFilesAndCacheWithoutEditingCharacterState() + throws Exception { + Path folder = directory.resolve("data/characterdata").resolve(accountId("Offline").toString()); + Files.createDirectories(folder); + Files.writeString(folder.resolve("00-note.txt"), "This is not character data"); + Files.writeString(folder.resolve("01-empty.json"), "null"); + Files.writeString(folder.resolve("02-inactive.json"), "{\"active\":false,\"name\":\"Old\"}"); + Files.writeString(folder.resolve("03-active.json"), "{\"active\":true,\"name\":\"Current\"}"); + assertEquals("Current", CharacterNames.of("Offline")); + Files.delete(folder.resolve("03-active.json")); + assertEquals("Current", CharacterNames.forForeign("Offline")); + assertEquals("Unknown", CharacterNames.forForeign(null)); + } + + @Test + void unreadableCharacterDirectoryKeepsForeignIdentityHidden() throws Exception { + Path folder = directory.resolve("data/characterdata").resolve(accountId("Offline").toString()); + Files.createDirectories(folder); + try (MockedStatic<Files> storage = mockStatic(Files.class)) { + storage.when(() -> Files.isDirectory(folder)).thenReturn(true); + storage.when(() -> Files.list(folder)).thenThrow(new IOException("directory unavailable")); + assertEquals("Unknown", CharacterNames.forForeign("Offline")); + assertEquals("Offline", CharacterNames.of("Offline")); + storage.verify(() -> Files.list(folder)); + } + assertTrue(Files.isDirectory(folder)); + } + + @Test + void persistedCharacterAptitudesOverrideOlderOfficeCopiesAndRemainBoundToTheCharacter() + throws Exception { + offices(); + SpecialPositionAssignment holder = home.getEspionage().getSpymaster(); + int original = holder.aptitude; + int canonical = original == 91 ? 92 : 91; + Files.writeString(aptitudeFile, "{\"character-Leader\":" + canonical + "}"); + EspionageService.loadAptitudes(aptitudeFile); + assertSame(holder, home.getEspionage().getSpymaster()); + assertEquals(canonical, holder.aptitude); + assertEquals(canonical, EspionageService.effectiveAptitude(home, holder)); + assertTrue(Files.readString(aptitudeFile).contains("character-Leader")); + assertFalse(EspionageService.appoint(leader, home, leader)); + assertEquals(canonical, holder.aptitude); + } + + @Test + void founderStorageFailureKeepsRetryIntentAndNeverInstallsAnUnsavedOffice() throws Exception { + offices(); + home.getEspionage().removeSpymaster(); + when(characters.get("Leader").getId()).thenReturn("replacement-character"); + Files.delete(aptitudeFile); + Files.createDirectory(aptitudeFile); + EspionageService.initializeFounder(home); + assertNull(home.getEspionage().getSpymaster()); + assertTrue(home.getEspionage().isPendingFounder(SpecialPosition.SPYMASTER)); + assertEquals( + "replacement-character", + home.getEspionage().pendingFounderCharacter(SpecialPosition.SPYMASTER)); + verify(leader).sendMessage(contains("could not be initialized")); + Files.delete(aptitudeFile); + EspionageService.initializeFounder(home); + assertEquals("replacement-character", home.getEspionage().getSpymaster().characterId); + assertTrue(home.getEspionage().getSpymaster().automatic); + assertFalse(home.getEspionage().hasPendingFounder()); + } + + @Test + void legacyCharacterRollsMigrateWithoutRerollingRemovedOffices() throws Exception { + offices(); + EspionageState legacy = + net.tfminecraft.simplefactions.database.JsonUtil.GSON.fromJson( + "{\"appointmentAptitudes\":{\"old-faction:legacy-character\":78,\"unqualified\":15}}", + EspionageState.class); + home.setEspionage(legacy); + EspionageService.loadAptitudes(aptitudeFile); + CharacterAptitudes saved = new CharacterAptitudes(aptitudeFile); + saved.load(); + assertEquals( + 78, + saved.aptitude( + "legacy-character", + () -> { + throw new AssertionError("The permanent roll must not be rerolled"); + })); + assertFalse(Files.readString(aptitudeFile).contains("unqualified")); + assertNull(home.getEspionage().getSpymaster()); + } + + @Test + void reportRefreshPersistsFounderRetryIntentWithoutInventingAnIntelligenceReport() + throws Exception { + offices(); + home.getEspionage().removeSpymaster(); + when(characters.get("Leader").getId()).thenReturn("replacement-character"); + Files.delete(aptitudeFile); + Files.createDirectory(aptitudeFile); + int before = databases.constructed().size(); + EspionageService.refreshReports(leader); + assertNull(home.getEspionage().getSpymaster()); + assertTrue(home.getEspionage().isPendingFounder(SpecialPosition.SPYMASTER)); + assertEquals( + "replacement-character", + home.getEspionage().pendingFounderCharacter(SpecialPosition.SPYMASTER)); + assertEquals(before + 1, databases.constructed().size()); + verify(databases.constructed().getLast()).saveFaction(home); + verify(leader).sendMessage(contains("could not be initialized")); + } + + @Test + void publicReportRefreshCapturesRealGuildArmyAndOfficeStateOncePerDay() throws Exception { + offices(); + YamlConfiguration settings = new YamlConfiguration(); + settings.set("espionage.appointments.build-up-days", 0); + settings.set("espionage.checks.luck-spread", 0); + settings.set("espionage.checks.aptitude-multiplier", 2); + EspionageConfig.load(settings); + home.getEspionage().getSpymaster().aptitude = 100; + Faction target = fixture.saved("target", "Foreign"); + target.getOrCreateMainGuild(); + target.addMember("Ordinary"); + Player foreign = characterPlayer("Foreign"); + characterPlayer("Ordinary"); + YamlConfiguration upgradeData = new YamlConfiguration(); + upgradeData.set("upgrade.allowed-types", List.of("guild")); + UpgradeLoader.map.put( + "workshop", new Upgrade("workshop", upgradeData.getConfigurationSection("upgrade"))); + Guild guild = fixture.guild(target, "merchants", "Baron"); + guild.addMember("Trader"); + Player baron = characterPlayer("Baron"); + characterPlayer("Trader"); + fixture.provinceData.put(37, new Province(37, "PLAINS", 50)); + guild.setCapital(37, false); + guild.getUpgrade("workshop").setLevel(2); + SpecialPositionAssignment defender = new SpecialPositionAssignment(); + defender.playerName = baron.getName(); + defender.playerId = baron.getUniqueId(); + defender.characterId = "character-Baron"; + target.getEspionage().appoint(defender, 0, 0); + Regiment regiment = fixture.regiment("guards", false, 8, 1); + Regiment levies = fixture.regiment("levies", true, 0, 0); + levies.setLevyEntries(List.of(new LevyEntry(target, 6))); + target.getMilitary().getRegiments().add(regiment); + target.getMilitary().getRegiments().add(levies); + target.getMilitary().addQueueItem(regiment, 123); + installFortConfiguration(); + target + .getInstallationHandler() + .acceptTransferred(new Installation("fort", "Fort", InstallationKind.FORT, 37, 0, 0, 1)); + target.getBank().deposit(2000.0); + guild.getBank().deposit(700.0); + double capturedWealth = target.getWealth(); + assertTrue(capturedWealth > 0); + + EspionageService.refreshReports(leader); + + IntelligenceReport report = EspionageService.report(leader, target); + assertNotNull(report); + assertEquals(IntelligenceTier.DETAILED, report.tier()); + assertEquals(LocalDate.now(ZoneOffset.UTC).toEpochDay(), report.day); + assertEquals("Character Baron", report.officeHolder(SpecialPosition.SPYMASTER)); + assertTrue(report.estimates.containsKey("Training:0")); + assertTrue(report.estimates.containsKey("Regiment:guards:Soldiers")); + assertTrue(report.estimates.containsKey("Regiment:levies:Levies")); + assertTrue(report.estimates.containsKey("Installation:fort:Level")); + assertTrue(report.estimates.containsKey(IntelligenceLedger.key(guild, "Upgrade:workshop"))); + assertTrue(report.estimates.containsKey(IntelligenceLedger.key(guild, "Branch:commerce"))); + assertTrue(report.estimates.containsKey(IntelligenceLedger.key(guild, "Trade power"))); + var roster = + report.roster.stream() + .filter(member -> member.character().equals("Character Baron")) + .findFirst() + .orElseThrow(); + assertEquals(guild.getId(), roster.guildId()); + assertTrue(roster.guildLeader()); + assertEquals(List.of(SpecialPosition.SPYMASTER), roster.offices()); + assertFalse( + report.roster.stream().anyMatch(member -> member.character().equals("Character Foreign"))); + var wealthEstimate = report.estimate("Wealth"); + assertNotNull(wealthEstimate); + assertTrue( + wealthEstimate.lower() <= capturedWealth && wealthEstimate.upper() >= capturedWealth); + target.getBank().deposit(9000.0); + EspionageService.refreshReports(candidate); + assertSame(report, EspionageService.report(candidate, target)); + assertEquals(wealthEstimate, report.estimate("Wealth")); + assertEquals(2, EspionageService.regenerateReports()); + assertNotSame(report, EspionageService.report(leader, target)); + assertNotNull(EspionageService.report(foreign, home)); + } + + @SuppressWarnings("unchecked") + private void installFortConfiguration() throws Exception { + Field field = + net.tfminecraft.simplefactions.loaders.InstallationConfigLoader.class.getDeclaredField( + "byKind"); + field.setAccessible(true); + Map<Object, Object> map = (Map<Object, Object>) field.get(null); + originalConfigMaps.put(map, new LinkedHashMap<>(map)); + map.put( + InstallationKind.FORT, + new net.tfminecraft.simplefactions.installation.InstallationKindConfig( + 12, + Map.of( + 1, + new net.tfminecraft.simplefactions.installation.InstallationKindConfig.Level( + 0, 0, Map.of())))); + } + + @Test + void trailingRemoveArgumentsMustNotDismissTheCurrentSpymaster() throws Exception { + offices(); + assertTrue(EspionageService.appoint(leader, home, candidate)); + SpecialPositionAssignment before = home.getEspionage().getSpymaster(); + assertTrue( + EspionageCommands.handle(leader, new String[] {"spymaster", "remove", "accidental"})); + assertSame(before, home.getEspionage().getSpymaster()); + verify(leader).sendMessage(contains("<player|remove>")); + } + + @Test + void aDeadActiveCharacterCannotDisplaceTheCurrentSpymaster() throws Exception { + offices(); + SpecialPositionAssignment before = home.getEspionage().getSpymaster(); + when(characters.get("Candidate").getStatus()).thenReturn(Status.DEAD); + assertFalse(EspionageService.appoint(leader, home, candidate)); + assertSame(before, home.getEspionage().getSpymaster()); + assertEquals(0, home.getEspionage().appointmentCount(SpecialPosition.SPYMASTER)); + } + + @ParameterizedTest + @ValueSource(strings = {"espionage", "positions", "spymaster"}) + void commandAliasesOpenOnlyTheCallersPermittedOfficeView(String alias) throws Exception { + offices(); + assertTrue(EspionageCommands.matches(alias.toUpperCase())); + assertFalse(EspionageCommands.matches("unrelated")); + assertTrue(EspionageCommands.handle(leader, new String[] {alias})); + assertEquals( + alias.equals("positions") ? SFGUI.SPECIAL_POSITIONS : SFGUI.SPYMASTER_SETTINGS, + ((SFInventoryHolder) leader.getOpenInventory().getTopInventory().getHolder()).getType()); + assertTrue(EspionageCommands.handle(candidate, new String[] {alias})); + assertEquals( + alias.equals("positions") ? SFGUI.SPECIAL_POSITIONS : SFGUI.SPYMASTER_VIEW, + ((SFInventoryHolder) candidate.getOpenInventory().getTopInventory().getHolder()).getType()); + assertTrue(EspionageCommands.handle(visitor, new String[] {alias})); + verify(visitor).sendMessage(contains("belong to a faction")); + verify(visitor, never()).openInventory(any(org.bukkit.inventory.Inventory.class)); + } + + @Test + void commandAppointmentSabotageAndRemovalPreservePrivateConductAndAuthority() throws Exception { + offices(); + assertTrue( + EspionageCommands.handle(leader, new String[] {"spymaster", "Character", "Candidate"})); + SpecialPositionAssignment appointed = home.getEspionage().getSpymaster(); + assertEquals(candidate.getUniqueId(), appointed.playerId); + assertFalse(appointed.automatic); + clearInvocations(leader, candidate); + assertTrue( + EspionageCommands.handle( + candidate, new String[] {"spymaster", "sabotage", "offense", "50"})); + assertTrue( + EspionageCommands.handle( + candidate, new String[] {"spymaster", "sabotage", "defense", "25"})); + assertEquals(50, appointed.offenseReduction); + assertEquals(25, appointed.defenseReduction); + verifyNoInteractions(leader); + assertTrue( + EspionageCommands.handle(leader, new String[] {"spymaster", "sabotage", "offense", "100"})); + assertEquals(50, appointed.offenseReduction); + assertTrue(EspionageCommands.handle(candidate, new String[] {"spymaster", "remove"})); + assertSame(appointed, home.getEspionage().getSpymaster()); + assertTrue(EspionageCommands.handle(leader, new String[] {"spymaster", "remove"})); + assertTrue(home.getEspionage().getSpymaster().automatic); + assertEquals(leader.getUniqueId(), home.getEspionage().getSpymaster().playerId); + } + + @ParameterizedTest + @ValueSource( + strings = { + "invalid-number", + "invalid-side", + "missing-value", + "wrong-command", + "missing-player" + }) + void invalidCommandArgumentsNeverChangeTheOffice(String kind) throws Exception { + offices(); + SpecialPositionAssignment before = home.getEspionage().getSpymaster(); + String[] args = + switch (kind) { + case "invalid-number" -> new String[] {"spymaster", "sabotage", "offense", "NaN"}; + case "invalid-side" -> new String[] {"spymaster", "sabotage", "other", "25"}; + case "missing-value" -> new String[] {"spymaster", "sabotage", "offense"}; + case "missing-player" -> new String[] {"spymaster", "Absent"}; + default -> new String[] {"positions", "extra"}; + }; + assertTrue(EspionageCommands.handle(leader, args)); + assertSame(before, home.getEspionage().getSpymaster()); + assertEquals(0, before.offenseReduction); + assertEquals(0, before.defenseReduction); + verify(leader) + .sendMessage( + contains(kind.equals("missing-player") ? "No online player" : "<player|remove>")); + } + + @Test + void completionListsOnlyAuthorizedOnlineAppointmentsAndPrivateConductChoices() throws Exception { + offices(); + home.addMember("OfflineMember"); + assertTrue( + EspionageCommands.complete(leader, new String[] {"spymaster", ""}) + .containsAll(List.of("remove", "Leader", "Candidate", "sabotage"))); + assertFalse( + EspionageCommands.complete(leader, new String[] {"spymaster", ""}) + .contains("OfflineMember")); + assertEquals( + List.of("Candidate"), EspionageCommands.complete(leader, new String[] {"spymaster", "Ca"})); + assertTrue(EspionageCommands.complete(candidate, new String[] {"spymaster", ""}).isEmpty()); + assertTrue(EspionageCommands.complete(visitor, new String[] {"spymaster", ""}).isEmpty()); + assertTrue(EspionageCommands.complete(leader, new String[] {"positions", ""}).isEmpty()); + assertEquals( + List.of("offense", "defense"), + EspionageCommands.complete(leader, new String[] {"spymaster", "sabotage", ""})); + assertEquals( + List.of("50"), + EspionageCommands.complete(leader, new String[] {"spymaster", "sabotage", "defense", "5"})); + } + + @ParameterizedTest + @ValueSource( + strings = {"null", "offline", "foreign", "no-character", "already-holder", "cooldown"}) + void appointmentDenialsRetainTheCurrentOfficeAndItsAppointmentCount(String reason) + throws Exception { + offices(); + Player choice = candidate; + if (reason.equals("null")) choice = null; + if (reason.equals("offline")) when(candidate.isOnline()).thenReturn(false); + if (reason.equals("foreign")) choice = visitor; + if (reason.equals("no-character")) + when(characterData.get(candidate.getUniqueId()).getActiveCharacter()).thenReturn(null); + if (reason.equals("already-holder")) choice = leader; + if (reason.equals("cooldown")) { + assertTrue(EspionageService.appoint(leader, home, candidate)); + YamlConfiguration settings = new YamlConfiguration(); + settings.set("espionage.appointments.change-cooldown-days", 2); + EspionageConfig.load(settings); + choice = leader; + } + SpecialPositionAssignment before = home.getEspionage().getSpymaster(); + int appointments = home.getEspionage().appointmentCount(SpecialPosition.SPYMASTER); + assertFalse(EspionageService.appoint(leader, home, choice)); + assertSame(before, home.getEspionage().getSpymaster()); + assertEquals(appointments, home.getEspionage().appointmentCount(SpecialPosition.SPYMASTER)); + } + + @ParameterizedTest + @ValueSource(strings = {"database", "aptitude-storage", "unavailable"}) + void failedAppointmentPersistenceLeavesThePreviousHolderAndUnrestUntouched(String failure) + throws Exception { + offices(); + SpecialPositionAssignment before = home.getEspionage().getSpymaster(); + if (failure.equals("database")) saveSucceeds.set(false); + if (failure.equals("unavailable")) aptitudeField.set(null, null); + if (failure.equals("aptitude-storage")) { + Files.delete(aptitudeFile); + Files.createDirectory(aptitudeFile); + } + assertFalse(EspionageService.appoint(leader, home, candidate)); + assertSame(before, home.getEspionage().getSpymaster()); + assertEquals(0, home.getEspionage().appointmentCount(SpecialPosition.SPYMASTER)); + assertTrue(home.getEspionage().unrestModifiers(System.currentTimeMillis()).isEmpty()); + verify(leader).sendMessage(contains("could not be saved")); + } + + @ParameterizedTest + @ValueSource(strings = {"denied", "extra-argument", "missing-resource", "valid"}) + void reloadRequiresPermissionAndReportsConfigurationFailuresWithoutTouchingReports(String state) + throws Exception { + offices(); + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission(EspionageConfig.reloadPermission())) + .thenReturn(!state.equals("denied")); + when(fixture.ui.plugin.getDataFolder()).thenReturn(directory.toFile()); + if (state.equals("valid")) + when(fixture.ui.plugin.getResource("special-positions.yml")) + .thenAnswer( + call -> Files.newInputStream(Path.of("src/main/resources/special-positions.yml"))); + String[] args = + state.equals("extra-argument") + ? new String[] {"reloadespionage", "extra"} + : new String[] {"reloadespionage"}; + assertTrue(EspionageCommands.reload(sender, args)); + String expected = + switch (state) { + case "denied" -> "do not have permission"; + case "extra-argument" -> "/faction reloadespionage"; + case "missing-resource" -> "Could not reload"; + default -> "0 intelligence reports regenerated"; + }; + verify(sender).sendMessage(contains(expected)); + if (!state.equals("valid")) + assertFalse(Files.exists(directory.resolve("special-positions.yml"))); + } + + @SuppressWarnings("unchecked") + private void offices() throws Exception { + for (String name : + List.of( + "bypassPermission", + "weights", + "stabilityPenalty", + "penaltyDays", + "buildUpDays", + "startingAptitude", + "changeCooldownDays", + "reloadPermission", + "base", + "extraPositionPenalty", + "rollMultiplier", + "center", + "cap", + "aptitudeSpread", + "luckSpread", + "luckDraws", + "rosterLimit", + "tiers", + "minimums", + "cashflows", + "vacancyPenalties")) { + Field field = EspionageConfig.class.getDeclaredField(name); + field.setAccessible(true); + if (java.lang.reflect.Modifier.isFinal(field.getModifiers())) { + Map<Object, Object> value = (Map<Object, Object>) field.get(null); + originalConfigMaps.put(value, new LinkedHashMap<>(value)); + } else originalConfig.put(field, field.get(null)); + } + YamlConfiguration settings = new YamlConfiguration(); + settings.set("espionage.appointments.change-cooldown-days", 0); + settings.set("espionage.appointments.build-up-days", 0); + EspionageConfig.load(settings); + databases = + mockConstruction( + Database.class, + (database, context) -> + when(database.saveFactionChecked(any())).thenAnswer(call -> saveSucceeds.get())); + roleplayPlayers = mockStatic(net.tfminecraft.rpcharacters.managers.PlayerManager.class); + roleplayPlayers + .when(() -> net.tfminecraft.rpcharacters.managers.PlayerManager.get(any(Player.class))) + .thenAnswer(call -> characterData.get(((Player) call.getArgument(0)).getUniqueId())); + roleplayPlayers + .when(() -> net.tfminecraft.rpcharacters.managers.PlayerManager.get(any(UUID.class))) + .thenAnswer(call -> characterData.get(call.getArgument(0))); + when(Bukkit.getOnlinePlayers()).thenAnswer(call -> fixture.online.values()); + when(Bukkit.getOfflinePlayer(any(UUID.class))) + .thenAnswer( + call -> { + OfflinePlayer player = mock(OfflinePlayer.class); + when(player.getUniqueId()).thenReturn(call.getArgument(0)); + return player; + }); + home = fixture.saved("home", "Leader"); + home.getOrCreateMainGuild(); + home.addMember("Candidate"); + leader = characterPlayer("Leader"); + candidate = characterPlayer("Candidate"); + visitor = characterPlayer("Visitor"); + aptitudeField = EspionageService.class.getDeclaredField("characterAptitudes"); + aptitudeField.setAccessible(true); + originalAptitudes = aptitudeField.get(null); + aptitudeFile = directory.resolve("aptitudes.json"); + EspionageService.loadAptitudes(aptitudeFile); + EspionageService.initializeFounder(home); + assertNotNull(home.getEspionage().getSpymaster()); + items = mockStatic(TLibs.class); + ItemAPI api = mock(ItemAPI.class); + ItemCreator creator = mock(ItemCreator.class); + when(api.getCreator()).thenReturn(creator); + items.when(TLibs::getItemAPI).thenReturn(api); + when(creator.getItemFromPath(anyString())).thenAnswer(call -> new ItemStack(Material.PAPER)); + } + + private Player characterPlayer(String name) { + Player player = fixture.player(name); + RPCharacter character = mock(RPCharacter.class); + when(character.getId()).thenReturn("character-" + name); + when(character.getName()).thenReturn("Character " + name); + when(character.getStatus()).thenReturn(Status.ALIVE); + AttributeData attributes = mock(AttributeData.class); + when(attributes.getAmount( + any(net.tfminecraft.rpcharacters.objects.attributes.AttributeModifier.class))) + .thenReturn(6); + when(character.getAttributeData()).thenReturn(attributes); + PlayerData data = mock(PlayerData.class); + when(data.getActiveCharacter()).thenReturn(character); + when(data.getCharacters()).thenReturn(List.of(character)); + characterData.put(player.getUniqueId(), data); + characters.put(name, character); + return player; + } + + private static UUID accountId(String name) { + return UUID.nameUUIDFromBytes(name.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/events/PluginEventBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/events/PluginEventBoundaryCoverageTest.java new file mode 100644 index 00000000..ae6f183e --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/events/PluginEventBoundaryCoverageTest.java @@ -0,0 +1,83 @@ +package net.tfminecraft.simplefactions.events; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.war.battle.enums.BattleEndReason; +import net.tfminecraft.simplefactions.war.battle.enums.BattleType; +import net.tfminecraft.simplefactions.war.battle.events.BattleEndedEvent; +import net.tfminecraft.simplefactions.war.battle.events.BattleStartedEvent; +import org.junit.jupiter.api.Test; + +class PluginEventBoundaryCoverageTest { + @Test + void factionLifecycleEventsCarryTheirActorAndSupportCancellationWithoutMutatingTheFaction() { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + var faction = fixture.saved("realm", "Alice"); + var player = fixture.player("Alice"); + FactionCreateEvent created = new FactionCreateEvent(player, faction); + FactionDeleteEvent deleted = new FactionDeleteEvent(player, faction); + assertSame(faction, created.getFaction()); + assertSame(faction, deleted.getFaction()); + assertSame(player, created.getCreator()); + assertSame(player, deleted.getCreator()); + assertSame(FactionCreateEvent.getHandlerList(), created.getHandlers()); + assertSame(FactionDeleteEvent.getHandlerList(), deleted.getHandlers()); + assertNotSame(created.getHandlers(), deleted.getHandlers()); + assertFalse(created.isCancelled()); + assertFalse(deleted.isCancelled()); + created.setCancelled(true); + deleted.setCancelled(true); + assertTrue(created.isCancelled()); + assertTrue(deleted.isCancelled()); + assertEquals("Alice", faction.getLeader()); + } + } + + @Test + void battleEventsSnapshotTheirPayloadAndKeepIndependentHandlerLists() { + UUID player = UUID.randomUUID(); + Set<UUID> participants = new HashSet<>(Set.of(player)); + Map<String, Integer> casualties = new HashMap<>(Map.of("attacker", 3)); + BattleStartedEvent started = new BattleStartedEvent("field", BattleType.FIELD, 7, participants); + BattleEndedEvent ended = + new BattleEndedEvent( + "field", + BattleType.FIELD, + 7, + "defender", + casualties, + participants, + BattleEndReason.TIMER); + participants.clear(); + casualties.clear(); + assertEquals(Set.of(player), started.getParticipantIds()); + assertEquals(Set.of(player), ended.getParticipantIds()); + assertEquals(Map.of("attacker", 3), ended.getSideCasualties()); + assertThrows(UnsupportedOperationException.class, () -> ended.getSideCasualties().clear()); + assertThrows(UnsupportedOperationException.class, () -> started.getParticipantIds().clear()); + assertEquals(BattleType.FIELD, started.getBattleType()); + assertEquals(BattleType.FIELD, ended.getBattleType()); + assertEquals("field", started.getBattleId()); + assertEquals("field", ended.getBattleId()); + assertEquals(7, started.getWarId()); + assertEquals(7, ended.getWarId()); + assertTrue(ended.hasWinner()); + assertEquals(BattleEndReason.TIMER, ended.getEndReason()); + assertFalse(ended.isCampaignRaid()); + assertTrue(ended.isLootEnabled()); + assertSame(BattleStartedEvent.getHandlerList(), started.getHandlers()); + assertSame(BattleEndedEvent.getHandlerList(), ended.getHandlers()); + assertNotSame(started.getHandlers(), ended.getHandlers()); + BattleEndedEvent draw = + new BattleEndedEvent("draw", BattleType.SIEGE, null, " ", null, null, null); + assertFalse(draw.hasWinner()); + assertEquals(BattleEndReason.TIMER, draw.getEndReason()); + assertTrue(draw.getParticipantIds().isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/government/GovernmentCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/government/GovernmentCoverageTest.java new file mode 100644 index 00000000..aa76bee3 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/government/GovernmentCoverageTest.java @@ -0,0 +1,829 @@ +package net.tfminecraft.simplefactions.government; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.tfminecraft.denareconomy.accounts.OfflineModifier; +import net.tfminecraft.denareconomy.enums.Accounts; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.database.GovernmentData; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.database.MovementData; +import net.tfminecraft.simplefactions.enums.FactionModifiers; +import net.tfminecraft.simplefactions.enums.GuildModifier; +import net.tfminecraft.simplefactions.enums.Rules; +import net.tfminecraft.simplefactions.espionage.SpecialPosition; +import net.tfminecraft.simplefactions.government.movement.Action; +import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.government.movement.PoliticalAction; +import net.tfminecraft.simplefactions.government.movement.cause.Cause; +import net.tfminecraft.simplefactions.government.proposal.FeeChange; +import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.government.proposal.Proposal; +import net.tfminecraft.simplefactions.government.proposal.TaxLawChange; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.government.stability.StabilityDebuffs; +import net.tfminecraft.simplefactions.government.stability.StabilityTuning; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.branch.BranchModifier; +import net.tfminecraft.simplefactions.loaders.PoliticalActionLoader; +import net.tfminecraft.simplefactions.managers.SessionManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.war.core.War; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class GovernmentCoverageTest { + private FactionDomainFixture fixture; + private Map<Action, PoliticalAction> originalActions; + private String originalBaseYear; + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + originalActions = PoliticalActionLoader.map; + originalBaseYear = Cache.baseYear; + Cache.baseYear = "322 AE"; + PoliticalActionLoader.map = new LinkedHashMap<>(); + for (Action action : Action.values()) + PoliticalActionLoader.map.put(action, new PoliticalAction(action)); + when(Bukkit.getPlayer(anyString())).thenAnswer(call -> fixture.online.get(call.getArgument(0))); + when(fixture.ui.plugin.getSessionManager()).thenReturn(new SessionManager()); + } + + @AfterEach + void cleanup() { + PoliticalActionLoader.map = originalActions; + Cache.baseYear = originalBaseYear; + if (fixture != null) fixture.close(); + } + + private Faction councilFaction(Rules type, int size) { + fixture.lawGroup( + "constitution", + Map.of( + "effects.faction.rules", + List.of("HAS_COUNCIL true", type.name() + " true"), + "effects.faction.council-size", + size)); + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + faction.addMember("Cara"); + faction.addMember("Drew"); + faction.getGovernment().getCouncil().reorganize(); + return faction; + } + + private Proposal tax(Faction faction, String proposer, TaxTarget target, double rate) { + Proposal proposal = new Proposal(proposer, faction.getGovernment()); + proposal.setTaxProposal(new TaxLawChange(target, null, rate)); + return proposal; + } + + @Test + void councilEligibilityRejectsForeignersAndOrdinaryGuildMembers() { + Faction faction = councilFaction(Rules.APPOINTED_COUNCIL, 3); + Guild guild = fixture.guild(faction, "merchants", "Eve"); + guild.addMember("Finn"); + Faction vassal = fixture.saved("vassal", "Grace"); + vassal.addMember("Hank"); + fixture.subject(faction, vassal); + Council council = faction.getGovernment().getCouncil(); + + assertTrue(council.canBeMember("Bob", false, false)); + assertTrue(council.canBeMember("Eve", false, false)); + assertTrue(council.canBeMember("Grace", false, false)); + assertFalse(council.canBeMember("Stranger", false, false)); + assertFalse(council.canBeMember("Finn", false, false)); + assertFalse(council.canBeMember("Hank", false, false)); + assertFalse(council.canRemainMember("Stranger")); + assertFalse(council.canRemainMember("Finn")); + assertFalse(council.canRemainMember("Hank")); + } + + @Test + void councilCleanupRemovesMultipleInvalidRefusalsWithoutLosingValidRefusals() { + Faction faction = councilFaction(Rules.APPOINTED_COUNCIL, 3); + Council council = faction.getGovernment().getCouncil(); + council.toggleRefuse("Alice"); + council.toggleRefuse("Stranger"); + council.toggleRefuse("Bob"); + assertDoesNotThrow(council::replace); + assertFalse(council.refuses("Alice")); + assertFalse(council.refuses("Stranger")); + assertTrue(council.refuses("Bob")); + } + + @Test + void councilSeatsRefusalsQuorumAndProposalLimitsReflectCurrentMembers() { + Faction faction = councilFaction(Rules.APPOINTED_COUNCIL, 2); + Government government = faction.getGovernment(); + Council council = government.getCouncil(); + assertEquals(Rules.APPOINTED_COUNCIL, council.getType()); + assertEquals(2, council.getMaxSize()); + assertTrue(council.couldBeBigger()); + assertEquals(50, government.getStabilityMalusFromCouncil()); + assertFalse(council.canBeMember("Alice", false, false)); + assertTrue(council.toggleRefuse("Bob")); + assertFalse(council.canBeMember("Bob", false, false)); + assertTrue(council.canBeMember("Bob", false, true)); + assertTrue(council.toggleRefuse("Bob")); + council.addMember("Bob"); + council.addMemberForce("Bob"); + assertEquals(1, council.getCurrentSize()); + assertFalse(council.canBeMember("Bob", false, false)); + assertEquals(.5, council.fillPercentage()); + assertEquals(25, government.getStabilityMalusFromCouncil()); + council.addMember("Cara"); + council.addMember("Drew"); + assertEquals(List.of("Bob", "Cara"), council.getMembers()); + assertFalse(council.canBeMember("Drew", false, false)); + assertTrue(council.canBeMember("Drew", true, false)); + council.replaceMember(-1, "Drew"); + council.replaceMember(2, "Drew"); + council.replaceMember(1, "Drew"); + assertEquals(List.of("Bob", "Drew"), council.getMembers()); + assertEquals(Set.of("Alice", "Bob", "Drew"), council.getEligibleVoters()); + assertFalse(council.hasEnoughValidVoters()); + fixture.player("Alice"); + fixture.player("Bob"); + assertFalse(council.hasEnoughValidVoters()); + fixture.player("Drew"); + assertTrue(council.hasEnoughValidVoters()); + assertTrue(council.isDummyAccount("DuMmY_1")); + assertFalse(council.isDummyAccount("Bob")); + assertFalse(council.canPropose("Cara")); + assertTrue(council.canPropose("Bob")); + assertFalse(council.hasProposals()); + assertFalse(council.canHostSession()); + Proposal proposal = tax(faction, "Bob", TaxTarget.CITIZENS, 12); + assertTrue(council.canBeProposed(proposal)); + government.propose(proposal); + assertTrue(council.hasProposals()); + assertTrue(council.canHostSession()); + government.propose(tax(faction, "Bob", TaxTarget.GUILDS, 8)); + assertEquals(2, council.getCurrentProposals("Bob")); + assertFalse(council.canPropose("Bob")); + SessionManager sessions = fixture.ui.plugin.getSessionManager(); + sessions.newSession(fixture.online.get("Alice"), faction); + assertTrue(council.hasSession()); + assertNotNull(council.getSession()); + assertFalse(council.canHostSession()); + sessions.endSession(council); + council.clearMembers(); + assertEquals(0, council.getCurrentSize()); + council.setCouncilSize(0); + assertEquals(0, council.fillPercentage()); + } + + @Test + void councilReorganizationTrimsSeatsAndDropsProposalsOnlyWhenTypeChanges() { + Faction faction = councilFaction(Rules.APPOINTED_COUNCIL, 2); + Council council = faction.getGovernment().getCouncil(); + council.setCouncilSize(4); + council.addMemberForce("Bob"); + council.addMemberForce("Cara"); + council.addMemberForce("Drew"); + faction.getGovernment().propose(tax(faction, "Bob", TaxTarget.CITIZENS, 9)); + council.reorganize(); + assertEquals(List.of("Bob", "Cara"), council.getMembers()); + assertTrue(council.hasProposals()); + council.setCouncilType(Rules.NO_COUNCIL); + council.reorganize(); + assertEquals(Rules.APPOINTED_COUNCIL, council.getType()); + assertEquals(List.of("Bob", "Cara"), council.getMembers()); + assertFalse(council.hasProposals()); + council.addMemberForce("Departed"); + council.replace(); + assertFalse(council.isMember("Departed")); + } + + @Test + void electedCouncilFillsFromStoredWinnersThenEligibleFactionMembers() { + Faction faction = councilFaction(Rules.ELECTED_COUNCIL, 3); + Council council = faction.getGovernment().getCouncil(); + council.clearMembers(); + faction + .getGovernment() + .getElection() + .restoreFromData( + null, null, Map.of("COUNCIL", Map.of("Missing", 100, "Cara", 50, "Alice", 20)), null); + council.replace(); + assertEquals(List.of("Cara", "Bob", "Drew"), council.getMembers()); + council.replace(); + assertEquals(3, council.getCurrentSize()); + } + + @Test + void wealthCouncilSelectsEligibleWealthiestMembersAndRespectsRefusals() { + try (MockedStatic<OfflineModifier> economy = mockStatic(OfflineModifier.class)) { + economy + .when(() -> OfflineModifier.balance(anyString(), any(Accounts.class))) + .thenAnswer( + call -> + Map.of("Alice", 1000.0, "Bob", 800.0, "Cara", 500.0, "Drew", 100.0) + .getOrDefault(call.getArgument(0), 0.0)); + Faction faction = councilFaction(Rules.WEALTH_BASED_COUNCIL, 2); + Council council = faction.getGovernment().getCouncil(); + council.clearMembers(); + council.toggleRefuse("Bob"); + council.replace(); + assertEquals(List.of("Cara", "Drew"), council.getMembers()); + assertFalse(council.couldBeBigger()); + assertEquals(0, faction.getGovernment().getStabilityMalusFromCouncil()); + } + } + + @Test + void governmentPermissionChecksUseRealCouncilGuildAndSubjectRoles() { + Faction faction = councilFaction(Rules.APPOINTED_COUNCIL, 2); + Government government = faction.getGovernment(); + government.getCouncil().addMember("Bob"); + Guild guild = fixture.guild(faction, "merchants", "Eve"); + guild.addMember("Finn"); + Faction vassal = fixture.saved("vassal", "Grace"); + vassal.addMember("Hank"); + fixture.subject(faction, vassal); + fixture.saved("foreign", "Iris"); + assertFalse(government.isCouncilMember((String) null)); + assertFalse(government.isCouncilMember((org.bukkit.entity.Player) null)); + assertTrue(government.isCouncilMember("aLiCe")); + assertTrue(government.isCouncilMember(fixture.player("Bob"))); + assertFalse(government.isCouncilMember("Cara")); + assertTrue(government.canPropose(fixture.player("Alice"))); + assertTrue(government.canPropose(fixture.online.get("Bob"))); + assertFalse(government.canPropose(fixture.player("Cara"))); + for (String name : List.of("Alice", "Bob", "Cara", "Eve", "Grace")) + assertTrue(government.canProposeOrStartMovement(fixture.player(name)), name); + for (String name : List.of("Finn", "Hank", "Iris", "Stranger")) + assertFalse(government.canProposeOrStartMovement(fixture.player(name)), name); + assertFalse(government.canProposePolitical(fixture.online.get("Alice"), Action.NONE)); + assertFalse(government.canProposePolitical(fixture.online.get("Alice"), Action.DISSOLVE)); + assertFalse(government.canProposePolitical(fixture.online.get("Bob"), Action.DISSOLVE)); + assertFalse(government.canProposePolitical(fixture.online.get("Cara"), Action.SNAP_ELECTIONS)); + assertFalse(government.canProposePolitical(fixture.online.get("Hank"), Action.DISSOLVE)); + assertFalse(government.canProposePolitical(fixture.online.get("Iris"), Action.DISSOLVE)); + assertTrue(government.canProposePolitical(fixture.online.get("Cara"), Action.DISSOLVE)); + assertTrue(government.canProposePolitical(fixture.online.get("Eve"), Action.DISSOLVE)); + assertTrue(government.canProposePolitical(fixture.online.get("Finn"), Action.DISSOLVE)); + assertTrue(government.canProposePolitical(fixture.online.get("Grace"), Action.DISSOLVE)); + assertFalse(government.canProposePolitical(fixture.online.get("Finn"), Action.NATIONHOOD)); + assertTrue(government.canProposePolitical(fixture.online.get("Eve"), Action.NATIONHOOD)); + fixture.provinceData.put(7, new Province(7, "PLAINS", 1)); + faction.addProvince(7); + faction.setCapital(7); + guild.setCapital(7); + assertFalse(government.canProposePolitical(fixture.online.get("Eve"), Action.NATIONHOOD)); + YamlConfiguration blocked = new YamlConfiguration(); + blocked.set("DISSOLVE.pools", List.of()); + PoliticalActionLoader.map.put( + Action.DISSOLVE, + new PoliticalAction("DISSOLVE", blocked.getConfigurationSection("DISSOLVE"))); + for (String name : List.of("Cara", "Eve", "Grace")) + assertFalse(government.canProposePolitical(fixture.online.get(name), Action.DISSOLVE), name); + assertFalse(government.canProposePolitical(fixture.online.get("Alice"), Action.WHITE_PEACE)); + Faction enemy = fixture.saved("enemy", "Jo"); + WarManager.get().add(new War(1, faction, enemy)); + assertTrue(government.canProposePolitical(fixture.online.get("Alice"), Action.WHITE_PEACE)); + assertTrue(government.canProposePolitical(fixture.online.get("Bob"), Action.SURRENDER)); + government.propose(tax(faction, "Bob", TaxTarget.CITIZENS, 10)); + government.propose(tax(faction, "Bob", TaxTarget.GUILDS, 10)); + assertFalse(government.canProposeOrStartMovement(fixture.online.get("Bob"))); + assertFalse(government.canPropose(fixture.online.get("Bob"))); + } + + @Test + void administrativePowerSeedsWithinCapacityRegeneratesAndDecaysWithoutOvershoot() { + fixture.lawGroup( + "administration", + Map.of( + "upkeep", + 5, + "effects.faction.modifiers", + List.of("ADMIN_POWER_MULTIPLIER(50)", "ADMIN_POWER_GAIN_MULTIPLIER(100)"))); + Faction faction = fixture.saved("home", "Alice"); + faction + .getOrCreateMainGuild() + .getBranches() + .get(0) + .getModifiers() + .put(GuildModifier.ADMIN_POWER, new BranchModifier(100, 0)); + faction + .getOrCreateMainGuild() + .getBranches() + .get(0) + .getModifiers() + .put(GuildModifier.ADMIN_POWER_GAIN, new BranchModifier(2, 0)); + Government government = faction.getGovernment(); + assertNull(government.serialize().power); + assertEquals(90, government.getStability()); + assertEquals(148.828125, government.getBaseMaxPower(), .0001); + assertEquals(5.078125, government.getTotalUpkeep(), .0001); + assertEquals(143.75, government.getMaxPower(), .0001); + government.ping(); + assertEquals(10, government.getPower()); + assertEquals(5.953125, government.getPowerGain(), .0001); + government.powerTick(); + assertEquals(15.95, government.getPower()); + government.setPower(143); + government.powerTick(); + assertEquals(143.75, government.getPower()); + assertEquals(0, government.getPowerGain()); + government.powerTick(); + assertEquals(143.75, government.getPower()); + government.setPower(170); + assertEquals(-10, government.getPowerGain()); + government.powerTick(); + assertEquals(160, government.getPower()); + government.spendPower(12.25); + assertEquals(147.75, government.getPower()); + government.spendPower(1000); + assertEquals(0, government.getPower()); + government.setPower(-12); + assertEquals(0, government.getPower()); + government.setPower(12.345); + assertEquals(12.35, government.getPower()); + government.addStabilityModifier(new StabilityModifier("Unrest", -20, 5)); + government.addStabilityModifier(new StabilityModifier("uNrEsT", -5, 10)); + assertEquals(-25, government.getByName("UNREST").getModifier()); + assertNull(government.getByName("Missing")); + assertEquals(2, government.getStabilityModifiers().size()); + assertEquals(5, government.getByName("Unrest").getDecay()); + for (int i = 0; i < 5; i++) government.powerTick(); + assertEquals("Vacant Spymaster", government.getStabilityModifiers().getFirst().getName()); + assertEquals(1, government.getStabilityModifiers().size()); + faction.getEspionage().addUnrest(SpecialPosition.SPYMASTER, 10, 2, System.currentTimeMillis()); + assertEquals("Replaced Spymaster", government.getStabilityModifiers().getFirst().getName()); + assertTrue(government.getStabilityModifiers().getFirst().getModifier() < 0); + assertTrue(government.serialize().stabilityModifiers.isEmpty()); + } + + @Test + void favourAndRepressionAreMutuallyExclusiveForGuildsAndDirectSubjectsAndContributeToUpkeep() { + Faction faction = fixture.saved("home", "Alice"); + Government government = faction.getGovernment(); + Guild merchants = fixture.guild(faction, "merchants", "Bob"); + Guild artisans = fixture.guild(faction, "artisans", "Cara"); + Faction vassal = fixture.saved("vassal", "Drew"); + Faction foreign = fixture.saved("foreign", "Eve"); + fixture.subject(faction, vassal); + Guild subject = vassal.getOrCreateMainGuild(); + assertTrue(government.canAffectStability(merchants)); + assertTrue(government.canAffectStability(subject)); + assertFalse(government.canAffectStability(faction.getOrCreateMainGuild())); + assertFalse(government.canAffectStability(foreign.getOrCreateMainGuild())); + assertTrue(government.canFavour(merchants)); + assertTrue(government.canRepress(merchants)); + government.toggleFavour(merchants); + assertTrue(merchants.isFavoured()); + assertFalse(government.canRepress(merchants)); + government.toggleRepress(merchants); + assertFalse(merchants.isRepressed()); + government.toggleFavour(merchants); + assertFalse(merchants.isFavoured()); + government.toggleRepress(merchants); + assertTrue(merchants.isRepressed()); + assertFalse(government.canFavour(merchants)); + government.toggleFavour(merchants); + assertFalse(merchants.isFavoured()); + government.toggleRepress(merchants); + assertFalse(merchants.isRepressed()); + merchants.setFavoured(true); + artisans.setRepressed(true); + subject.setFavoured(true); + assertEquals(List.of(merchants, subject), government.getFavoured()); + assertEquals(List.of(artisans), government.getRepressed()); + double upkeep = + (merchants.getRepressFavourCost() + + artisans.getRepressFavourCost() + + subject.getRepressFavourCost()) + * StabilityDebuffs.upkeepFactor(government.getStability(), StabilityTuning.get()); + assertEquals(upkeep, government.getTotalUpkeep(), .0001); + merchants.setRepressed(true); + subject.setRepressed(true); + government.validateFavoursAndRepressions(); + assertFalse(merchants.isFavoured()); + assertFalse(subject.isFavoured()); + assertTrue(merchants.isRepressed()); + assertTrue(subject.isRepressed()); + assertTrue(government.getRepressed().contains(subject)); + assertEquals(25, government.getBaseStability()); + assertEquals(government.stateReport().legitimacy, government.getLegitimacy()); + assertEquals(government.stateReport().illegitimate, government.isIllegitimate()); + assertEquals(government.stateReport().status.getTaxFactor(), government.getTaxEfficiency()); + assertEquals( + faction.getModifier(FactionModifiers.DE_JURE).getAmount() + + StabilityDebuffs.deJureBonus(government.getStability(), StabilityTuning.get()), + government.deJureScore()); + assertTrue( + government.getStabilityString().endsWith(Double.toString(government.getStability()))); + } + + @Test + void movementRoundTripRetainsAllProposalKindsPoolsBackersAndFrozenState() { + fixture.lawGroup("finance", Map.of("name", "Existing")); + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + faction.addMember("Cara"); + Guild guild = fixture.guild(faction, "merchants", "Drew"); + Faction subject = fixture.saved("subject", "Eve"); + fixture.subject(faction, subject); + Faction backer = fixture.saved("foreign", "Finn"); + Government government = faction.getGovernment(); + Proposal law = new Proposal("Bob", government); + law.setLawProposal(fixture.law("finance", "new", Map.of("name", "New"))); + Proposal tax = tax(faction, "Bob", TaxTarget.CITIZENS, 14); + Proposal fee = new Proposal("Bob", government); + fee.setFeeProposal(new FeeChange(FeeKind.TRANSFER_FEE, "tf:cart", 1.5)); + Proposal political = new Proposal("Bob", government); + political.setPoliticalActionProposal(new PoliticalAction(Action.CHANGE_LEADER)); + political.setTarget("Cara"); + government.startMovement("Bob", law); + Movement movement = government.getMovementByLeader("bOB"); + assertNotNull(movement); + assertTrue(government.hasMovements()); + assertSame(movement, government.getMovementById(movement.getId().toUpperCase())); + assertSame(movement, government.getMovementByMember("Bob")); + assertNull(government.getMovementByMember("Stranger")); + assertNull(government.getMovementByLeader("Stranger")); + assertNull(government.getMovementById("missing")); + assertNull(government.getMovementByForeignBacker(backer)); + movement.getForeignBackers().add(backer); + assertSame(movement, government.getMovementByForeignBacker(backer)); + for (Proposal proposal : List.of(tax, fee, political)) + movement.addCause(new Cause(movement, proposal, "Bob")); + movement.getCauses().getFirst().getPool().addGuild(guild); + movement.getCauses().getFirst().getPool().addFaction(subject); + movement.getSupporters().addCitizen("Cara"); + movement.getSupporters().addGuild(guild); + movement.getSupporters().addFaction(subject); + movement.changeOrganization(7.25); + movement.setFrozen(true); + government.setPower(42.75); + government.addStabilityModifier(new StabilityModifier("Victory", 8, .5)); + government.getCouncil().addMemberForce("Cara"); + government.propose(fee); + when(fixture.ui.world.getName()).thenReturn("world"); + Location booth = new Location(fixture.ui.world, 2, 64, -3); + government.addVotingBooth(booth); + GovernmentData snapshot = + JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(government.serialize()), GovernmentData.class); + assertEquals(4, snapshot.movements.getFirst().causes.size()); + assertEquals( + List.of("law", "tax", "fee", "political"), + snapshot.movements.getFirst().causes.stream().map(cause -> cause.proposal.type).toList()); + assertEquals("tf:cart", snapshot.movements.getFirst().causes.get(2).proposal.feeVehicle); + assertEquals("Cara", snapshot.movements.getFirst().causes.get(3).proposal.target); + assertEquals(List.of("merchants"), snapshot.movements.getFirst().supporters.guilds); + assertEquals(List.of("subject"), snapshot.movements.getFirst().supporters.factions); + assertEquals(List.of("foreign"), snapshot.movements.getFirst().foreignBackers); + Government restored = new Government(faction, snapshot); + restored.loadMovements(); + assertEquals(42.75, restored.getPower()); + assertEquals(List.of("Cara"), restored.getCouncilMembers()); + assertEquals(8, restored.getByName("Victory").getModifier()); + assertTrue(restored.isVotingBooth(booth)); + assertTrue(restored.getCouncil().hasProposals()); + assertEquals(7.25, restored.getMovements().getFirst().getOrganization()); + assertTrue(restored.getMovements().getFirst().isFrozen()); + assertEquals(JsonUtil.GSON.toJson(snapshot), JsonUtil.GSON.toJson(restored.serialize())); + restored.deserializeMovements(faction, snapshot.movements); + assertEquals(1, restored.getMovements().size()); + restored.removeVotingBooth(booth); + assertFalse(restored.isVotingBooth(booth)); + restored.endMovement(restored.getMovements().getFirst()); + assertFalse(restored.hasMovements()); + } + + @Test + void movementDayTickSkipsFrozenCausesAndAdvancesOnlyActiveOrganization() { + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + faction.addMember("Cara"); + Government government = faction.getGovernment(); + government.startMovement("Bob", tax(faction, "Bob", TaxTarget.CITIZENS, 8)); + government.startMovement("Cara", tax(faction, "Cara", TaxTarget.GUILDS, 8)); + Movement active = government.getMovementByLeader("Bob"); + Movement frozen = government.getMovementByLeader("Cara"); + frozen.setFrozen(true); + double gain = active.getOrganizationGain(); + assertTrue(gain > 0); + government.applyDailyOrganizationGain(); + assertEquals(Math.min(gain, active.getMaxOrganization()), active.getOrganization()); + assertEquals(0, frozen.getOrganization()); + government.setLastElectionDate(); + government.tick(); + assertEquals("Never", government.getLastElectionString()); + assertTrue(government.getMovements().containsAll(List.of(active, frozen))); + faction.forceRemoveMember("Bob"); + government.tick(); + assertFalse(government.getMovements().contains(active)); + assertTrue(government.getMovements().contains(frozen)); + } + + @Test + void restoredGovernmentToleratesAbsentOptionalFieldsAndBadMovementRecordsIndependently() { + Faction faction = fixture.saved("home", "Alice"); + GovernmentData data = new GovernmentData(); + data.stabilityModifiers = null; + data.councilMembers = null; + data.proposals = null; + data.movements = null; + data.votingBooths = null; + data.electionCandidates = null; + data.electionVotes = null; + data.previousVotes = null; + data.eligibleVoters = null; + Government restored = new Government(faction, data); + assertEquals("Never", restored.getLastElectionString()); + assertNull(restored.getElectionEndDate()); + assertEquals("N/A", restored.getTimeUntilElectionEnds()); + assertEquals("N/A", restored.getTimeUntilNextElection()); + assertFalse(restored.hasCouncil()); + restored.cancelAllElections(); + restored.cancelElections(net.tfminecraft.simplefactions.government.election.Candidate.LEADER); + assertFalse(restored.hasElection()); + restored.ping(); + assertEquals(0, restored.getPower()); + MovementData valid = new MovementData(); + valid.id = "valid"; + List<MovementData> records = new ArrayList<>(); + records.add(null); + records.add(valid); + restored.deserializeMovements(faction, records); + assertEquals(1, restored.getMovements().size()); + assertEquals("valid", restored.getMovements().getFirst().getId()); + assertNull(restored.serialize().movements.getFirst().leader); + } + + @Test + void mondayElectionCadenceWaitsFourWeeksAndExpiresTheSevenDayBallot() { + fixture.lawGroup( + "democracy", Map.of("effects.faction.rules", List.of("LEADER_ELECTIONS true"))); + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + Government government = faction.getGovernment(); + var candidate = net.tfminecraft.simplefactions.government.election.Candidate.LEADER; + assertFalse(government.shouldStartElection()); + government.getElection().addCandidate(candidate, "Bob"); + LocalDate monday = LocalDate.of(2026, 10, 5); + try (MockedStatic<LocalDate> dates = mockStatic(LocalDate.class, CALLS_REAL_METHODS)) { + dates.when(LocalDate::now).thenReturn(monday.minusDays(1)); + assertFalse(government.shouldStartElection()); + dates.when(LocalDate::now).thenReturn(monday); + assertTrue(government.shouldStartElection()); + government.ping(); + assertTrue(government.hasElection()); + assertFalse(government.shouldStartElection()); + assertNotEquals("Never", government.getLastElectionString()); + assertEquals( + government.getLastElectionStartDate().plusDays(7), government.getElectionEndDate()); + assertNotEquals("N/A", government.getTimeUntilElectionEnds()); + LocalDate electionEnd = government.getElectionEndDate(); + dates.when(LocalDate::now).thenReturn(electionEnd); + government.ping(); + assertFalse(government.hasElection()); + assertEquals("Bob", faction.getLeader()); + assertEquals("N/A", government.getTimeUntilElectionEnds()); + GovernmentData recent = new GovernmentData(); + recent.lastElectionDate = + monday.minusDays(14).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(); + Government waiting = new Government(faction, recent); + waiting.getElection().addCandidate(candidate, "Alice"); + dates.when(LocalDate::now).thenReturn(monday); + assertFalse(waiting.shouldStartElection()); + assertEquals(monday.plusDays(14), waiting.getNextElectionStartDate()); + } + } + + @Test + void cancellingConstitutionalElectionsClearsBallotsWithoutReplacingTheCurrentLeader() { + fixture.lawGroup( + "democracy", Map.of("effects.faction.rules", List.of("LEADER_ELECTIONS true"))); + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + Government government = faction.getGovernment(); + var type = net.tfminecraft.simplefactions.government.election.Candidate.LEADER; + government.getElection().addCandidate(type, "Bob"); + government.getElection().start(); + government.getElection().addVote(type, "Alice", "Bob"); + government.cancelElections(type); + assertTrue(government.getElection().getCandidates(type).isEmpty()); + assertNull(government.getElection().getVote(type, "Alice")); + government.cancelAllElections(); + assertFalse(government.hasElection()); + assertEquals("Alice", faction.getLeader()); + assertTrue(government.getTimeUntilNextElection().matches("[0-9]+d [0-9]+h|Today")); + assertEquals(DayOfWeek.MONDAY, government.getNextElectionStartDate().getDayOfWeek()); + } + + @Test + void missingMovementLeaderDoesNotBreakLookupsForLaterValidMovements() { + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + MovementData leaderless = new MovementData(); + leaderless.id = "old"; + Government government = faction.getGovernment(); + government.deserializeMovements(faction, List.of(leaderless)); + government.startMovement("Bob", tax(faction, "Bob", TaxTarget.CITIZENS, 12)); + Movement found = assertDoesNotThrow(() -> government.getMovementByLeader("Bob")); + assertNotNull(found); + assertEquals("Bob", found.getLeader()); + assertNull(government.getMovementByLeader("Missing")); + } + + @Test + void councilMemberOptOutSurvivesCleanupAndVacatesTheSeat() { + Faction faction = councilFaction(Rules.APPOINTED_COUNCIL, 2); + Council council = faction.getGovernment().getCouncil(); + council.addMember("Bob"); + council.toggleRefuse("Bob"); + council.replace(); + assertFalse(council.isMember("Bob")); + assertTrue(council.refuses("Bob")); + assertFalse(council.canBeMember("Bob", false, false)); + } + + @Test + void leaderReplacementUsesAnEligibleElectionWinnerAndOtherwiseTheRemainingRoster() { + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + Government government = faction.getGovernment(); + faction.setLeader("Departed"); + government + .getElection() + .restoreFromData(null, null, Map.of("LEADER", Map.of("Outsider", 100, "Bob", 50)), null); + government.replace(); + assertEquals("Bob", faction.getLeader()); + faction.setLeader("GoneAgain"); + government + .getElection() + .restoreFromData(null, null, Map.of("LEADER", Map.of("Outsider", 100)), null); + government.replace(); + assertEquals("Alice", faction.getLeader()); + } + + @Test + void electionResultsRetainEligibleOldCouncilMembersWhenTheBallotLeavesSeatsOpen() { + Faction faction = councilFaction(Rules.ELECTED_COUNCIL, 3); + Council council = faction.getGovernment().getCouncil(); + council.addMemberForce("Bob"); + council.addMemberForce("Cara"); + faction + .getGovernment() + .getElection() + .restoreFromData(null, null, Map.of("COUNCIL", Map.of("Stranger", 100, "Drew", 50)), null); + faction.getGovernment().applyElectionResults(); + assertEquals(List.of("Drew", "Bob", "Cara"), council.getMembers()); + council.setCouncilSize(1); + faction.getGovernment().applyElectionResults(); + assertEquals(List.of("Drew"), council.getMembers()); + } + + @Test + void changingCouncilKindsClearsOldSeatsWhenRequiredAndPreservesElectedSeatsUntilTheVote() + throws Exception { + Faction faction = councilFaction(Rules.APPOINTED_COUNCIL, 2); + Council council = faction.getGovernment().getCouncil(); + java.lang.reflect.Field loaded = + net.tfminecraft.simplefactions.managers.FactionManager.class.getDeclaredField("loaded"); + loaded.setAccessible(true); + boolean previous = loaded.getBoolean(null); + try (MockedStatic<OfflineModifier> economy = mockStatic(OfflineModifier.class)) { + loaded.setBoolean(null, true); + council.addMember("Bob"); + var wealth = + fixture.law( + "constitution", + "wealth", + Map.of( + "effects.faction.rules", + List.of("HAS_COUNCIL true", "WEALTH_BASED_COUNCIL true"), + "effects.faction.council-size", + 1)); + economy + .when(() -> OfflineModifier.balance(anyString(), any(Accounts.class))) + .thenAnswer(call -> "Cara".equals(call.getArgument(0)) ? 1000.0 : 0.0); + faction.applyLaw(wealth, faction.getLawHandler().getGroup("constitution")); + council.reorganize(); + assertEquals(Rules.WEALTH_BASED_COUNCIL, council.getType()); + assertEquals(List.of("Cara"), council.getMembers()); + var elected = + fixture.law( + "constitution", + "elected", + Map.of( + "effects.faction.rules", + List.of("HAS_COUNCIL true", "ELECTED_COUNCIL true"), + "effects.faction.council-size", + 1)); + faction.applyLaw(elected, faction.getLawHandler().getGroup("constitution")); + council.reorganize(); + assertEquals(Rules.ELECTED_COUNCIL, council.getType()); + assertEquals(List.of("Cara"), council.getMembers()); + var none = + fixture.law( + "constitution", + "none", + Map.of("effects.faction.rules", List.of("HAS_COUNCIL false"))); + faction.applyLaw(none, faction.getLawHandler().getGroup("constitution")); + council.reorganize(); + assertEquals(Rules.NO_COUNCIL, council.getType()); + assertTrue(council.getMembers().isEmpty()); + } finally { + loaded.setBoolean(null, previous); + } + } + + @Test + void guildAndVassalLeadersCanRemainSeatedAndDummyAccountsCountTowardQuorum() { + Faction faction = councilFaction(Rules.APPOINTED_COUNCIL, 3); + fixture.guild(faction, "merchants", "Eve"); + Faction vassal = fixture.saved("vassal", "Grace"); + fixture.subject(faction, vassal); + Council council = faction.getGovernment().getCouncil(); + assertTrue(council.canRemainMember("Eve")); + assertTrue(council.canRemainMember("Grace")); + assertFalse(council.canRemainMember("Alice")); + council.addMemberForce("dummy_offline"); + fixture.player("Alice"); + assertTrue(council.hasEnoughValidVoters()); + } + + @Test + void movementRulesBlockAGuildRevoltWithinASingleProvinceHost() { + Faction faction = fixture.saved("home", "Alice"); + fixture.guild(faction, "merchants", "Bob"); + fixture.provinceData.put(7, new Province(7, "PLAINS", 1)); + faction.addProvince(7); + faction.getGovernment().startMovement("Bob", tax(faction, "Bob", TaxTarget.CITIZENS, 12)); + assertFalse(faction.getGovernment().hasMovements()); + assertTrue( + faction.getGovernment().canBeProposed(tax(faction, "Alice", TaxTarget.CITIZENS, 12))); + } + + @Test + void votingBoothRestoreSkipsMalformedEntriesAndUnavailableWorlds() { + Faction faction = fixture.saved("home", "Alice"); + when(fixture.ui.world.getName()).thenReturn("world"); + when(Bukkit.getWorld("removed")).thenReturn(null); + GovernmentData data = new GovernmentData(); + data.votingBooths = + new ArrayList<>(List.of("bad", "world,a,64,3", "removed,1,2,3", "world,-2,64,3")); + data.votingBooths.add(null); + Government government = new Government(faction, data); + Location booth = new Location(fixture.ui.world, -2, 64, 3); + assertEquals(List.of(booth), government.getVotingBooths()); + assertFalse(government.isVotingBooth(new Location(fixture.ui.world, 9, 64, 3))); + assertNull(Government.formatVotingBooth((Location) null)); + assertNull(Government.formatVotingBooth(new Location(null, 1, 2, 3))); + assertNull(Government.formatVotingBooth(" ", 1, 2, 3)); + assertNull(Government.parseVotingBoothParts(" ")); + assertNull(Government.parseVotingBoothParts(",1,2,3")); + assertArrayEquals( + new String[] {"world", "-2", "64", "3"}, Government.parseVotingBoothParts("world,-2,64,3")); + } + + @Test + void countdownReportsTodayAndExpiredBallotsAtExactCalendarBoundaries() { + fixture.lawGroup( + "democracy", Map.of("effects.faction.rules", List.of("LEADER_ELECTIONS true"))); + Faction faction = fixture.saved("home", "Alice"); + LocalDate monday = LocalDate.of(2026, 10, 5); + Instant noon = monday.atStartOfDay(ZoneId.systemDefault()).toInstant().plusSeconds(43200); + Instant afterBallot = noon.plusSeconds(8 * 86400); + try (MockedStatic<LocalDate> dates = mockStatic(LocalDate.class, CALLS_REAL_METHODS); + MockedStatic<Instant> instants = mockStatic(Instant.class, CALLS_REAL_METHODS)) { + dates.when(LocalDate::now).thenReturn(monday); + instants.when(Instant::now).thenReturn(noon); + assertEquals("Today", faction.getGovernment().getTimeUntilNextElection()); + GovernmentData data = new GovernmentData(); + data.lastElectionDate = noon.toEpochMilli(); + Government active = new Government(faction, data); + assertTrue(active.hasElection()); + assertEquals("6d 12h", active.getTimeUntilElectionEnds()); + instants.when(Instant::now).thenReturn(afterBallot); + assertEquals("0d 0h", active.getTimeUntilElectionEnds()); + assertEquals("0d 0h", Government.formatTimeUntil(afterBallot, noon)); + assertEquals("0d 0h", Government.formatTimeUntil(null, noon)); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/government/GovernmentProcessesCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/government/GovernmentProcessesCoverageTest.java new file mode 100644 index 00000000..76b174d3 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/government/GovernmentProcessesCoverageTest.java @@ -0,0 +1,546 @@ +package net.tfminecraft.simplefactions.government; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.tfminecraft.simplefactions.government.election.Candidate; +import net.tfminecraft.simplefactions.government.election.Election; +import net.tfminecraft.simplefactions.government.proposal.FeeChange; +import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.government.proposal.Proposal; +import net.tfminecraft.simplefactions.government.proposal.TaxLawChange; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.government.session.Session; +import net.tfminecraft.simplefactions.government.session.SessionReport; +import net.tfminecraft.simplefactions.government.session.Vote; +import net.tfminecraft.simplefactions.government.session.VoteResult; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.SessionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Particle; +import org.bukkit.block.Block; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.bukkit.entity.TextDisplay; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BookMeta; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class GovernmentProcessesCoverageTest { + private FactionDomainFixture fixture; + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + when(Bukkit.getPlayer(anyString())).thenAnswer(call -> fixture.online.get(call.getArgument(0))); + when(Bukkit.getOnlinePlayers()).thenAnswer(call -> new ArrayList<>(fixture.online.values())); + when(fixture.ui.plugin.getSessionManager()).thenReturn(new SessionManager()); + } + + @AfterEach + void cleanup() { + if (fixture != null) fixture.close(); + } + + @Test + void validatingCompletedBallotsPreservesVotesFromStillEligibleMembers() { + fixture.lawGroup( + "democracy", Map.of("effects.faction.rules", List.of("LEADER_ELECTIONS true"))); + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + Election election = faction.getGovernment().getElection(); + election.addCandidate(Candidate.LEADER, "Bob"); + election.start(); + assertTrue(election.canVote("Alice")); + election.addVote(Candidate.LEADER, "Alice", "Bob"); + assertFalse(election.canVote("Alice")); + assertEquals(1, election.getVotes(Candidate.LEADER, "Bob")); + + election.validateVotes(); + + assertEquals(1, election.getVotes(Candidate.LEADER, "Bob")); + assertEquals("Bob", election.getVote(Candidate.LEADER, "Alice")); + election.end(); + assertEquals("Bob", faction.getLeader()); + assertEquals(Map.of("Bob", 1), election.getPreviousVotes().get(Candidate.LEADER)); + assertFalse(election.isActive()); + assertTrue(election.getCandidates(Candidate.LEADER).isEmpty()); + assertNull(election.getVote(Candidate.LEADER, "Alice")); + } + + @Test + void voteValidationStillRemovesDepartedVotersMissingCandidatesAndUnregisteredVoters() { + fixture.lawGroup( + "democracy", Map.of("effects.faction.rules", List.of("LEADER_ELECTIONS true"))); + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + faction.addMember("Cara"); + Election election = faction.getGovernment().getElection(); + election.addCandidate(Candidate.LEADER, "Bob"); + election.start(); + election.addVote(Candidate.LEADER, "Alice", "Missing"); + election.addVote(Candidate.LEADER, "Cara", "Bob"); + election.addVote(Candidate.LEADER, "Outsider", "Bob"); + faction.forceRemoveMember("Cara"); + election.validateVotes(); + assertTrue(election.serializeVotes().get("LEADER").isEmpty()); + assertFalse(election.canVote("Cara")); + assertFalse(election.canVote("Outsider")); + election.addVote(Candidate.LEADER, "Alice", "Bob"); + election.removeCandidate(Candidate.LEADER, "Bob"); + assertNull(election.getVote(Candidate.LEADER, "Alice")); + assertFalse(election.hasAnyCandidates()); + } + + @Test + void electionEligibilityIncludesEntitledSubjectsAndEachEnabledBallotMustBeCompleted() { + fixture.lawGroup( + "democracy", + Map.of( + "effects.faction.rules", + List.of( + "LEADER_ELECTIONS true", + "HAS_COUNCIL true", + "ELECTED_COUNCIL true", + "VASSAL_VOTING_RIGHTS true"), + "effects.faction.council-size", + 2)); + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + faction.addMember("Cara"); + Faction vassal = fixture.saved("vassal", "Drew"); + fixture.subject(faction, vassal); + Election election = faction.getGovernment().getElection(); + election.addCandidate(Candidate.LEADER, "Bob"); + election.addCandidate(Candidate.COUNCIL, "Cara"); + assertTrue(election.hasAnyCandidates()); + election.start(); + assertTrue( + election.getStoredEligibleVoters().containsAll(List.of("Alice", "Bob", "Cara", "Drew"))); + assertTrue(election.canVote("Drew")); + election.addVote(Candidate.LEADER, "Drew", "Bob"); + assertTrue(election.hasVoted(Candidate.LEADER, "Drew")); + assertFalse(election.hasVoted("Drew")); + assertTrue(election.canVote("Drew")); + election.addVote(Candidate.COUNCIL, "Drew", "Cara"); + assertTrue(election.hasVoted("Drew")); + assertFalse(election.canVote("Drew")); + election.validateVotes(); + assertEquals(1, election.getVotes(Candidate.LEADER, "Bob")); + assertEquals(1, election.getVotes(Candidate.COUNCIL, "Cara")); + election.end(); + assertEquals("Bob", faction.getLeader()); + assertEquals("Cara", faction.getGovernment().getCouncilMembers().getFirst()); + } + + @Test + void electionRanksLiveAndArchivedResultsWithStableAlphabeticalTiesAndIndependentSnapshots() { + fixture.lawGroup( + "democracy", Map.of("effects.faction.rules", List.of("LEADER_ELECTIONS true"))); + Faction faction = fixture.saved("home", "Alice"); + for (String name : List.of("Bob", "Cara", "Drew")) faction.addMember(name); + Election election = faction.getGovernment().getElection(); + Map<String, List<String>> candidates = new LinkedHashMap<>(); + candidates.put("LEADER", new ArrayList<>(List.of("Drew", "Cara", "Bob"))); + candidates.put("REMOVED_TYPE", List.of("Invalid")); + Map<String, Map<String, String>> votes = + Map.of("LEADER", Map.of("Alice", "Cara"), "BAD", Map.of()); + Map<String, Map<String, Integer>> previous = + Map.of("LEADER", Map.of("Bob", 3, "Cara", 3, "Drew", 1), "BAD", Map.of()); + election.restoreFromData(candidates, votes, previous, List.of("Alice", "Bob")); + candidates.get("LEADER").clear(); + assertEquals(List.of("Bob", "Cara", "Drew"), election.getWinners(Candidate.LEADER)); + assertEquals(List.of("Drew", "Cara", "Bob"), election.getCandidates(Candidate.LEADER)); + assertEquals("Cara", election.getVote(Candidate.LEADER, "Alice")); + election.serializeCandidates().get("LEADER").clear(); + election.serializeVotes().get("LEADER").clear(); + election.serializePreviousVotes().get("LEADER").clear(); + assertEquals(3, election.getCandidates(Candidate.LEADER).size()); + assertEquals(1, election.getVotes(Candidate.LEADER, "cARA")); + election.start(); + assertNull(election.getVote(Candidate.LEADER, "Alice")); + election.addVote(Candidate.LEADER, "Alice", "Drew"); + assertEquals(List.of("Drew", "Bob", "Cara"), election.getWinners(Candidate.LEADER)); + election.cancel(Candidate.LEADER); + assertTrue(election.isActive()); + assertTrue(election.serializePreviousVotes().get("LEADER").isEmpty()); + election.cancelAll(); + assertFalse(election.isActive()); + assertTrue(election.serializeCandidates().values().stream().allMatch(List::isEmpty)); + } + + @Test + void + candidateRulesExcludeGuildLeadersAndDuplicateGuildTicketsWhilePreservingTheExistingCandidate() { + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + Guild guild = fixture.guild(faction, "merchants", "Eve"); + guild.addMember("Finn"); + guild.addMember("Grace"); + Election election = faction.getGovernment().getElection(); + assertFalse(election.canBeCandidate(Candidate.LEADER, "Stranger")); + assertFalse(election.canBeCandidate(Candidate.LEADER, "Eve")); + assertTrue(election.canBeCandidate(Candidate.LEADER, "Finn")); + election.addCandidate(Candidate.LEADER, "Finn"); + assertTrue(election.isCandiate(Candidate.LEADER, "Finn")); + assertFalse(election.canBeCandidate(Candidate.LEADER, "Finn")); + assertTrue(election.canBeCandidate(Candidate.LEADER, "Finn", false)); + assertFalse(election.canBeCandidate(Candidate.LEADER, "Grace")); + assertFalse(election.otherCandidateExists(Candidate.LEADER, "Bob")); + assertFalse(election.otherCandidateExists(Candidate.LEADER, "Stranger")); + election.addCandidate(Candidate.LEADER, "Bob"); + election.addCandidate(Candidate.LEADER, "Missing"); + election.validateCandidates(); + assertEquals(List.of("Finn", "Bob"), election.getCandidates(Candidate.LEADER)); + List<String> lore = new ArrayList<>(); + election.applyReasons(lore, Candidate.LEADER, fixture.player("Finn")); + assertTrue(lore.isEmpty()); + election.applyReasons(lore, Candidate.LEADER, fixture.player("Eve")); + assertTrue(lore.stream().anyMatch(line -> line.contains("Guild leaders"))); + lore.clear(); + election.applyReasons(lore, Candidate.LEADER, fixture.player("Stranger")); + assertTrue(lore.stream().anyMatch(line -> line.contains("must be a member"))); + lore.clear(); + election.applyReasons(lore, Candidate.LEADER, fixture.player("Grace")); + assertTrue(lore.stream().anyMatch(line -> line.contains("already has a Leader candidate"))); + election.addCandidate(Candidate.COUNCIL, "Eve"); + assertFalse(election.canBeCandidate(Candidate.COUNCIL, "Eve")); + assertFalse(election.canBeCandidate(Candidate.COUNCIL, "Finn")); + lore.clear(); + election.applyReasons(lore, Candidate.COUNCIL, fixture.player("Finn")); + assertTrue(lore.stream().anyMatch(line -> line.contains("already has a Council candidate"))); + election.start(); + lore.clear(); + election.applyReasons(lore, Candidate.LEADER, fixture.player("Alice")); + assertTrue(lore.stream().anyMatch(line -> line.contains("voting phase"))); + } + + @Test + void activeElectionNotifiesOnlyUnfinishedEligibleVotersAtConfiguredBooths() { + fixture.lawGroup( + "democracy", Map.of("effects.faction.rules", List.of("LEADER_ELECTIONS true"))); + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + Player alice = fixture.player("Alice"); + Player bob = fixture.player("Bob"); + Player stranger = fixture.player("Stranger"); + Election election = faction.getGovernment().getElection(); + election.addCandidate(Candidate.LEADER, "Bob"); + election.start(); + election.addVote(Candidate.LEADER, "Bob", "Bob"); + for (int i = 0; i < 301; i++) election.tick(); + verify(alice, never()).sendMessage(anyString()); + faction.getGovernment().addVotingBooth(new Location(fixture.ui.world, 4, 65, -3)); + for (int i = 0; i < 301; i++) election.tick(); + verify(alice).sendMessage(contains("Active Election!")); + verify(alice).sendMessage(contains("4§fx §e65§fy §e-3§fz")); + verify(bob, never()).sendMessage(anyString()); + verify(stranger, never()).sendMessage(anyString()); + } + + private Faction sessionFaction() { + fixture.lawGroup( + "constitution", + Map.of( + "effects.faction.rules", + List.of("HAS_COUNCIL true", "APPOINTED_COUNCIL true"), + "effects.faction.council-size", + 3)); + Faction faction = fixture.saved("home", "Alice"); + fixture.player("Alice"); + for (String member : List.of("Bob", "Cara", "Drew")) { + faction.addMember(member); + faction.getGovernment().getCouncil().addMemberForce(member); + } + fixture.player("Bob"); + fixture.player("Cara"); + return faction; + } + + private Proposal tax(Faction faction, TaxTarget target, String id, double rate) { + Proposal proposal = new Proposal("Alice", faction.getGovernment()); + proposal.setTaxProposal(new TaxLawChange(target, id, rate)); + return proposal; + } + + private Session session(Faction faction) { + SessionManager manager = fixture.ui.plugin.getSessionManager(); + manager.newSession(fixture.online.get("Alice"), faction); + return manager.getSession(faction.getGovernment().getCouncil()); + } + + private Block lantern() { + Block block = mock(Block.class); + when(block.getWorld()).thenReturn(fixture.ui.world); + when(block.getLocation()).thenAnswer(call -> new Location(fixture.ui.world, 1, 64, 2)); + when(fixture.ui.world.spawnEntity(any(Location.class), eq(EntityType.TEXT_DISPLAY))) + .thenAnswer(call -> mock(TextDisplay.class)); + return block; + } + + private BookMeta report(Player player) { + ItemStack report = + Arrays.stream(player.getInventory().getContents()) + .filter(item -> item != null && item.getType() == Material.WRITTEN_BOOK) + .findFirst() + .orElseThrow(); + return (BookMeta) report.getItemMeta(); + } + + @Test + void multiProposalSessionKeepsEachRecordedVoteCountInItsFinalReport() { + Faction faction = sessionFaction(); + Proposal first = tax(faction, TaxTarget.CITIZENS, null, 12); + Proposal second = tax(faction, TaxTarget.GUILDS, null, 30); + faction.getGovernment().propose(first); + faction.getGovernment().propose(second); + Session session = session(faction); + Block lantern = lantern(); + session.onLanternClick(lantern); + assertTrue(session.isStarted()); + assertSame(first, session.getCurrentProposal()); + assertSame(faction, session.getFaction()); + assertSame(lantern, session.getLantern()); + assertSame(fixture.online.get("Alice"), session.getLeader()); + assertFalse(session.recordVote("Stranger", Vote.YAY)); + assertTrue(session.recordVote("Alice", Vote.YAY)); + assertTrue(session.recordVote("Bob", Vote.YAY)); + assertTrue(session.recordVote("Cara", Vote.NAY)); + assertSame(first, session.getCurrentProposal()); + fixture.ui.runTasks(); + assertNull(session.getCurrentProposal()); + fixture.ui.runTasks(); + assertSame(second, session.getCurrentProposal()); + session.recordVote("Alice", Vote.NAY); + session.recordVote("Bob", Vote.NAY); + session.recordVote("Cara", Vote.ABSTAIN); + fixture.ui.runTasks(); + fixture.ui.runTasks(); + assertFalse(faction.getGovernment().getCouncil().hasSession()); + assertEquals(12, faction.getTaxRate()); + assertEquals(10, faction.getTaxRate(TaxTarget.GUILDS, null, false)); + BookMeta report = report(fixture.online.get("Alice")); + assertEquals(3, report.getPages().size()); + assertTrue(report.getPages().get(1).contains("§a2 §aYay")); + assertTrue(report.getPages().get(1).contains("§c1 §cNay")); + assertTrue(report.getPages().get(1).contains("§71 §7Abstain")); + assertTrue(report.getPages().get(2).contains("§a0 §aYay")); + assertTrue(report.getPages().get(2).contains("§c2 §cNay")); + assertTrue(report.getPages().get(2).contains("§72 §7Abstain")); + } + + @Test + void sessionTimesOutBeforeSelectionAndRejectsInsufficientQuorumWithoutConsumingProposals() { + Faction faction = sessionFaction(); + Proposal proposal = tax(faction, TaxTarget.CITIZENS, null, 12); + faction.getGovernment().propose(proposal); + Session timeout = session(faction); + assertFalse(timeout.isStarted()); + for (int i = 0; i < 59; i++) timeout.tick(); + assertTrue(faction.getGovernment().getCouncil().hasSession()); + timeout.tick(); + assertFalse(faction.getGovernment().getCouncil().hasSession()); + assertEquals( + List.of(proposal), + faction.getGovernment().getCouncil().getProposalHandler().getProposals()); + when(fixture.online.get("Bob").isOnline()).thenReturn(false); + Session denied = session(faction); + denied.onLanternClick(lantern()); + assertFalse(denied.isStarted()); + verify(fixture.online.get("Alice")).sendMessage(contains("Not enough council members online")); + assertEquals( + List.of(proposal), + faction.getGovernment().getCouncil().getProposalHandler().getProposals()); + } + + @Test + void tieLeavesTheLawUnchangedAndStartedSessionIgnoresAnotherLantern() { + Faction faction = sessionFaction(); + faction.getGovernment().propose(tax(faction, TaxTarget.CITIZENS, null, 12)); + Session session = session(faction); + Block lantern = lantern(); + session.onLanternClick(lantern); + session.onLanternClick(mock(Block.class)); + assertSame(lantern, session.getLantern()); + session.recordVote("Alice", Vote.YAY); + session.recordVote("Bob", Vote.NAY); + session.recordVote("Cara", Vote.ABSTAIN); + fixture.ui.runTasks(); + session.countVotes(); + fixture.ui.runTasks(); + assertEquals(5, faction.getTaxRate()); + assertTrue(report(fixture.online.get("Alice")).getPages().get(1).contains("TIED")); + verify(fixture.ui.world, times(20)) + .spawnParticle(eq(Particle.NOTE), any(Location.class), eq(1), eq(.5), eq(.5), eq(.5)); + session.kill(); + session.tick(); + } + + @Test + void offlineSessionLeaderStillAppliesDecidedProposalsWithoutCreatingABook() { + Faction faction = sessionFaction(); + faction.getGovernment().propose(tax(faction, TaxTarget.CITIZENS, null, 12)); + Session session = session(faction); + session.start(); + assertNull(session.getLantern()); + session.recordVote("Alice", Vote.YAY); + session.recordVote("Bob", Vote.YAY); + session.recordVote("Cara", Vote.ABSTAIN); + fixture.ui.runTasks(); + when(fixture.online.get("Alice").isOnline()).thenReturn(false); + fixture.ui.runTasks(); + assertEquals(12, faction.getTaxRate()); + assertTrue( + Arrays.stream(fixture.online.get("Alice").getInventory().getContents()) + .allMatch(item -> item == null)); + assertFalse(faction.getGovernment().getCouncil().hasSession()); + } + + @ParameterizedTest + @EnumSource( + value = TaxTarget.class, + names = {"GUILD_ID", "VASSAL_ID", "TARIFF_ID"}) + void reportPreservesDeletedTaxTargetIdentityInsteadOfFailingTheCompletedSession( + TaxTarget target) { + Faction faction = fixture.saved("home", "Alice"); + SessionReport report = new SessionReport("Alice", faction); + report.addResult(tax(faction, target, "deleted_faction", 15), VoteResult.PASSED, 3, 0, 1); + BookMeta book = (BookMeta) assertDoesNotThrow(report::generateReportBook).getItemMeta(); + assertTrue(book.getPages().get(1).contains("deleted_faction")); + TaxTarget baseTarget = switch (target) { + case GUILD_ID -> TaxTarget.GUILDS; + case VASSAL_ID -> TaxTarget.VASSALS; + case TARIFF_ID -> TaxTarget.TARIFFS; + default -> throw new AssertionError(target); + }; + double baseRate = faction.getTaxRate(baseTarget, null, false); + assertTrue(baseRate >= 0); + assertTrue(book.getPages().get(1).contains(String.format("%.0f", baseRate) + "% §0→ 15.0%")); + } + + @Test + void reportDescribesLawTaxAndVehicleFeeOutcomesWithTotalsAndVoteBreakdowns() { + fixture.lawGroup("finance", Map.of("name", "Existing law")); + Faction faction = fixture.saved("home", "Alice"); + Guild guild = fixture.guild(faction, "merchants", "Bob"); + Faction vassal = fixture.saved("vassal", "Cara"); + SessionReport report = new SessionReport("Alice", faction); + Proposal law = new Proposal("Alice", faction.getGovernment()); + law.setLawProposal(fixture.law("finance", "new", Map.of("name", "New law"))); + report.addResult(law, VoteResult.PASSED, 3, 0, 1); + report.addResult( + tax(faction, TaxTarget.GUILD_ID, guild.getId(), 15), VoteResult.FAILED, 1, 2, 1); + report.addResult( + tax(faction, TaxTarget.VASSAL_ID, vassal.getId(), 16), VoteResult.TIE, 2, 2, 0); + report.addResult( + tax(faction, TaxTarget.TARIFF_ID, vassal.getId(), 17), VoteResult.PASSED, 3, 0, 1); + Proposal fee = new Proposal("Alice", faction.getGovernment()); + fee.setFeeProposal(new FeeChange(FeeKind.REGISTRATION_FEE, null, 1.5)); + report.addResult(fee, VoteResult.PASSED, 4, 0, 0); + BookMeta book = (BookMeta) report.generateReportBook().getItemMeta(); + assertEquals("Governor Alice", book.getAuthor()); + assertTrue(book.getTitle().contains("Council Session")); + assertEquals(6, book.getPages().size()); + assertTrue(book.getPages().getFirst().contains("Total Proposals\n5")); + assertTrue(book.getPages().getFirst().contains("§aPassed§0: 3")); + assertTrue(book.getPages().get(1).contains("New law")); + assertTrue(book.getPages().get(2).contains(guild.getName())); + assertTrue(book.getPages().get(3).contains(vassal.getName())); + assertTrue(book.getPages().get(4).contains("17.0%")); + assertTrue(book.getPages().get(5).contains("1.5x upkeep")); + } + + @Test + void aFullInventoryDoesNotDiscardTheCompletedSessionReport() { + Faction faction = sessionFaction(); + faction.getGovernment().propose(tax(faction, TaxTarget.CITIZENS, null, 12)); + Player alice = fixture.online.get("Alice"); + when(alice.getWorld()).thenReturn(fixture.ui.world); + for (int slot = 0; slot < alice.getInventory().getSize(); slot++) + alice.getInventory().setItem(slot, new ItemStack(Material.STONE, 64)); + Session session = session(faction); + session.start(); + session.recordVote("Alice", Vote.YAY); + session.recordVote("Bob", Vote.YAY); + session.recordVote("Cara", Vote.ABSTAIN); + fixture.ui.runTasks(); + fixture.ui.runTasks(); + assertEquals(12, faction.getTaxRate()); + assertTrue( + Arrays.stream(alice.getInventory().getContents()) + .allMatch(item -> item.getType() == Material.STONE && item.getAmount() == 64)); + verify(fixture.ui.world) + .dropItemNaturally( + eq(alice.getLocation()), + argThat( + (ItemStack item) -> + item.getType() == Material.WRITTEN_BOOK + && ((BookMeta) item.getItemMeta()).getPages().size() == 2)); + assertFalse(faction.getGovernment().getCouncil().hasSession()); + } + + @Test + void councilCandidateRegistrationRejectsOutsidersAndOrdinaryGuildMembers() { + fixture.lawGroup( + "democracy", + Map.of("effects.faction.rules", List.of("HAS_COUNCIL true", "ELECTED_COUNCIL true"))); + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + Guild guild = fixture.guild(faction, "merchants", "Cara"); + guild.addMember("Drew"); + Election election = faction.getGovernment().getElection(); + assertTrue(election.canBeCandidate(Candidate.COUNCIL, "Bob")); + assertTrue(election.canBeCandidate(Candidate.COUNCIL, "Cara")); + assertFalse(election.canBeCandidate(Candidate.COUNCIL, "Outsider")); + assertFalse(election.canBeCandidate(Candidate.COUNCIL, "Drew")); + assertFalse(election.canBeCandidate(Candidate.COUNCIL, "Alice")); + } + + @Test + void cancellingDisabledBallotsClearsTicketsAndDifferentGuildsCanPresentIndependentCandidates() { + Faction faction = fixture.saved("home", "Alice"); + Guild first = fixture.guild(faction, "merchants", "Bob"); + Guild second = fixture.guild(faction, "artisans", "Cara"); + second.addMember("Drew"); + Election election = faction.getGovernment().getElection(); + election.addCandidate(Candidate.COUNCIL, "Missing"); + election.addCandidate(Candidate.COUNCIL, "Alice"); + election.addCandidate(Candidate.COUNCIL, first.getLeader()); + assertFalse(election.otherCandidateExists(Candidate.COUNCIL, second.getLeader())); + election.cancel(Candidate.COUNCIL); + assertFalse(election.isActive()); + assertTrue(election.getCandidates(Candidate.COUNCIL).isEmpty()); + } + + @Test + void finishingANewElectionReplacesPreviousResultsInsteadOfKeepingWithdrawnCandidates() { + fixture.lawGroup( + "democracy", Map.of("effects.faction.rules", List.of("LEADER_ELECTIONS true"))); + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + faction.addMember("Cara"); + Election election = faction.getGovernment().getElection(); + election.restoreFromData(null, null, Map.of("LEADER", Map.of("Bob", 50)), null); + election.addCandidate(Candidate.LEADER, "Cara"); + election.start(); + election.addVote(Candidate.LEADER, "Alice", "Cara"); + election.end(); + assertEquals("Cara", faction.getLeader()); + assertEquals(Map.of("Cara", 1), election.getPreviousVotes().get(Candidate.LEADER)); + assertEquals(List.of("Cara"), election.getWinners(Candidate.LEADER)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/government/PoliticalObjectsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/government/PoliticalObjectsCoverageTest.java new file mode 100644 index 00000000..60986955 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/government/PoliticalObjectsCoverageTest.java @@ -0,0 +1,490 @@ +package net.tfminecraft.simplefactions.government; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Map; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.enums.Stance; +import net.tfminecraft.simplefactions.government.handler.ProposalHandler; +import net.tfminecraft.simplefactions.government.movement.Action; +import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.government.movement.PoliticalAction; +import net.tfminecraft.simplefactions.government.movement.Pool; +import net.tfminecraft.simplefactions.government.movement.cause.Cause; +import net.tfminecraft.simplefactions.government.proposal.FeeChange; +import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.government.proposal.Proposal; +import net.tfminecraft.simplefactions.government.proposal.TaxLawChange; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.laws.Law; +import net.tfminecraft.simplefactions.laws.LawGroup; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemCreator; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BookMeta; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +class PoliticalObjectsCoverageTest { + private FactionDomainFixture fixture; + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + } + + @AfterEach + void cleanup() { + if (fixture != null) fixture.close(); + } + + private Faction host() { + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + faction.addMember("Cara"); + return faction; + } + + private Proposal political(Faction faction, Action action) { + Proposal proposal = new Proposal("Bob", faction.getGovernment()); + proposal.setPoliticalActionProposal(new PoliticalAction(action)); + return proposal; + } + + private PoliticalAction restricted(String... pools) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("action.pools", List.of(pools)); + return new PoliticalAction("CHANGE_LEADER", yaml.getConfigurationSection("action")); + } + + private Movement movement(Faction faction) { + faction.getGovernment().startMovement("Bob", political(faction, Action.CHANGE_LEADER)); + return faction.getGovernment().getMovementByLeader("Bob"); + } + + private String pages(ItemStack item) { + assertEquals(Material.WRITTEN_BOOK, item.getType()); + return org.bukkit.ChatColor.stripColor( + String.join("\n", ((BookMeta) item.getItemMeta()).getPages())); + } + + @Test + void causeRejectsCitizensOutsideItsPermittedPoolAndDropsDepartedMembers() { + Faction faction = host(); + Movement movement = movement(faction); + Cause cause = movement.getCauses().getFirst(); + assertSame(movement, cause.getMovement()); + assertEquals(Action.CHANGE_LEADER, cause.getAction()); + assertEquals(0, cause.getIndex()); + assertEquals(List.of("Bob"), cause.getCitizenList()); + assertEquals(List.of("Bob"), cause.getFullMemberList()); + assertNotNull(cause.joinBlockReason(42, true)); + assertNotNull(cause.memberBlockReason("Alice", true, true)); + assertFalse(cause.canBeLeader(null)); + assertFalse(cause.canBeLeader("Cara")); + cause.getProposal().setPoliticalActionProposal(restricted("guilds")); + assertNotNull(cause.memberBlockReason("Cara", true, true)); + assertFalse(cause.canBeLeader("Bob")); + cause.join("Cara"); + assertEquals(List.of("Bob"), cause.getCitizenList()); + cause.getProposal().setPoliticalActionProposal(restricted("citizens")); + cause.join("Cara"); + cause.join("Cara"); + assertEquals(List.of("Bob", "Cara"), cause.getCitizenList()); + faction.forceRemoveMember("Bob"); + cause.tick(); + assertNull(cause.getLeader()); + assertEquals(List.of("Cara"), cause.getFullMemberList()); + assertTrue(cause.canBeLeader("Cara")); + cause.leave("Cara"); + cause.tick(); + assertFalse(movement.getCauses().contains(cause)); + } + + @Test + void guildCauseEligibilityUsesPoolRoleRealmStanceAndExistingMembership() { + Faction faction = host(); + Movement movement = movement(faction); + Cause cause = movement.getCauses().getFirst(); + Guild guild = fixture.guild(faction, "merchants", "Drew"); + guild.addMember("Eve"); + guild.setStance(Stance.OPPOSE); + cause.getProposal().setPoliticalActionProposal(restricted("citizens")); + assertNotNull(cause.guildBlockReason(guild, true, true)); + cause.getProposal().setPoliticalActionProposal(restricted("guilds", "citizens")); + assertNotNull(cause.guildBlockReason(faction.getOrCreateMainGuild(), true, false)); + Guild foreign = fixture.guild(fixture.saved("foreign", "Finn"), "foreign_guild", "Grace"); + assertNotNull(cause.guildBlockReason(foreign, true, true)); + guild.setStance(Stance.SUPPORT); + assertNotNull(cause.guildBlockReason(guild, true, false)); + guild.setStance(Stance.OPPOSE); + assertNull(cause.guildBlockReason(guild, true, true)); + cause.join(guild); + assertEquals(List.of(guild), cause.getPool().getGuilds()); + assertTrue(cause.canBeLeader("Drew")); + assertTrue(cause.canBeLeader("Eve")); + assertNotNull(cause.joinBlockReason(guild, true)); + cause.leave(guild); + assertTrue(cause.getPool().getGuilds().isEmpty()); + fixture.provinceData.put(1, new Province(1, "PLAINS", 1)); + faction.addProvince(1); + assertNotNull(cause.guildBlockReason(guild, true, false)); + } + + @Test + void factionCauseEligibilityUsesPoolVassalageStanceAndExistingMembership() { + Faction faction = host(); + Movement movement = movement(faction); + Cause cause = movement.getCauses().getFirst(); + Faction vassal = fixture.saved("vassal", "Drew"); + vassal.addMember("Eve"); + cause.getProposal().setPoliticalActionProposal(restricted("citizens")); + assertNotNull(cause.factionBlockReason(vassal, true, true)); + cause.getProposal().setPoliticalActionProposal(restricted("factions", "citizens")); + assertNotNull(cause.factionBlockReason(faction, true, true)); + assertNotNull(cause.factionBlockReason(vassal, true, false)); + fixture.subject(faction, vassal); + vassal.getOrCreateMainGuild().setStance(Stance.SUPPORT); + assertNotNull(cause.factionBlockReason(vassal, true, true)); + vassal.getOrCreateMainGuild().setStance(Stance.OPPOSE); + cause.join(vassal); + assertEquals(List.of(vassal), cause.getPool().getFactions()); + assertNotNull(cause.factionBlockReason(vassal, true, false)); + assertTrue(cause.canBeLeader("Drew")); + assertFalse(cause.canBeLeader("Eve")); + cause.leave(vassal); + assertTrue(cause.getPool().getFactions().isEmpty()); + } + + @Test + void persistedCauseWithoutAnEligibleFounderRetainsExplicitPoolAndRequiresANewLeader() { + Faction faction = host(); + Movement movement = movement(faction); + Cause restored = new Cause(movement, political(faction, Action.NONE), "Departed"); + assertFalse(restored.hasLeader()); + assertTrue(restored.isEmpty()); + restored.setPool( + new Pool( + new java.util.ArrayList<>(List.of("Cara")), + new java.util.ArrayList<>(), + new java.util.ArrayList<>())); + movement.addCause(restored); + assertEquals(1, restored.getIndex()); + assertTrue(restored.canBeLeader("Cara")); + restored.setLeader("Cara"); + restored.tick(); + assertEquals("Cara", restored.getLeader()); + assertTrue(movement.getCauses().contains(restored)); + } + + @Test + void lawApplicationHonorsTheCurrentSwitchLockWhileSuccessfulMovementsCanOverrideIt() { + fixture.lawGroup("finance", Map.of()); + Faction faction = host(); + Law next = fixture.law("finance", "reform", Map.of()); + LawGroup group = faction.getLawHandler().getGroup("finance"); + Proposal proposal = new Proposal("Bob", faction.getGovernment()); + proposal.setLawProposal(next); + assertEquals("Bob", proposal.getProposer()); + assertSame(next, proposal.getLaw()); + assertEquals(Action.LAW_CHANGE, proposal.getPoliticalAction().getAction()); + double previousLock = Cache.lawSwitchLockDays; + try { + Cache.lawSwitchLockDays = 1; + group.setChangedAt(System.currentTimeMillis()); + Law original = group.getCurrent(); + proposal.apply(null); + assertSame(original, group.getCurrent()); + Cause cause = new Cause(new Movement(faction, "Bob", proposal), proposal, "Bob"); + proposal.apply(cause); + assertSame(next, group.getCurrent()); + } finally { + Cache.lawSwitchLockDays = previousLock; + } + } + + @ParameterizedTest + @EnumSource(TaxTarget.class) + void taxProposalsApplyOnlyTheirIntendedRateAndRenderCurrentAndProposedValues(TaxTarget target) { + Faction faction = host(); + String id = + switch (target) { + case GUILD_ID, VASSAL_ID, TARIFF_ID -> "missing_target"; + default -> null; + }; + TaxLawChange tax = new TaxLawChange(target, id, 17); + Proposal proposal = new Proposal("Bob", faction.getGovernment()); + proposal.setTaxProposal(tax); + assertSame(tax, proposal.getTaxChange()); + assertTrue(proposal.affectsEconomy()); + assertFalse(proposal.needsTarget()); + assertTrue(proposal.checkTarget()); + assertEquals(Action.TAX_CHANGE, proposal.getPoliticalAction().getAction()); + String book = pages(proposal.getAsBook()); + assertTrue(book.contains("17.0%")); + assertTrue(book.contains("Economic preview unavailable")); + if (id != null) { + assertTrue(book.contains(id)); + assertTrue(book.contains("Base Rate")); + } + proposal.apply(null); + assertEquals(17, faction.getTaxRate(target, id, false)); + } + + @ParameterizedTest + @EnumSource(FeeKind.class) + void vehicleFeeProposalsApplyTheScopedRateAndDescribeGeneralAndSpecificTargets(FeeKind kind) { + Faction faction = host(); + Proposal general = new Proposal("Bob", faction.getGovernment()); + general.setFeeProposal(new FeeChange(kind, null, 1.5)); + assertTrue(general.isFeeProposal()); + assertEquals(Action.TAX_CHANGE, general.getPoliticalAction().getAction()); + general.apply(null); + assertEquals(1.5, faction.getVehicleFeeHandler().getRate(kind)); + assertTrue(pages(general.getAsBook()).contains("All vehicles")); + Proposal specific = new Proposal("Bob", faction.getGovernment()); + FeeChange fee = new FeeChange(kind, "tf:cart", 2.5); + specific.setFeeProposal(fee); + assertSame(fee, specific.getFeeChange()); + specific.apply(null); + assertEquals(2.5, faction.getVehicleFeeHandler().getRate(kind, "tf:cart")); + assertEquals(1.5, faction.getVehicleFeeHandler().getRate(kind)); + assertTrue(pages(specific.getAsBook()).contains("General Rate")); + assertTrue(pages(specific.getAsBook()).contains("tf:cart")); + } + + @ParameterizedTest + @EnumSource(Action.class) + void politicalBooksDescribeEachActionAndItsTargetRequirement(Action action) { + Faction faction = host(); + Proposal proposal = political(faction, action); + assertFalse(proposal.affectsEconomy()); + String book = pages(proposal.getAsBook()); + assertTrue( + book.contains(org.bukkit.ChatColor.stripColor(action.getDisplay()).replace("#c5e0e3", ""))); + boolean needs = + action == Action.CHANGE_LEADER + || action == Action.WHITE_PEACE + || action == Action.SURRENDER; + assertEquals(needs, proposal.needsTarget()); + assertEquals(!needs, proposal.checkTarget()); + if (needs) assertTrue(book.contains("No target")); + } + + @Test + void leaderTargetsAreRevalidatedBeforeDisplayAndPoliticalApplication() { + Faction faction = host(); + Proposal proposal = political(faction, Action.CHANGE_LEADER); + proposal.setTarget("Cara"); + proposal.tick(); + assertTrue(proposal.hasTarget()); + assertTrue(pages(proposal.getAsBook()).contains("Cara")); + faction.forceRemoveMember("Cara"); + proposal.tick(); + assertFalse(proposal.hasTarget()); + proposal.setTarget("Bob"); + assertTrue(proposal.checkTarget()); + proposal.apply(null); + assertEquals("Bob", faction.getLeader()); + } + + @Test + void warTargetsShowLiveWarsAndDiscardInvalidOrFinishedWarIds() { + Faction faction = host(); + Faction enemy = fixture.saved("enemy", "Drew"); + var war = new net.tfminecraft.simplefactions.war.core.War(19, faction, enemy); + WarManager.get().add(war); + Proposal proposal = political(faction, Action.WHITE_PEACE); + proposal.setTarget("19"); + assertTrue(proposal.checkTarget()); + assertTrue( + pages(proposal.getAsBook()).contains(org.bukkit.ChatColor.stripColor(war.getName()))); + proposal.setTarget("not_a_war"); + assertTrue(pages(proposal.getAsBook()).contains("Invalid war")); + proposal.tick(); + assertFalse(proposal.hasTarget()); + proposal.setTarget("19"); + WarManager.get().clear(); + proposal.tick(); + assertFalse(proposal.hasTarget()); + } + + @Test + void booksForUnavailableGovernmentsAndEmptyProposalsRemainReadable() { + Proposal empty = new Proposal(null, null); + assertFalse(empty.affectsEconomy()); + assertNull(empty.getPoliticalAction()); + BookMeta meta = (BookMeta) empty.getAsBook().getItemMeta(); + assertEquals("Unknown", meta.getAuthor()); + assertEquals(1, meta.getPageCount()); + Proposal tax = new Proposal("Bob", null); + tax.setTaxProposal(new TaxLawChange(TaxTarget.CITIZENS, null, 12)); + assertTrue(pages(tax.getAsBook()).contains("12.0%")); + Proposal fee = new Proposal("Bob", null); + fee.setFeeProposal(new FeeChange(FeeKind.TRANSFER_FEE, null, 3)); + assertTrue(pages(fee.getAsBook()).contains("3.0x upkeep")); + Law detached = fixture.law("removed_group", "law", Map.of("name", "Detached law")); + Proposal law = new Proposal("Bob", null); + law.setLawProposal(detached); + assertTrue(pages(law.getAsBook()).contains("removed_group")); + assertTrue(pages(law.getAsBook()).contains("Detached law")); + } + + @ParameterizedTest + @ValueSource(strings = {"citizens", "guilds", "factions", "vassals"}) + void configuredActionsRetainNamesDescriptionsIconsAndAllowedPools(String pool) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("action.name", "Public reform"); + yaml.set("action.icon", "v.book"); + yaml.set("action.description", List.of("First line", "Second line")); + yaml.set("action.pools", List.of(pool)); + PoliticalAction action = + new PoliticalAction("change_leader", yaml.getConfigurationSection("action")); + assertEquals(Action.CHANGE_LEADER, action.getAction()); + assertEquals("Public reform", action.getName()); + assertEquals(List.of("First line", "Second line"), action.getDescription()); + assertEquals(List.of(pool), action.getPools()); + assertEquals(pool.equals("citizens"), action.allowCitizens()); + assertEquals(pool.equals("guilds"), action.allowGuilds()); + assertEquals(pool.equals("factions") || pool.equals("vassals"), action.allowFactions()); + assertEquals("v.book", action.getIconString()); + ItemAPI api = mock(ItemAPI.class); + ItemCreator creator = mock(ItemCreator.class); + when(api.getCreator()).thenReturn(creator); + ItemStack book = new ItemStack(Material.BOOK); + when(creator.getItemFromPath("v.book")).thenReturn(book); + try (MockedStatic<TLibs> tlibs = mockStatic(TLibs.class)) { + tlibs.when(TLibs::getItemAPI).thenReturn(api); + assertSame(book, action.getIcon()); + } + assertThrows( + IllegalArgumentException.class, + () -> new PoliticalAction("removed_action", yaml.getConfigurationSection("action"))); + } + + @Test + void actionKeysRemainStableUnderATurkishServerLocale() { + java.util.Locale previous = java.util.Locale.getDefault(); + try { + java.util.Locale.setDefault(java.util.Locale.forLanguageTag("tr-TR")); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("action.pools", List.of("citizens")); + PoliticalAction action = + assertDoesNotThrow( + () -> new PoliticalAction("independence", yaml.getConfigurationSection("action"))); + assertEquals(Action.INDEPENDENCE, action.getAction()); + } finally { + java.util.Locale.setDefault(previous); + } + } + + @Test + void onlineEconomicBooksPreviewLawTaxAndTariffsWithoutApplyingTheirChanges() { + fixture.lawGroup("government", Map.of()); + Faction faction = host(); + Guild viewer = faction.getOrCreateMainGuild(); + var player = fixture.player("Bob"); + Law next = fixture.law("government", "reform", Map.of("name", "Reformed government")); + LawGroup group = faction.getLawHandler().getGroup("government"); + Law original = group.getCurrent(); + when(fixture.provinces.previewLawIncomeExact(faction, group, next)) + .thenReturn(Map.of(viewer, 4.5)); + Proposal law = new Proposal("Bob", faction.getGovernment()); + law.setLawProposal(next); + assertTrue(law.affectsEconomy()); + String lawBook = pages(law.getAsBook(player)); + assertTrue(lawBook.contains("Reformed government")); + assertTrue(lawBook.contains("+4.50d")); + assertSame(original, group.getCurrent()); + assertTrue(pages(law.getAsBook()).contains("Economic preview unavailable")); + Proposal tax = new Proposal("Bob", faction.getGovernment()); + tax.setTaxProposal(new TaxLawChange(TaxTarget.CITIZENS, null, 17)); + assertTrue(pages(tax.getAsBook(player)).contains("Impacts:")); + assertEquals(5, faction.getTaxRate()); + when(fixture.provinces.previewTariffRateChange(faction, null, 18)) + .thenReturn(Map.of(viewer, -2.5)); + Proposal tariffs = new Proposal("Bob", faction.getGovernment()); + tariffs.setTaxProposal(new TaxLawChange(TaxTarget.TARIFFS, null, 18)); + assertTrue(pages(tariffs.getAsBook(player)).contains("-2.50d")); + assertEquals(30, faction.getTaxRate(TaxTarget.TARIFFS, null, false)); + } + + @Test + void duplicateGeneralTaxProposalIsRejectedWithoutDereferencingItsAbsentSpecificId() { + Faction faction = host(); + ProposalHandler handler = new ProposalHandler(faction.getGovernment()); + Proposal original = new Proposal("Bob", faction.getGovernment()); + original.setTaxProposal(new TaxLawChange(TaxTarget.CITIZENS, null, 12)); + Proposal replacement = new Proposal("Cara", faction.getGovernment()); + replacement.setTaxProposal(new TaxLawChange(TaxTarget.CITIZENS, null, 18)); + handler.propose(original); + assertFalse(assertDoesNotThrow(() -> handler.canBeProposed(replacement))); + assertEquals(List.of(original), handler.getProposals()); + } + + @Test + void pendingPoliticalActionsAndGeneralTaxIdentitySurviveProposalSerialization() { + Faction faction = host(); + ProposalHandler handler = new ProposalHandler(faction.getGovernment()); + Proposal leader = political(faction, Action.CHANGE_LEADER); + leader.setTarget("Cara"); + handler.propose(leader); + Proposal peace = political(faction, Action.WHITE_PEACE); + peace.setTarget("19"); + handler.propose(peace); + Proposal elections = political(faction, Action.SNAP_ELECTIONS); + handler.propose(elections); + Proposal tax = new Proposal("Bob", faction.getGovernment()); + tax.setTaxProposal(new TaxLawChange(TaxTarget.CITIZENS, null, 17)); + handler.propose(tax); + ProposalHandler restored = new ProposalHandler(faction.getGovernment()); + restored.restoreProposals(faction, handler.serializeProposals()); + assertEquals(4, restored.getProposals().size()); + assertEquals( + Action.CHANGE_LEADER, restored.getProposals().get(0).getPoliticalAction().getAction()); + assertEquals("Cara", restored.getProposals().get(0).getTarget()); + assertEquals( + Action.WHITE_PEACE, restored.getProposals().get(1).getPoliticalAction().getAction()); + assertEquals("19", restored.getProposals().get(1).getTarget()); + assertEquals( + Action.SNAP_ELECTIONS, restored.getProposals().get(2).getPoliticalAction().getAction()); + assertNull(restored.getProposals().get(2).getTarget()); + assertNull(restored.getProposals().get(3).getTaxChange().getId()); + assertEquals(17, restored.getProposals().get(3).getTaxChange().getNewTax()); + } + + @Test + void malformedSavedProposalEntriesDoNotPreventValidSiblingsFromRestoring() { + Faction faction = host(); + ProposalHandler handler = new ProposalHandler(faction.getGovernment()); + List<String> saved = + java.util.Arrays.asList( + null, + "missing-separator", + "Bob:fee:bad", + "Bob:tax:REMOVED:null:3", + "Bob:law:missing:removed", + "Bob:action:REMOVED:Target", + "Bob:tax:CITIZENS:null:17"); + assertDoesNotThrow(() -> handler.restoreProposals(faction, saved)); + assertEquals(1, handler.getProposals().size()); + assertEquals(TaxTarget.CITIZENS, handler.getProposals().getFirst().getTaxChange().getTarget()); + assertNull(handler.getProposals().getFirst().getTaxChange().getId()); + assertEquals(17, handler.getProposals().getFirst().getTaxChange().getNewTax()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/government/movement/MovementCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/government/movement/MovementCoverageTest.java new file mode 100644 index 00000000..7fac3218 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/government/movement/MovementCoverageTest.java @@ -0,0 +1,502 @@ +package net.tfminecraft.simplefactions.government.movement; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.tfminecraft.simplefactions.database.CauseData; +import net.tfminecraft.simplefactions.database.MovementData; +import net.tfminecraft.simplefactions.database.PoolData; +import net.tfminecraft.simplefactions.database.ProposalData; +import net.tfminecraft.simplefactions.enums.Stance; +import net.tfminecraft.simplefactions.government.movement.cause.Cause; +import net.tfminecraft.simplefactions.government.proposal.Proposal; +import net.tfminecraft.simplefactions.government.proposal.TaxLawChange; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class MovementCoverageTest { + private FactionDomainFixture fixture; + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + } + + @AfterEach + void cleanup() { + if (fixture != null) fixture.close(); + } + + private Faction host() { + Faction faction = fixture.saved("home", "Alice"); + for (String member : List.of("Bob", "Cara", "Drew")) faction.addMember(member); + return faction; + } + + private Proposal tax(Faction faction, String proposer) { + Proposal proposal = new Proposal(proposer, faction.getGovernment()); + proposal.setTaxProposal(new TaxLawChange(TaxTarget.CITIZENS, null, 12)); + return proposal; + } + + private Movement movement(Faction faction, String founder) { + faction.getGovernment().startMovement(founder, tax(faction, founder)); + return faction.getGovernment().getMovementByLeader(founder); + } + + @ParameterizedTest + @ValueSource(strings = {"citizen", "guild", "vassal", "founder"}) + void repeatedSupportDoesNotDuplicateMembersOrArtificiallyIncreasePower(String kind) { + Faction faction = host(); + Movement movement = movement(faction, "Bob"); + Object supporter; + if (kind.equals("guild")) { + Guild guild = fixture.guild(faction, "merchants", "Eve"); + guild.setStance(Stance.OPPOSE); + supporter = guild; + } else if (kind.equals("vassal")) { + Faction vassal = fixture.saved("vassal", "Eve"); + fixture.subject(faction, vassal); + vassal.getOrCreateMainGuild().setStance(Stance.OPPOSE); + supporter = vassal; + } else { + supporter = kind.equals("founder") ? "Bob" : "Cara"; + } + int before = movement.getAllMembers().size(); + movement.join(supporter, null); + int expected = before + (kind.equals("founder") ? 0 : 1); + assertEquals(expected, movement.getAllMembers().size()); + double power = movement.getPower(); + movement.changeOrganization(20); + double organization = movement.getOrganization(); + movement.join(supporter, null); + assertEquals(expected, movement.getAllMembers().size()); + assertEquals(power, movement.getPower()); + assertEquals(organization, movement.getOrganization()); + } + + @Test + void repeatedForeignBackingCountsTheSameRealmOnlyOnce() { + Faction faction = host(); + Movement movement = movement(faction, "Bob"); + Faction backer = fixture.saved("foreign", "Eve"); + movement.joinAsForeignBacker(backer); + double power = movement.getPower(); + movement.joinAsForeignBacker(backer); + assertEquals(List.of(backer), movement.getForeignBackers()); + assertEquals(power, movement.getPower()); + } + + @Test + void leaderlessCauseDoesNotBreakLookupOfAnotherCauseLeader() { + Faction faction = host(); + Movement movement = movement(faction, "Bob"); + movement.getCauses().getFirst().setLeader(null); + movement.createCause("Cara", tax(faction, "Cara")); + Cause found = assertDoesNotThrow(() -> movement.getCauseByLeader("Cara")); + assertNotNull(found); + assertEquals("Cara", found.getLeader()); + assertNull(movement.getCauseByLeader("missing")); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void tickRemovesCauseSupportThatNowSupportsTheGovernment(boolean subject) { + Faction faction = host(); + Movement movement = movement(faction, "Bob"); + Cause cause = movement.getCauses().getFirst(); + if (subject) { + Faction vassal = fixture.saved("vassal", "Eve"); + fixture.subject(faction, vassal); + vassal.getOrCreateMainGuild().setStance(Stance.OPPOSE); + movement.join(vassal, cause); + assertEquals(List.of(vassal), cause.getPool().getFactions()); + vassal.getOrCreateMainGuild().setStance(Stance.SUPPORT); + movement.tick(); + assertTrue(cause.getPool().getFactions().isEmpty()); + } else { + Guild guild = fixture.guild(faction, "merchants", "Eve"); + guild.setName("Renamed merchants"); + guild.setStance(Stance.OPPOSE); + movement.join(guild, cause); + assertEquals(List.of(guild), cause.getPool().getGuilds()); + guild.setStance(Stance.SUPPORT); + movement.tick(); + assertTrue(cause.getPool().getGuilds().isEmpty()); + } + assertEquals(List.of("Bob"), movement.getAllMembers()); + assertTrue(faction.getGovernment().getMovements().contains(movement)); + } + + @Test + void organizationRespectsPhaseBoundsAndOnlyAdjacentPhasesCanBeSelected() { + Faction faction = host(); + Movement movement = movement(faction, "Bob"); + assertSame(faction, movement.getFaction()); + assertTrue(movement.hasLeader()); + assertTrue(movement.isLeader("bOB")); + assertFalse(movement.isLeader("Cara")); + assertEquals(Phase.GATHERING, movement.getPhase()); + assertEquals(20, movement.getMaxOrganization()); + assertFalse(movement.canChangeToPhase(Phase.GATHERING)); + assertFalse(movement.canChangeToPhase(Phase.PRESSURING)); + movement.changeOrganization(100); + assertEquals(20, movement.getOrganization()); + assertTrue(movement.canChangeToPhase(Phase.PRESSURING)); + assertFalse(movement.canChangeToPhase(Phase.REBELLIOUS)); + movement.setPhase(Phase.PRESSURING); + assertTrue(movement.canChangeToPhase(Phase.GATHERING)); + movement.changeOrganization(40); + assertEquals(40, movement.getOrganization()); + movement.setPhase(Phase.GATHERING); + assertEquals(20, movement.getOrganization()); + movement.changeOrganization(-100); + assertEquals(0, movement.getOrganization()); + movement.setLeader(null); + assertFalse(movement.hasLeader()); + assertFalse(movement.isLeader("Bob")); + assertEquals(-10, movement.getOrganizationGain()); + movement.setLeader("Bob"); + assertTrue(movement.canBeLeader("Bob")); + assertFalse(movement.canBeLeader("Cara")); + assertTrue(movement.isMember("Bob")); + assertFalse(movement.isMember("Alice")); + assertEquals(25, movement.getPower()); + assertEquals(75, movement.getStabilityEffect()); + } + + @Test + void citizenSupportMembershipChangesOrganizationAndFreezingBlocksNewMembersAndCauses() { + Faction faction = host(); + Movement movement = movement(faction, "Bob"); + movement.changeOrganization(20); + assertTrue(movement.canJoin("Cara", null)); + movement.join("Cara", null); + assertEquals(List.of("Cara"), movement.getSupporters().getCitizens()); + assertEquals(0, movement.getOrganization()); + movement.leave("Cara", null); + assertTrue(movement.getSupporters().getCitizens().isEmpty()); + assertEquals(20, movement.getOrganization()); + assertNotNull(movement.joinBlockReason("Stranger", null, false)); + movement.join("Stranger", null); + assertFalse(movement.isMember("Stranger")); + assertNotNull(movement.joinBlockReason(42, null, true)); + movement.join(42, null); + assertEquals(List.of("Bob"), movement.getAllMembers()); + Cause cause = movement.getCauses().getFirst(); + assertTrue(movement.canJoin("Cara", cause, false)); + movement.join("Cara", cause); + assertEquals(List.of("Bob", "Cara"), cause.getPool().getCitizens()); + assertFalse(movement.canJoin("Cara", cause)); + movement.join("Cara", cause); + assertEquals(2, cause.getPool().getCitizens().size()); + movement.leave("Cara", cause); + assertEquals(List.of("Bob"), cause.getPool().getCitizens()); + movement.setFrozen(true); + movement.join("Cara", null); + movement.createCause("Cara", tax(faction, "Cara")); + movement.tick(); + assertTrue(movement.isFrozen()); + assertEquals(1, movement.getCauses().size()); + assertEquals(List.of("Bob"), movement.getAllMembers()); + movement.setFrozen(false); + movement.removeCause(cause); + movement.tick(); + assertFalse(faction.getGovernment().getMovements().contains(movement)); + } + + @Test + void newCausesMoveTheirFounderOutOfGeneralSupportAndRespectTheThreeCauseLimit() { + Faction faction = host(); + Movement movement = movement(faction, "Bob"); + movement.join("Cara", null); + movement.createCause("Cara", tax(faction, "Cara")); + assertTrue(movement.getSupporters().getCitizens().isEmpty()); + assertEquals("Cara", movement.getCauses().get(1).getLeader()); + Guild guild = fixture.guild(faction, "merchants", "Eve"); + guild.setStance(Stance.OPPOSE); + movement.join(guild, null); + movement.createCause("Eve", tax(faction, "Eve")); + assertTrue(movement.getSupporters().getGuilds().isEmpty()); + assertEquals(List.of(guild), movement.getCauses().get(2).getPool().getGuilds()); + movement.createCause("Drew", tax(faction, "Drew")); + assertEquals(3, movement.getCauses().size()); + assertNull(movement.getCauseByLeader("Drew")); + movement.removeCause(movement.getCauses().get(2)); + Faction subject = fixture.saved("vassal", "Finn"); + fixture.subject(faction, subject); + subject.getOrCreateMainGuild().setStance(Stance.OPPOSE); + movement.join(subject, null); + movement.createCause("Finn", tax(faction, "Finn")); + assertTrue(movement.getSupporters().getFactions().isEmpty()); + assertEquals(List.of(subject), movement.getCauses().get(2).getPool().getFactions()); + } + + @Test + void supporterPermissionsDistinguishFactionRolesForeignRealmsAndExistingOtherMovements() { + Faction faction = host(); + Movement movement = movement(faction, "Bob"); + Movement other = movement(faction, "Cara"); + assertTrue(movement.citizenSupporterBlockReason("Alice", true).contains("not a citizen")); + assertTrue(movement.citizenSupporterBlockReason("Cara", false).contains("another movement")); + assertNull(movement.citizenSupporterBlockReason("Drew", true)); + Guild guild = fixture.guild(faction, "merchants", "Eve"); + guild.addMember("Finn"); + guild.setStance(Stance.OPPOSE); + Faction foreign = fixture.saved("foreign", "Grace"); + Guild foreignGuild = fixture.guild(foreign, "foreign_guild", "Hank"); + assertNotNull(movement.guildSupporterBlockReason(foreignGuild, true)); + assertTrue( + movement + .guildSupporterBlockReason(faction.getOrCreateMainGuild(), false) + .contains("base guild")); + guild.setStance(Stance.SUPPORT); + assertTrue(movement.guildSupporterBlockReason(guild, true).contains("SUPPORT")); + movement.join(guild, null); + assertFalse(movement.isMember("Eve")); + guild.setStance(Stance.OPPOSE); + assertNull(movement.joinBlockReason(guild, null, false)); + other.join(guild, null); + assertTrue(movement.guildSupporterBlockReason(guild, true).contains("another movement")); + other.leave(guild, null); + assertNull(movement.guildSupporterBlockReason(guild, false)); + Faction vassal = fixture.saved("vassal", "Iris"); + vassal.addMember("Jo"); + assertTrue(movement.factionSupporterBlockReason(vassal, false).contains("must be a vassal")); + fixture.subject(faction, vassal); + vassal.getOrCreateMainGuild().setStance(Stance.SUPPORT); + assertTrue(movement.factionSupporterBlockReason(vassal, true).contains("SUPPORT")); + movement.join(vassal, null); + assertFalse(movement.isMember("Iris")); + vassal.getOrCreateMainGuild().setStance(Stance.OPPOSE); + assertNull(movement.joinBlockReason(vassal, null, false)); + other.join(vassal, null); + assertTrue(movement.factionSupporterBlockReason(vassal, true).contains("another movement")); + other.leave(vassal, null); + assertNull(movement.factionSupporterBlockReason(vassal, false)); + assertTrue(movement.foreignBackerBlockReason(faction, false).contains("same realm")); + assertTrue(movement.foreignBackerBlockReason(vassal, true).contains("same realm")); + assertNull(movement.foreignBackerBlockReason(foreign, false)); + other.joinAsForeignBacker(foreign); + assertTrue(movement.foreignBackerBlockReason(foreign, true).contains("already backing")); + movement.joinAsForeignBacker(foreign); + assertTrue(movement.getForeignBackers().isEmpty()); + other.leaveAsForeignBacker(foreign); + movement.joinAsForeignBacker(foreign); + assertEquals(List.of(foreign), movement.getForeignBackers()); + fixture.subject(faction, foreign); + movement.checkForeignBackers(); + assertTrue(movement.getForeignBackers().isEmpty()); + fixture.provinceData.put(7, new Province(7, "PLAINS", 1)); + faction.addProvince(7); + assertTrue(movement.guildSupporterBlockReason(guild, false).contains("one-province")); + } + + @Test + void cleanupPrunesGeneralSupportersAfterCitizensLeaveAndGuildsOrSubjectsChangeStance() { + Faction faction = host(); + Movement movement = movement(faction, "Bob"); + Guild guild = fixture.guild(faction, "merchants", "Eve"); + guild.setStance(Stance.OPPOSE); + Faction vassal = fixture.saved("vassal", "Finn"); + fixture.subject(faction, vassal); + vassal.getOrCreateMainGuild().setStance(Stance.OPPOSE); + movement.join("Cara", null); + movement.join(guild, null); + movement.join(vassal, null); + assertEquals(List.of("Bob", "Cara", "Eve", "Finn"), movement.getAllMembers()); + faction.forceRemoveMember("Cara"); + guild.setStance(Stance.SUPPORT); + vassal.getOrCreateMainGuild().setStance(Stance.SUPPORT); + movement.checkSupporters(); + assertTrue(movement.getSupporters().getAllMembers().isEmpty()); + assertEquals(List.of("Bob"), movement.getAllMembers()); + } + + @Test + void joiningRoleAndQuickJoinChecksUseThePlayersActualCitizenshipAndLeadership() { + Faction faction = host(); + Movement movement = movement(faction, "Bob"); + Guild guild = fixture.guild(faction, "merchants", "Eve"); + guild.addMember("Finn"); + Faction vassal = fixture.saved("vassal", "Grace"); + vassal.addMember("Hank"); + fixture.subject(faction, vassal); + assertEquals("Cara", movement.getJoiningAs(fixture.player("Cara"))); + assertSame(guild, movement.getJoiningAs(fixture.player("Eve"))); + assertSame(guild, movement.getJoiningAs(fixture.player("Finn"))); + assertSame(vassal, movement.getJoiningAs(fixture.player("Grace"))); + for (String name : List.of("Alice", "Hank", "Stranger")) + assertNull(movement.getJoiningAs(fixture.player(name)), name); + for (String name : List.of("Cara", "Eve", "Grace")) + assertTrue(movement.quickJoinCheck(fixture.player(name)), name); + for (String name : List.of("Bob", "Alice", "Finn", "Hank", "Stranger")) + assertFalse(movement.quickJoinCheck(fixture.player(name)), name); + } + + @Test + void tradeAndForeignBackingContributeToPowerWhileTheOverallShareIsCapped() { + Faction faction = host(); + Movement movement = movement(faction, "Bob"); + Guild guild = fixture.guild(faction, "merchants", "Eve"); + guild.setStance(Stance.OPPOSE); + Faction vassal = fixture.saved("vassal", "Finn"); + fixture.subject(faction, vassal); + vassal.getOrCreateMainGuild().setStance(Stance.OPPOSE); + Faction backer = fixture.saved("foreign", "Grace"); + faction.getOrCreateMainGuild().getTradeBreakdown().setTradePower(100); + guild.getTradeBreakdown().setTradePower(40); + vassal.getOrCreateMainGuild().getTradeBreakdown().setTradePower(50); + backer.getOrCreateMainGuild().getTradeBreakdown().setTradePower(80); + assertEquals(190, faction.getTotalTradePower()); + assertEquals(16.67, movement.getPower()); + movement.join(guild, null); + movement.join(vassal, movement.getCauses().getFirst()); + movement.joinAsForeignBacker(backer); + assertEquals(List.of(guild), movement.getAllSupportingGuilds()); + assertEquals(List.of(vassal), movement.getAllSupportingFactions()); + assertEquals(75.70, movement.getPower(), .001); + movement.join("Cara", null); + movement.join("Drew", null); + assertEquals(100, movement.getPower()); + assertEquals(50, movement.getStabilityEffect()); + movement.leave(guild, null); + movement.leave(vassal, movement.getCauses().getFirst()); + assertTrue(movement.getAllSupportingGuilds().isEmpty()); + assertTrue(movement.getAllSupportingFactions().isEmpty()); + } + + @Test + void organizationGainRespondsToMissingTargetsLeadersAndGovernmentLegitimacy() { + Faction faction = host(); + Movement movement = movement(faction, "Bob"); + assertEquals(7.5, movement.getOrganizationGain()); + Guild opposition = fixture.guild(faction, "merchants", "Eve"); + opposition.setStance(Stance.OPPOSE); + assertTrue(faction.getGovernment().isIllegitimate()); + assertEquals(12, movement.getOrganizationGain()); + Proposal political = new Proposal("Cara", faction.getGovernment()); + political.setPoliticalActionProposal(new PoliticalAction(Action.CHANGE_LEADER)); + movement.createCause("Cara", political); + assertEquals(2, movement.getOrganizationGain()); + movement.getCauses().get(1).setLeader(null); + assertEquals(-1.4, movement.getOrganizationGain()); + political.setTarget("Drew"); + assertEquals(7.2, movement.getOrganizationGain()); + movement.setLeader(null); + assertEquals(-10, movement.getOrganizationGain()); + } + + private CauseData savedCause(String type) { + CauseData cause = new CauseData(); + cause.leader = "Bob"; + cause.proposal = new ProposalData(); + cause.proposal.proposer = "Bob"; + cause.proposal.type = type; + cause.members.citizens.add("Bob"); + return cause; + } + + @Test + void persistedMovementRestoresEveryProposalAndPoolWithoutKeepingMissingEntities() { + fixture.lawGroup("finance", Map.of("name", "Finance")); + Faction faction = host(); + fixture.law("finance", "new", Map.of("name", "New law")); + Guild guild = fixture.guild(faction, "merchants", "Eve"); + Faction vassal = fixture.saved("vassal", "Finn"); + fixture.subject(faction, vassal); + Faction foreign = fixture.saved("foreign", "Grace"); + MovementData data = new MovementData(); + data.id = "saved_movement"; + data.leader = "Bob"; + data.organization = 32.5; + data.phase = "PRESSURING"; + data.frozen = true; + CauseData law = savedCause("law"); + law.proposal.groupId = "finance"; + law.proposal.lawId = "new"; + law.members.guilds.addAll(List.of("merchants", "missing")); + law.members.factions.addAll(List.of("vassal", "missing")); + CauseData tax = savedCause("tax"); + tax.proposal.taxTarget = "CITIZENS"; + tax.proposal.newTax = 12.0; + CauseData fee = savedCause("fee"); + fee.proposal.feeKind = "REGISTRATION_FEE"; + fee.proposal.feeVehicle = "tf:cart"; + fee.proposal.newFee = 1.5; + CauseData political = savedCause("political"); + political.proposal.actionKey = "CHANGE_LEADER"; + political.proposal.target = "Cara"; + data.causes.addAll(List.of(law, tax, fee, political)); + data.supporters.citizens.add("Drew"); + data.supporters.guilds.add("merchants"); + data.supporters.factions.add("vassal"); + data.foreignBackers.addAll(List.of("foreign", "missing")); + Movement restored = new Movement(faction, data); + assertEquals("saved_movement", restored.getId()); + assertEquals("Bob", restored.getLeader()); + assertEquals(32.5, restored.getOrganization()); + assertEquals(Phase.PRESSURING, restored.getPhase()); + assertTrue(restored.isFrozen()); + assertEquals(4, restored.getCauses().size()); + assertEquals("new", restored.getCauses().getFirst().getProposal().getLaw().getId()); + assertEquals(12, restored.getCauses().get(1).getProposal().getTaxChange().getNewTax()); + assertEquals( + "tf:cart", restored.getCauses().get(2).getProposal().getFeeChange().getVehicleTypeId()); + assertEquals(1.5, restored.getCauses().get(2).getProposal().getFeeChange().getNewRate()); + assertEquals("Cara", restored.getCauses().get(3).getProposal().getTarget()); + assertEquals(List.of(guild), restored.getCauses().getFirst().getPool().getGuilds()); + assertEquals(List.of(vassal), restored.getCauses().getFirst().getPool().getFactions()); + assertEquals(List.of(guild), restored.getSupporters().getGuilds()); + assertEquals(List.of(vassal), restored.getSupporters().getFactions()); + assertEquals(List.of(foreign), restored.getForeignBackers()); + } + + @Test + void malformedSavedCausesDoNotDiscardValidSiblingsAndOptionalFieldsUseSafeDefaults() { + Faction faction = host(); + MovementData data = new MovementData(); + data.phase = "old_removed_phase"; + CauseData missingGroup = savedCause("law"); + missingGroup.proposal.groupId = "missing"; + CauseData badTax = savedCause("tax"); + badTax.proposal.taxTarget = "REMOVED_TARGET"; + CauseData unknown = savedCause("unknown"); + CauseData valid = savedCause("fee"); + valid.proposal.feeKind = "TRANSFER_FEE"; + valid.members = null; + data.causes = new ArrayList<>(); + data.causes.add(null); + data.causes.add(new CauseData()); + data.causes.addAll(List.of(missingGroup, badTax, unknown, valid)); + data.supporters = new PoolData(); + data.supporters.citizens = null; + data.supporters.guilds = null; + data.supporters.factions = null; + data.foreignBackers = null; + Movement restored = new Movement(faction, data); + assertNotNull(restored.getId()); + assertEquals(Phase.GATHERING, restored.getPhase()); + assertEquals(0, restored.getOrganization()); + assertFalse(restored.hasLeader()); + assertEquals(1, restored.getCauses().size()); + assertEquals(0, restored.getCauses().getFirst().getProposal().getFeeChange().getNewRate()); + assertTrue(restored.getSupporters().getAllMembers().isEmpty()); + MovementData empty = new MovementData(); + empty.causes = null; + empty.supporters = null; + empty.foreignBackers = null; + assertTrue(new Movement(faction, empty).getCauses().isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/government/movement/MovementOutcomeServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/government/movement/MovementOutcomeServiceTest.java index 33d2aafb..c686ec43 100644 --- a/src/test/java/net/tfminecraft/simplefactions/government/movement/MovementOutcomeServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/government/movement/MovementOutcomeServiceTest.java @@ -24,6 +24,16 @@ class MovementOutcomeServiceTest { + @Test + void incompleteImportedMovementQueriesStayEmpty() { + assertEquals(List.of(), MovementOutcomeService.orderedCauses(null)); + assertNull(MovementOutcomeService.stabilityFor(null, MovementOutcomeSource.WAR)); + Movement movement = new Movement(null, new net.tfminecraft.simplefactions.database.MovementData()); + movement.getCauses().add(null); + assertEquals(List.of(), MovementOutcomeService.orderedCauses(movement)); + assertNull(MovementOutcomeService.stabilityFor(movement, MovementOutcomeSource.WAR)); + } + @Test void appliesLeaderChangeBeforeLaw() { Proposal lawProposal = mock(Proposal.class); diff --git a/src/test/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminCoverageTest.java new file mode 100644 index 00000000..919c86b7 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminCoverageTest.java @@ -0,0 +1,551 @@ +package net.tfminecraft.simplefactions.government.movement.admin; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.google.gson.Gson; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import net.tfminecraft.simplefactions.database.FactionData; +import net.tfminecraft.simplefactions.database.MovementData; +import net.tfminecraft.simplefactions.database.PoolData; +import net.tfminecraft.simplefactions.enums.Stance; +import net.tfminecraft.simplefactions.government.movement.Action; +import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.government.movement.PoliticalAction; +import net.tfminecraft.simplefactions.government.proposal.Proposal; +import net.tfminecraft.simplefactions.government.proposal.TaxLawChange; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.PersistenceFilesFixture; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class MovementAdminCoverageTest { + private FactionDomainFixture fixture; + private PersistenceFilesFixture disk; + private Command command; + + @BeforeEach + void setup() throws Exception { + disk = new PersistenceFilesFixture(); + Files.createDirectories(disk.root.resolve("Data")); + fixture = new FactionDomainFixture(); + command = mock(Command.class); + when(command.getName()).thenReturn("movement"); + } + + @AfterEach + void close() throws Exception { + try { + if (fixture != null) fixture.close(); + } finally { + if (disk != null) disk.close(); + } + } + + private Faction host() { + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + faction.addMember("Cara"); + return faction; + } + + private Movement political(Faction faction, Action action) { + Proposal proposal = new Proposal("Bob", faction.getGovernment()); + proposal.setPoliticalActionProposal(new PoliticalAction(action)); + faction.getGovernment().startMovement("Bob", proposal); + return faction.getGovernment().getMovementByLeader("Bob"); + } + + @Test + void wantedLeaderCommandCannotReplaceAWarTargetWithAPlayerName() { + Faction faction = host(); + Movement movement = political(faction, Action.WHITE_PEACE); + Proposal proposal = movement.getCauses().getFirst().getProposal(); + proposal.setTarget("19"); + MovementAdminService.Result result = MovementAdminService.target(movement.getId(), "0", "Cara"); + assertFalse(result.ok()); + assertEquals("19", proposal.getTarget()); + } + + @Test + void acceptingDemandsRevalidatesTargetsBeforeApplyingAnyCauseOrEndingTheMovement() { + Faction faction = host(); + Movement movement = political(faction, Action.CHANGE_LEADER); + movement.getCauses().getFirst().getProposal().setTarget("Cara"); + Proposal taxes = new Proposal("Cara", faction.getGovernment()); + taxes.setTaxProposal(new TaxLawChange(TaxTarget.CITIZENS, null, 17)); + movement.createCause("Cara", taxes); + faction.forceRemoveMember("Cara"); + MovementAdminService.Result result = MovementAdminService.demands(movement.getId(), "accept"); + assertFalse(result.ok()); + assertTrue(faction.getGovernment().getMovements().contains(movement)); + assertEquals("Alice", faction.getLeader()); + assertEquals(5, faction.getTaxRate()); + } + + @Test + void uppercaseAdminCommandsRemainCaseInsensitiveUnderTurkishLocale() { + Faction faction = host(); + Movement movement = political(faction, Action.NONE); + Player staff = fixture.player("Staff"); + when(staff.hasPermission("simplefactions.admin")).thenReturn(true); + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertTrue( + new MovementCommandManager() + .onCommand( + staff, + command, + "movement", + new String[] { + "ADMIN", "JOIN", movement.getId(), "SUPPORTER", "CITIZEN", "Cara" + })); + assertTrue(movement.getSupporters().getCitizens().contains("Cara")); + } finally { + Locale.setDefault(previous); + } + } + + private MovementData persistedMovement() throws Exception { + FactionData data = + new Gson() + .fromJson(Files.readString(disk.root.resolve("Data/home.json")), FactionData.class); + assertEquals(1, data.governmentData.movements.size()); + return data.governmentData.movements.getFirst(); + } + + private Player staff() { + Player player = fixture.player("Staff"); + when(player.hasPermission("simplefactions.admin")).thenReturn(true); + return player; + } + + private void execute(Player player, String... args) { + assertTrue(new MovementCommandManager().onCommand(player, command, "movement", args)); + } + + private List<String> complete(Player player, String... args) { + return new MovementTabCompletion().onTabComplete(player, command, "movement", args); + } + + @ParameterizedTest + @CsvSource({ + "supporter,citizen", + "cause,citizen", + "supporter,guild", + "cause,guild", + "supporter,vassal", + "cause,vassal" + }) + void commandsAddAndRemoveOnlyTheSelectedMemberSlotAndPersistIt(String slot, String type) + throws Exception { + Faction faction = host(); + Movement movement = political(faction, Action.NONE); + String target = "Cara"; + if (type.equals("guild")) { + Guild guild = fixture.guild(faction, "merchants", "Eve"); + guild.setStance(Stance.OPPOSE); + target = guild.getId(); + } else if (type.equals("vassal")) { + Faction subject = fixture.saved("subject", "Eve"); + fixture.subject(faction, subject); + subject.getOrCreateMainGuild().setStance(Stance.OPPOSE); + target = subject.getId(); + } + Player staff = staff(); + List<String> args = new ArrayList<>(List.of("admin", "join", movement.getId(), slot)); + if (slot.equals("cause")) args.add("0"); + args.add(type); + args.add(target); + execute(staff, args.toArray(String[]::new)); + var pool = + slot.equals("cause") ? movement.getCauses().getFirst().getPool() : movement.getSupporters(); + assertEquals(slot.equals("cause") ? 2 : 1, pool.getAllMembers().size()); + MovementData stored = persistedMovement(); + PoolData storedPool = + slot.equals("cause") ? stored.causes.getFirst().members : stored.supporters; + List<String> storedMembers = + switch (type) { + case "guild" -> storedPool.guilds; + case "vassal" -> storedPool.factions; + default -> storedPool.citizens; + }; + assertTrue(storedMembers.contains(target)); + assertEquals(2, movement.getAllMembers().size()); + args.set(1, "leave"); + execute(staff, args.toArray(String[]::new)); + assertEquals(slot.equals("cause") ? 1 : 0, pool.getAllMembers().size()); + assertEquals(List.of("Bob"), movement.getAllMembers()); + MovementData afterLeave = persistedMovement(); + PoolData remaining = + slot.equals("cause") ? afterLeave.causes.getFirst().members : afterLeave.supporters; + assertFalse(remaining.citizens.contains(target)); + assertFalse(remaining.guilds.contains(target)); + assertFalse(remaining.factions.contains(target)); + verify(staff).sendMessage(contains("Added " + type)); + verify(staff).sendMessage(contains("Removed " + type)); + } + + @Test + void commandBackerLifecycleRejectsDuplicateSameRealmFrozenAndAbsentBacking() { + Faction faction = host(); + Movement movement = political(faction, Action.NONE); + Faction foreign = fixture.saved("foreign", "Drew"); + Player staff = staff(); + execute(staff, "admin", "join", movement.getId(), "backer", foreign.getId()); + assertEquals(List.of(foreign), movement.getForeignBackers()); + assertFalse(MovementAdminService.joinBacker(movement.getId(), foreign.getId()).ok()); + execute(staff, "admin", "leave", movement.getId(), "backer", foreign.getId()); + assertTrue(movement.getForeignBackers().isEmpty()); + assertFalse(MovementAdminService.leaveBacker(movement.getId(), foreign.getId()).ok()); + assertFalse(MovementAdminService.joinBacker(movement.getId(), faction.getId()).ok()); + movement.setFrozen(true); + assertFalse(MovementAdminService.joinBacker(movement.getId(), foreign.getId()).ok()); + assertTrue(movement.getForeignBackers().isEmpty()); + assertEquals( + MovementAdminService.JOIN_USAGE, + MovementAdminService.joinBacker(movement.getId(), null).message()); + assertEquals( + MovementAdminService.LEAVE_USAGE, + MovementAdminService.leaveBacker(movement.getId(), " ").message()); + assertFalse(MovementAdminService.joinBacker(movement.getId(), "missing").ok()); + assertFalse(MovementAdminService.joinBacker("missing", foreign.getId()).ok()); + } + + @Test + void + movementMemberMutationsRejectStaleIdsInvalidTypesAndDifferentSlotsWithoutChangingMembership() { + Movement movement = political(host(), Action.NONE); + assertFalse(MovementAdminService.joinSupporter(null, "citizen", "Cara").ok()); + assertFalse(MovementAdminService.joinSupporter(" ", "citizen", "Cara").ok()); + assertFalse(MovementAdminService.joinSupporter("missing", "citizen", "Cara").ok()); + for (String index : List.of("-1", "1", "bad")) { + assertFalse(MovementAdminService.joinCause(movement.getId(), index, "citizen", "Cara").ok()); + } + assertFalse(MovementAdminService.joinSupporter(movement.getId(), null, "Cara").ok()); + assertFalse(MovementAdminService.joinSupporter(movement.getId(), "citizen", null).ok()); + assertFalse(MovementAdminService.joinSupporter(movement.getId(), "citizen", " ").ok()); + for (String kind : List.of("bad", "guild", "vassal")) { + assertFalse(MovementAdminService.joinSupporter(movement.getId(), kind, "missing").ok()); + } + assertFalse(MovementAdminService.joinSupporter(movement.getId(), "citizen", "Alice").ok()); + assertFalse(MovementAdminService.leaveSupporter(movement.getId(), "citizen", "Cara").ok()); + assertEquals( + "§cCould not add: join did not stick.", + MovementAdminService.joinSupporter(movement.getId(), "citizen", "Bob").message()); + assertTrue(MovementAdminService.joinSupporter(movement.getId(), "citizen", "Cara").ok()); + assertFalse(MovementAdminService.joinSupporter(movement.getId(), "citizen", "Cara").ok()); + assertFalse(MovementAdminService.joinCause(movement.getId(), "0", "citizen", "Cara").ok()); + assertEquals(List.of("Cara"), movement.getSupporters().getCitizens()); + movement.setFrozen(true); + assertFalse(MovementAdminService.joinCause(movement.getId(), "0", "citizen", "Cara").ok()); + assertEquals(List.of("Bob"), movement.getCauses().getFirst().getPool().getCitizens()); + } + + @Test + void commandPermissionsAndIncompleteArgumentsReturnTheCorrectUsageWithoutMutating() { + Movement movement = political(host(), Action.NONE); + CommandSender console = mock(CommandSender.class); + assertTrue( + new MovementCommandManager() + .onCommand(console, command, "movement", new String[] {"admin", "list"})); + verifyNoInteractions(console); + Player denied = fixture.player("Guest"); + execute(denied, "admin", "list"); + verify(denied).sendMessage(contains("do not have access")); + Player staff = staff(); + for (String[] args : new String[][] {{}, {"other"}, {"admin"}, {"admin", "unknown"}}) { + clearInvocations(staff); + execute(staff, args); + verify(staff).sendMessage(MovementAdminService.USAGE); + } + for (String action : List.of("join", "leave")) { + String usage = + action.equals("join") + ? MovementAdminService.JOIN_USAGE + : MovementAdminService.LEAVE_USAGE; + for (String[] args : + new String[][] { + {"admin", action}, + {"admin", action, movement.getId(), "backer"}, + {"admin", action, movement.getId(), "supporter", "citizen"}, + {"admin", action, movement.getId(), "cause", "0", "citizen"}, + {"admin", action, movement.getId(), "invalid"} + }) { + clearInvocations(staff); + execute(staff, args); + verify(staff).sendMessage(usage); + } + } + execute(staff, "admin", "demands"); + verify(staff).sendMessage(MovementAdminService.DEMANDS_USAGE); + execute(staff, "admin", "target", movement.getId()); + verify(staff).sendMessage(MovementAdminService.TARGET_USAGE); + assertEquals(List.of("Bob"), movement.getAllMembers()); + } + + @Test + void listsDescribeLiveMovementsAndLookupCollectionsAreIndependentCopies() { + assertEquals(List.of("§7No movements."), MovementAdminService.listLines()); + assertTrue(MovementAdminService.allMovementIds().isEmpty()); + Faction faction = host(); + assertEquals(List.of("§7No movements."), MovementAdminService.listLines()); + Movement movement = political(faction, Action.NONE); + Guild guild = fixture.guild(faction, "merchants", "Eve"); + Faction subject = fixture.saved("subject", "Finn"); + fixture.subject(faction, subject); + Faction foreign = fixture.saved("foreign", "Drew"); + movement.join("Cara", null); + movement.joinAsForeignBacker(foreign); + assertEquals(List.of(movement.getId()), MovementAdminService.allMovementIds()); + List<String> citizens = MovementAdminService.hostCitizenNames(movement); + assertTrue(citizens.containsAll(List.of("Alice", "Bob", "Cara"))); + citizens.clear(); + assertTrue(faction.getMembers().contains("Cara")); + assertEquals(List.of(guild.getId()), MovementAdminService.hostGuildIds(movement)); + assertEquals(List.of(subject.getId()), MovementAdminService.hostSubjectIds(movement)); + assertEquals( + List.of(subject.getId(), foreign.getId()), MovementAdminService.otherFactionIds(movement)); + assertTrue( + MovementAdminService.wantedLeaderNames(movement).containsAll(List.of("Bob", "Cara"))); + assertTrue(MovementAdminService.wantedLeaderNames(null).isEmpty()); + assertEquals(List.of("0"), MovementAdminService.causeIndices(movement)); + String row = MovementAdminService.listLines().getFirst(); + for (String part : + List.of( + movement.getId(), + "home", + "Bob", + "GATHERING", + "false", + "causes: §f1", + "supporters: §f1", + "backers: §f1")) assertTrue(row.contains(part), row); + Player staff = staff(); + execute(staff, "admin", "list"); + verify(staff).sendMessage(row); + movement.setLeader(null); + assertTrue(MovementAdminService.listLines().getFirst().contains("leader: §fNone")); + } + + @Test + void settingLeaderTargetValidatesTheLiveCauseAndPersistsTheAcceptedName() throws Exception { + Faction faction = host(); + Movement movement = political(faction, Action.CHANGE_LEADER); + assertFalse(MovementAdminService.target("missing", "0", "Cara").ok()); + assertFalse(MovementAdminService.target(movement.getId(), "bad", "Cara").ok()); + assertFalse(MovementAdminService.target(movement.getId(), "0", null).ok()); + assertFalse(MovementAdminService.target(movement.getId(), "0", " ").ok()); + assertFalse(MovementAdminService.target(movement.getId(), "0", "Stranger").ok()); + movement.setFrozen(true); + assertFalse(MovementAdminService.target(movement.getId(), "0", "Cara").ok()); + movement.setFrozen(false); + Player staff = staff(); + execute(staff, "admin", "target", movement.getId(), "0", "Cara"); + assertEquals("Cara", movement.getCauses().getFirst().getProposal().getTarget()); + assertEquals("Cara", persistedMovement().causes.getFirst().proposal.target); + verify(staff).sendMessage(contains("Set wanted leader")); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void acceptingDemandsSupportsBothCommandFormsAndAppliesValidLeaderChange(boolean legacy) { + Faction faction = host(); + Movement movement = political(faction, Action.CHANGE_LEADER); + movement.getCauses().getFirst().getProposal().setTarget("Cara"); + Player staff = staff(); + if (legacy) execute(staff, "admin", movement.getId(), "demands", "accept"); + else execute(staff, "admin", "demands", movement.getId(), "accept"); + assertEquals("Cara", faction.getLeader()); + assertTrue(faction.getGovernment().getMovements().isEmpty()); + verify(staff).sendMessage(contains("Accepted demands")); + } + + @Test + void demandsRejectInvalidInputAndUnstartableCivilWarWithoutEndingTheMovement() { + Faction faction = host(); + Movement movement = political(faction, Action.CHANGE_LEADER); + assertFalse(MovementAdminService.demands("missing", "accept").ok()); + assertEquals( + MovementAdminService.DEMANDS_USAGE, + MovementAdminService.demands(movement.getId(), null).message()); + assertFalse(MovementAdminService.demands(movement.getId(), "accept").ok()); + movement.getCauses().getFirst().getProposal().setTarget("Cara"); + assertEquals( + MovementAdminService.DEMANDS_USAGE, + MovementAdminService.demands(movement.getId(), "invalid").message()); + for (String outcome : List.of("reject", "decline")) { + assertFalse(MovementAdminService.demands(movement.getId(), outcome).ok()); + assertTrue(faction.getGovernment().getMovements().contains(movement)); + assertFalse(movement.isFrozen()); + } + } + + @Test + void completionFollowsEachCommandShapeAndRealFactionMembership() { + Faction faction = host(); + Movement movement = political(faction, Action.CHANGE_LEADER); + Guild guild = fixture.guild(faction, "merchants", "Eve"); + Faction subject = fixture.saved("subject", "Finn"); + fixture.subject(faction, subject); + Player staff = staff(); + assertEquals(List.of("admin"), complete(staff, "A")); + assertEquals(List.of("list", "leave"), complete(staff, "admin", "l")); + assertTrue(complete(staff, "other", "").isEmpty()); + assertTrue(complete(fixture.player("Guest"), "").isEmpty()); + when(command.getName()).thenReturn("other"); + assertTrue(complete(staff, "").isEmpty()); + when(command.getName()).thenReturn("movement"); + for (String action : List.of("target", "demands", "join", "leave")) { + assertEquals(List.of(movement.getId()), complete(staff, "admin", action, "")); + assertTrue(complete(staff, "admin", action, "missing").isEmpty()); + } + assertEquals(List.of("0"), complete(staff, "admin", "target", movement.getId(), "")); + assertEquals(List.of("Cara"), complete(staff, "admin", "target", movement.getId(), "0", "C")); + assertTrue(complete(staff, "admin", "target", "missing", "").isEmpty()); + assertTrue(complete(staff, "admin", "target", movement.getId(), "0", "Cara", "").isEmpty()); + assertEquals( + List.of("accept", "reject"), complete(staff, "admin", "demands", movement.getId(), "")); + assertTrue(complete(staff, "admin", "demands", movement.getId(), "accept", "").isEmpty()); + assertEquals(List.of("demands"), complete(staff, "admin", movement.getId(), "d")); + assertEquals(List.of("reject"), complete(staff, "admin", movement.getId(), "demands", "r")); + assertTrue(complete(staff, "admin", movement.getId(), "bad", "").isEmpty()); + for (String action : List.of("join", "leave")) { + assertEquals( + List.of("supporter", "cause", "backer"), + complete(staff, "admin", action, movement.getId(), "")); + assertTrue(complete(staff, "admin", action, "missing", "backer", "").isEmpty()); + assertEquals( + List.of(subject.getId()), + complete(staff, "admin", action, movement.getId(), "backer", "")); + assertTrue( + complete(staff, "admin", action, movement.getId(), "backer", "subject", "").isEmpty()); + assertEquals( + List.of("citizen", "guild", "vassal"), + complete(staff, "admin", action, movement.getId(), "supporter", "")); + assertEquals( + List.of("Cara"), + complete(staff, "admin", action, movement.getId(), "supporter", "citizen", "C")); + assertEquals( + List.of(guild.getId()), + complete(staff, "admin", action, movement.getId(), "supporter", "guild", "")); + assertEquals( + List.of(subject.getId()), + complete(staff, "admin", action, movement.getId(), "supporter", "vassal", "")); + assertTrue( + complete(staff, "admin", action, movement.getId(), "supporter", "invalid", "").isEmpty()); + assertTrue( + complete(staff, "admin", action, movement.getId(), "supporter", "citizen", "Cara", "") + .isEmpty()); + assertEquals(List.of("0"), complete(staff, "admin", action, movement.getId(), "cause", "")); + assertEquals( + List.of("citizen", "guild", "vassal"), + complete(staff, "admin", action, movement.getId(), "cause", "0", "")); + assertEquals( + List.of("Cara"), + complete(staff, "admin", action, movement.getId(), "cause", "0", "citizen", "C")); + assertTrue( + complete(staff, "admin", action, movement.getId(), "cause", "0", "citizen", "Cara", "") + .isEmpty()); + assertTrue(complete(staff, "admin", action, movement.getId(), "unknown", "").isEmpty()); + } + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertEquals(List.of("join"), complete(staff, "ADMIN", "J")); + assertEquals( + List.of("Cara"), + complete(staff, "ADMIN", "JOIN", movement.getId(), "SUPPORTER", "CITIZEN", "C")); + } finally { + Locale.setDefault(previous); + } + } + + @Test + void publicRegistryQueriesSkipMissingEntriesWithoutHidingLiveMovementsOrForeignFactions() { + Faction faction = host(); + Movement movement = political(faction, Action.NONE); + Faction foreign = fixture.saved("foreign", "Drew"); + FactionManager.factions.addFirst(null); + faction.getGovernment().getMovements().addFirst(null); + assertEquals(List.of(movement.getId()), MovementAdminService.allMovementIds()); + assertEquals(1, MovementAdminService.listLines().size()); + assertTrue(MovementAdminService.listLines().getFirst().contains(movement.getId())); + assertEquals(List.of(foreign.getId()), MovementAdminService.otherFactionIds(movement)); + List<Faction> registry = FactionManager.factions; + try { + FactionManager.factions = null; + assertEquals(List.of("§7No movements."), MovementAdminService.listLines()); + assertTrue(MovementAdminService.allMovementIds().isEmpty()); + assertTrue(MovementAdminService.otherFactionIds(movement).isEmpty()); + } finally { + FactionManager.factions = registry; + } + } + + @Test + void incompletePublicCauseEntriesDoNotHideAnotherCausesMissingLeaderTarget() { + Faction faction = host(); + Movement movement = political(faction, Action.CHANGE_LEADER); + movement.getCauses().addFirst(null); + MovementAdminService.Result result = MovementAdminService.demands(movement.getId(), "accept"); + assertFalse(result.ok()); + assertEquals("§cOne or more causes lack a target.", result.message()); + assertTrue(faction.getGovernment().getMovements().contains(movement)); + assertEquals("Alice", faction.getLeader()); + } + + @Test + void baseGuildRegisteredThroughThePublicHandlerIsExcludedFromDomesticGuildChoices() { + Faction faction = host(); + Movement movement = political(faction, Action.NONE); + Guild guild = fixture.guild(faction, "merchants", "Eve"); + faction.getGuildHandler().addGuild(faction.getOrCreateMainGuild()); + assertEquals(List.of(guild.getId()), MovementAdminService.hostGuildIds(movement)); + assertTrue(faction.getGuildHandler().getGuilds().contains(faction.getOrCreateMainGuild())); + } + + @Test + void importedMovementWithoutAnAttachedHostHasNoHostSpecificChoices() { + Faction faction = host(); + Movement movement = new Movement(null, new MovementData()); + assertNull(movement.getFaction()); + assertTrue(MovementAdminService.hostCitizenNames(movement).isEmpty()); + assertTrue(MovementAdminService.hostGuildIds(movement).isEmpty()); + assertTrue(MovementAdminService.hostSubjectIds(movement).isEmpty()); + assertTrue(MovementAdminService.wantedLeaderNames(movement).isEmpty()); + assertTrue(MovementAdminService.causeIndices(movement).isEmpty()); + assertEquals(List.of(faction.getId()), MovementAdminService.otherFactionIds(movement)); + } + + @Test + void selectionListsOmitImportedEntriesWithoutIdsWhileKeepingUsableNeighbors() { + Faction faction = host(); + Movement movement = political(faction, Action.NONE); + Guild usableGuild = fixture.guild(faction, "merchants", "Eve"); + Guild incompleteGuild = fixture.guild(faction, null, "Finn"); + Faction usableFaction = fixture.saved("foreign", "Grace"); + Faction incompleteFaction = fixture.saved("importing", "Hank"); + incompleteFaction.setId(null); + assertNull(incompleteGuild.getId()); + assertEquals(List.of(usableGuild.getId()), MovementAdminService.hostGuildIds(movement)); + assertEquals(List.of(usableFaction.getId()), MovementAdminService.otherFactionIds(movement)); + assertTrue(faction.getGuildHandler().getGuilds().contains(incompleteGuild)); + assertTrue(FactionManager.factions.contains(incompleteFaction)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminServiceTest.java index 94501f86..17a79f03 100644 --- a/src/test/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/government/movement/admin/MovementAdminServiceTest.java @@ -260,7 +260,8 @@ void target_setsWantedLeaderWithoutOnlineCheck() { when(movement.getFaction()).thenReturn(host); when(movement.getCauses()).thenReturn(List.of(cause)); when(cause.getProposal()).thenReturn(proposal); - when(proposal.needsTarget()).thenReturn(true); + when(proposal.isPoliticalActionProposal()).thenReturn(true); + when(proposal.getPoliticalAction()).thenReturn(new net.tfminecraft.simplefactions.government.movement.PoliticalAction(net.tfminecraft.simplefactions.government.movement.Action.CHANGE_LEADER)); when(host.canBecomeLeader("dummy_11")).thenReturn(true); try (MockedStatic<FactionManager> factions = mockStatic(FactionManager.class); diff --git a/src/test/java/net/tfminecraft/simplefactions/government/stability/PoliticalStateBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/government/stability/PoliticalStateBoundaryCoverageTest.java new file mode 100644 index 00000000..77c8e641 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/government/stability/PoliticalStateBoundaryCoverageTest.java @@ -0,0 +1,199 @@ +package net.tfminecraft.simplefactions.government.stability; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import net.tfminecraft.simplefactions.government.StabilityModifier; +import net.tfminecraft.simplefactions.government.VotingBlock; +import net.tfminecraft.simplefactions.government.movement.Action; +import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.government.movement.MovementCrackdownQueries; +import net.tfminecraft.simplefactions.government.movement.MovementIds; +import net.tfminecraft.simplefactions.government.movement.MovementJoinCopy; +import net.tfminecraft.simplefactions.government.movement.PoliticalAction; +import net.tfminecraft.simplefactions.government.movement.Pool; +import net.tfminecraft.simplefactions.government.proposal.Proposal; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.branch.Branch; +import net.tfminecraft.simplefactions.loaders.BranchLoader; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +class PoliticalStateBoundaryCoverageTest { + @Test + void bankruptcyOverridesOtherwiseStableFactsAndMissingFactsReportFailure() { + StabilityReport missing = StabilityMath.assess(null); + assertEquals(StabilityStatus.FAILED, missing.status); + assertEquals(List.of("Failed State"), missing.lines); + StabilityFacts facts = new StabilityFacts(); + assertEquals(100, StabilityMath.assess(facts).stability); + facts.bankrupt = true; + StabilityReport bankrupt = StabilityMath.assess(facts); + assertEquals(0, bankrupt.stability); + assertEquals(StabilityStatus.FAILED, bankrupt.status); + assertFalse(bankrupt.status.canFormTitles()); + assertFalse(bankrupt.status.canWageWar()); + } + + @Test + void electedGovernmentChangesWeakStateRequirementsRegardlessOfServerLocale() { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + StabilityFacts facts = new StabilityFacts(); + facts.government = "OLIGARCHY"; + facts.electedLeadership = true; + assertEquals(.25, StabilityMath.sizeRequirement(facts, StabilityTuning.DEFAULTS)); + assertEquals(.5, StabilityMath.governmentExpectation("OLIGARCHY", StabilityTuning.DEFAULTS)); + } finally { + Locale.setDefault(previous); + } + } + + @Test + void guildIncompatibilityUsesConfiguredGovernmentAndElectedLeadershipInAnyLocale() { + Locale previous = Locale.getDefault(); + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + fixture.lawGroup("government", Map.of()); + fixture.lawGroup("leadership", Map.of()); + Faction faction = fixture.saved("realm", "Alice"); + Guild state = faction.getOrCreateMainGuild(); + Guild merchants = fixture.guild(faction, "merchants", "Bob"); + state.getBranches().put(0, new Branch(BranchLoader.getByString("commerce"), 1)); + merchants.getBranches().put(0, new Branch(BranchLoader.getByString("commerce"), 8)); + var government = fixture.law("government", "OLIGARCHY", Map.of()); + var elected = fixture.law("leadership", "ELECTED", Map.of()); + faction.getLawHandler().getGroup("government").setCurrent(government); + faction.getLawHandler().getGroup("leadership").setCurrent(elected); + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertEquals(.5, GovernmentIncompatibility.factor(merchants)); + assertTrue(StateStability.facts(faction).electedLeadership); + } finally { + Locale.setDefault(previous); + } + } + + @Test + void liveStateIncludesSubjectPopulationAndTheirStanceWithoutChangingTheRoster() { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + Faction realm = fixture.saved("realm", "Alice"); + Faction subject = fixture.saved("subject", "Bob"); + subject.addMember("Cara"); + subject.getOrCreateMainGuild().setStance(net.tfminecraft.simplefactions.enums.Stance.OPPOSE); + fixture.subject(realm, subject); + StabilityFacts facts = StateStability.facts(realm); + assertEquals(1, facts.vassals.size()); + assertEquals("subject", facts.vassals.getFirst().name); + assertEquals(2, facts.vassals.getFirst().members); + assertEquals("OPPOSE", facts.vassals.getFirst().stance); + assertEquals(List.of("Bob", "Cara"), subject.getMembers()); + assertTrue(StateStability.facts(null).guilds.isEmpty()); + } + } + + @Test + void stabilityBandsExposeTheDocumentedAdministrativeAndDisplayEffects() { + assertEquals(1, StabilityStatus.STABLE.getAdminFactor()); + assertEquals(.75, StabilityStatus.STRUGGLING.getAdminFactor()); + assertEquals(1.5, StabilityStatus.STRUGGLING.getUpkeepFactor()); + assertEquals(30, StabilityStatus.FAILING.getDeJureBonus()); + assertEquals("#d13530", StabilityStatus.FAILING.getListColor()); + assertEquals("#a32020", StabilityStatus.COLLAPSING.getListColor()); + assertEquals("#8f1d1d", StabilityStatus.FAILED.getListColor()); + assertTrue(new StabilityModifier("Expired", 0, 1).tick()); + } + + @Test + void absentTuningRestoresDefaultsAfterAnExplicitConfiguration() throws Exception { + // Preserve process-global configuration, including a non-default value installed by another + // test. + var active = StabilityTuning.class.getDeclaredField("active"); + active.setAccessible(true); + Object previous = active.get(null); + try { + YamlConfiguration config = new YamlConfiguration(); + config.set("stability.weak-state-oligarchy", .75); + StabilityTuning.load(config); + assertEquals(.75, StabilityTuning.get().weakStateOligarchy); + assertNotSame(StabilityTuning.DEFAULTS, StabilityTuning.get()); + StabilityTuning.load(null); + assertSame(StabilityTuning.DEFAULTS, StabilityTuning.get()); + assertEquals(.5, StabilityTuning.get().weakStateOligarchy); + } finally { + active.set(null, previous); + } + } + + @Test + void removingPoolEntriesByPersistentIdKeepsUnrelatedSupporters() { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + Faction realm = fixture.saved("realm", "Alice"); + Faction first = fixture.saved("first", "Bob"); + Faction second = fixture.saved("second", "Cara"); + Guild one = fixture.guild(realm, "one", "Drew"); + Guild two = fixture.guild(realm, "two", "Eve"); + Pool pool = new Pool(); + pool.addGuild(one); + pool.addGuild(two); + pool.addFaction(first); + pool.addFaction(second); + pool.remove("guilds", "one"); + pool.remove("factions", "first"); + pool.remove("guilds", "unknown"); + assertEquals(List.of(two), pool.getGuilds()); + assertEquals(List.of(second), pool.getFactions()); + } + } + + @Test + void movementIdentifiersAvoidMultipleExistingCollisions() { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + Faction faction = fixture.saved("realm", "Alice"); + faction.addMember("Bob"); + Proposal proposal = new Proposal("Bob", faction.getGovernment()); + proposal.setPoliticalActionProposal(new PoliticalAction(Action.CHANGE_LEADER)); + Movement first = new Movement(faction, "Bob", proposal); + faction.getGovernment().getMovements().add(first); + Movement second = new Movement(faction, "Bob", proposal); + faction.getGovernment().getMovements().add(second); + assertEquals("bob_movement", first.getId()); + assertEquals("bob_movement_2", second.getId()); + assertEquals("bob_movement_3", MovementIds.allocate("Bob")); + assertEquals("unknown_movement", MovementIds.slug(null)); + assertEquals("unknown_movement", MovementIds.slug(" ")); + } + } + + @Test + void missingAssemblyLawIdentityUsesTheDefaultCrackdownRules() { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + fixture.lawGroup("assembly", Map.of()); + Faction faction = fixture.saved("realm", "Alice"); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("law.name", "Unnamed imported law"); + var law = + new net.tfminecraft.simplefactions.laws.Law( + "assembly", null, yaml.getConfigurationSection("law")); + faction.getLawHandler().getGroup("assembly").getLaws().put("missing-id", law); + faction.getLawHandler().getGroup("assembly").setCurrent(law); + assertEquals( + MovementCrackdownQueries.LAW_NONE, MovementCrackdownQueries.assemblyLawId(faction)); + } + } + + @Test + void votingAndMembershipMessagesHandleIncompleteExternalIdentifiers() { + assertFalse(VotingBlock.matches("iaf(tfmc:voting)", null)); + assertFalse(VotingBlock.matches("iaf(tfmc:voting)", " ")); + assertNull(VotingBlock.furnitureId("tfmc:voting")); + assertNull(VotingBlock.furnitureId("iaf(")); + assertEquals("?", MovementJoinCopy.factionId(null)); + assertEquals("?", MovementJoinCopy.guildId(null)); + assertTrue(MovementJoinCopy.backerSameRealm(false, null, null).contains("same realm")); + assertTrue(MovementJoinCopy.backerOwnFaction(true, null).contains("own movement")); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/GuildCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/GuildCoverageTest.java new file mode 100644 index 00000000..60672c41 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/GuildCoverageTest.java @@ -0,0 +1,962 @@ +package net.tfminecraft.simplefactions.guild; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.database.GuildBranchData; +import net.tfminecraft.simplefactions.database.GuildData; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.database.MercenaryCompanyData; +import net.tfminecraft.simplefactions.database.StabilityModifierData; +import net.tfminecraft.simplefactions.diplomacy.RelationType; +import net.tfminecraft.simplefactions.enums.FactionModifiers; +import net.tfminecraft.simplefactions.enums.GuildModifier; +import net.tfminecraft.simplefactions.enums.Rules; +import net.tfminecraft.simplefactions.enums.Scope; +import net.tfminecraft.simplefactions.enums.Stance; +import net.tfminecraft.simplefactions.government.StabilityModifier; +import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.guild.branch.Branch; +import net.tfminecraft.simplefactions.guild.income.IncomePreviewContext; +import net.tfminecraft.simplefactions.guild.income.TradeBreakdown; +import net.tfminecraft.simplefactions.guild.upgrade.Upgrade; +import net.tfminecraft.simplefactions.inactivity.InactivityService; +import net.tfminecraft.simplefactions.installation.InstallationTransferService; +import net.tfminecraft.simplefactions.loaders.BranchLoader; +import net.tfminecraft.simplefactions.loaders.GuildLoader; +import net.tfminecraft.simplefactions.loaders.RelationLoader; +import net.tfminecraft.simplefactions.loaders.UpgradeLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.managers.ProvinceManager; +import net.tfminecraft.simplefactions.managers.RelationManager; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.Modifier; +import net.tfminecraft.simplefactions.rest.BannerFetcher; +import net.tfminecraft.simplefactions.settlement.handler.CapitalResult; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import net.tfminecraft.simplefactions.testsupport.TestRegistryAccess; +import net.tfminecraft.simplefactions.utils.RandomRGB; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.DyeColor; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.block.banner.Pattern; +import org.bukkit.block.banner.PatternType; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BannerMeta; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +public class GuildCoverageTest { + private GuildFixture fixture; + + @BeforeEach + void setUp() { + fixture = new GuildFixture(); + } + + @AfterEach + void close() { + fixture.close(); + } + + @Test + void realmGuildLeadershipTracksTheCurrentFactionLeader() { + Guild guild = fixture.guild("realm", 0); + assertTrue(guild.isLeader("Leader")); + when(fixture.host.getLeader()).thenReturn("Successor"); + assertEquals("Successor", guild.getLeader()); + assertFalse(guild.isLeader("Leader")); + assertTrue(guild.isLeader("successor")); + assertTrue(guild.isLeader(fixture.ui.player("Successor"))); + } + + @Test + void anExpansionExponentOfOneMeansConstantCostRatherThanZeroInvestedWealth() { + Guild guild = fixture.guild("guild", 3); + Cache.branchUpgradeExponent = 1.0; + assertEquals(100.0, guild.getExpansionCost()); + assertEquals(300.0, guild.getTotalExpansionSpent()); + assertEquals(240.0, guild.getTotalPossibleRefund()); + } + + @Test + void liquidationRefundsTheLevelBeingRemovedAndRecalculatesWealthAfterTheLevelDrops() { + Guild guild = fixture.guild("guild", 2); + assertEquals(160.0, guild.getRefund()); + guild.liquidateRandom(); + assertEquals(1, guild.getSize()); + assertEquals(160.0, guild.getBank().getWealth()); + assertEquals(260.0, guild.getWealth()); + } + + @Test + void liquidationWorksWhenConfiguredBranchGroupsHaveGaps() { + YamlConfiguration config = new YamlConfiguration(); + config.set("trade.group", 4); + config.set("trade.allowed-types", List.of("guild")); + Branch template = new Branch("trade", config.getConfigurationSection("trade")); + BranchLoader.map.clear(); + BranchLoader.map.put("trade", template); + GuildData data = fixture.data("guild", 2); + data.branches.getFirst().id = "trade"; + Guild guild = new Guild(data, fixture.host); + guild.liquidateRandom(); + assertEquals(1, guild.getSize()); + assertEquals(160.0, guild.getBank().getWealth()); + } + + @Test + void savedGuildDataSurvivesJsonRoundTripAndRestoresFinancialSettings() { + GuildData data = fixture.data("guild", 2); + data.members = null; + data.dividendEligible = null; + data.dividendPercent = 12.5; + data.donation = 25.0; + data.favoured = true; + data.repressed = true; + data.stance = "OPPOSE"; + data.creditScore = 70; + data.vehicleFeeIncome = 3.0; + data.casinoProfit = 5.0; + data.citizenTaxes = Map.of("Alice", 4.0); + data.usedLoanOffers = Map.of("offer", System.currentTimeMillis() + 60_000); + data.wealthModifiers.add("Inheritance(7.5)"); + data.depositsToday = Map.of("Alice", 8.0); + data.pillageHits.add(null); + StabilityModifierData hit = new StabilityModifierData(); + hit.name = "Raid"; + hit.modifier = 10; + hit.decay = 2; + data.pillageHits.add(hit); + StabilityModifierData invalid = new StabilityModifierData(); + data.pillageHits.add(invalid); + Guild loaded = + new Guild( + JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(data), GuildData.class), fixture.host); + assertEquals("merchants", loaded.getId()); + assertEquals("Merchants", loaded.getName()); + assertEquals("Leader", loaded.getLeader()); + assertEquals(List.of("Leader"), loaded.getMembers()); + assertTrue(loaded.getDividendEligibleSnapshot().isEmpty()); + assertEquals(12.5, loaded.getDividendPercent()); + assertEquals(25, loaded.getDonationAmount()); + assertTrue(loaded.isFavoured()); + assertTrue(loaded.isRepressed()); + assertEquals(Stance.OPPOSE, loaded.getStance(fixture.host)); + assertEquals(70, loaded.getLoanHandler().getCreditScore()); + assertTrue(loaded.getLoanHandler().getUsedOffers().containsKey("offer")); + assertEquals(3, loaded.getLedger().getVehicleFeeIncome()); + assertEquals(5, loaded.getLedger().getCasinoProfit()); + assertEquals(Map.of("Alice", 4.0), loaded.getLedger().getCitizenTaxesCopy()); + assertEquals(Map.of("Alice", 8.0), loaded.getLedger().getHistory().getDepositsToday()); + assertEquals(1, loaded.getPillageHits().size()); + assertEquals("Raid", loaded.getPillageHits().getFirst().getName()); + assertEquals(2, loaded.getBranch(0).getLevel()); + assertEquals(7.5, loaded.getWealthModifiers().getFirst().getAmount()); + } + + @Test + void membershipsAndInvitationsAreCaseInsensitiveAndLeaveTheMainGuildOnce() { + Guild guild = fixture.guild("guild", 0); + guild.invite(null); + guild.invite("BOB"); + guild.invite("bob"); + assertEquals(List.of("BOB"), guild.getInvites()); + assertTrue(guild.isInvited("Bob")); + assertFalse(guild.consumeInvite("missing")); + when(fixture.main.isMember("Bob")).thenReturn(true); + guild.addMember("Bob"); + verify(fixture.main).kick("Bob"); + assertTrue(guild.getInvites().isEmpty()); + guild.addMember("BOB"); + assertEquals(List.of("Leader", "Alice", "Bob"), guild.getMembers()); + assertTrue(guild.isMember(fixture.ui.player("bOb"))); + guild.kick("bOB"); + assertFalse(guild.isMember("Bob")); + guild.kick("missing"); + assertEquals(2, guild.getMembers().size()); + assertNull(Guild.findIgnoreCase(null, "Bob")); + assertNull(Guild.findIgnoreCase(List.of("Bob"), null)); + assertEquals("Alice", Guild.findIgnoreCase(java.util.Arrays.asList(null, "Alice"), "ALICE")); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void creatingGuildsInitializesDefinitionsAndWaitsForBannerFetch(boolean base) { + fixture.upgrade("roads", false, 1); + List<java.util.function.Consumer<List<String>>> callbacks = new ArrayList<>(); + try (MockedStatic<RandomRGB> rgb = mockStatic(RandomRGB.class); + MockedStatic<BannerFetcher> banners = mockStatic(BannerFetcher.class)) { + rgb.when(() -> RandomRGB.similarButDistinct("1,2,3")).thenReturn("1,2,4", "1,2,5"); + rgb.when(RandomRGB::random).thenReturn("1,2,4", "1,2,5"); + rgb.when(() -> RandomRGB.isFree("1,2,5")).thenReturn(true); + List<String> placeholder = new ArrayList<>(List.of("white")); + banners.when(BannerFetcher::placeholder).thenReturn(placeholder); + banners.when(() -> BannerFetcher.isPlaceholder(placeholder)).thenReturn(true); + banners + .when(() -> BannerFetcher.fetch(anyString(), any())) + .thenAnswer( + call -> { + callbacks.add(call.getArgument(1)); + return null; + }); + Guild guild = + base ? new Guild(fixture.host) : new Guild("New_Guild", fixture.player, fixture.host, 7); + assertEquals(base ? "realm" : "New_Guild", guild.getId()); + assertEquals(List.of("Leader"), guild.getMembers()); + assertEquals(List.of("Leader"), guild.getDividendEligibleSnapshot()); + assertNotNull(guild.getBank()); + assertNotNull(guild.getBranch(0)); + assertNotNull(guild.getUpgrade("roads")); + assertEquals(0.0, guild.getWealth()); + if (base) { + assertTrue(guild.isBase()); + assertTrue(callbacks.isEmpty()); + } else { + assertFalse(guild.isBase()); + verify(fixture.main).kick("Leader"); + assertEquals("1,2,5", guild.getRGB()); + callbacks.getFirst().accept(null); + assertEquals(List.of("white"), guild.getBannerPatterns()); + callbacks.getFirst().accept(new ArrayList<>(List.of("red"))); + assertEquals(List.of("red"), guild.getBannerPatterns()); + callbacks.getFirst().accept(List.of("blue")); + assertEquals(List.of("red"), guild.getBannerPatterns()); + } + } + } + + @Test + void wealthModifiersReplaceAccumulateAndRemoveWithoutDuplicatingSources() { + Guild guild = fixture.guild("guild", 2); + guild.getBank().setWealth(50.0); + guild.addWealthModifier(new Modifier("Gift", 3.0, false)); + guild.addWealthModifier(new Modifier("GIFT", 4.0, false)); + guild.addPersistentWealthModifier(new Modifier("Treasure", 10.0, true)); + guild.addPersistentWealthModifier(new Modifier("treasure", 5.0, true)); + guild.addPersistentWealthModifier(new Modifier("zero", 0.0, true)); + guild.updateWealth(); + assertEquals(369.0, guild.getWealth()); + assertEquals(4, guild.getWealthModifiers().size()); + guild.addPersistentWealthModifier(new Modifier("TREASURE", -15.0, true)); + guild.updateWealth(); + assertEquals(354.0, guild.getWealth()); + assertEquals(3, guild.getWealthModifiers().size()); + guild.setWealthModifiers(new ArrayList<>()); + guild.setWealth(12.0); + guild.setBank(null); + guild.updateWealth(); + assertEquals(300.0, guild.getWealth(), "Removing the bank preserves the value of guild expansions"); + assertEquals(2, guild.getWealthModifiers().size()); + assertEquals(0.0, guild.getWealthModifiers().stream().filter(m -> m.getType().equals("Bank")).findFirst().orElseThrow().getAmount()); + } + + @Test + void expansionAndRelocationCostsFollowLevelAndProvinceOwnership() { + Guild guild = fixture.guild("guild", 0); + assertEquals(0, guild.getTotalExpansionSpent()); + assertEquals(0, guild.getTotalPossibleRefund()); + assertFalse(guild.canLiquidate()); + guild.liquidateRandom(); + assertEquals(0, guild.getSize()); + guild.getBranch(0).levelUp(); + guild.getBranch(0).levelUp(); + assertEquals(300, guild.getTotalExpansionSpent()); + assertEquals(240, guild.getTotalPossibleRefund()); + assertEquals(400, guild.getExpansionCost()); + assertEquals(-1, guild.getRelocationCost(99)); + Province province = mock(Province.class); + when(fixture.provinces.get(7)).thenReturn(province); + assertEquals(100, guild.getRelocationCost(7)); + for (int n = 0; n < 4; n++) guild.getBranch(0).levelUp(); + assertEquals(945, guild.getRelocationCost(7)); + when(province.getOwner()).thenReturn(fixture.host); + assertEquals(315, guild.getRelocationCost(7)); + Faction other = mock(Faction.class); + when(other.getId()).thenReturn("other"); + when(province.getOwner()).thenReturn(other); + assertEquals(945, guild.getRelocationCost(7)); + guild.setBank(null); + guild.liquidateRandom(); + assertEquals(6, guild.getSize()); + } + + @Test + void upgradesRestoreFilterQueueFreezeAndCompleteWithAnInventoryNotification() { + Upgrade template = fixture.upgrade("roads", true, 1); + GuildData data = fixture.data("guild", 0); + GuildBranchData saved = new GuildBranchData(); + saved.id = "roads"; + saved.level = 2; + data.upgrades.add(saved); + Guild guild = new Guild(data, fixture.host); + Upgrade upgrade = guild.getUpgrade("roads"); + assertEquals(2, upgrade.getLevel()); + assertTrue(guild.hasUpgrades()); + assertEquals(List.of(upgrade), guild.getUpgrades()); + assertEquals(6, guild.getUpgradesUpkeep()); + guild.enqueueUpgrade(upgrade); + guild.tick(true); + assertEquals(1, guild.getUpgradeQueue().getFirst().getTimeLeft()); + guild.tick(); + assertEquals(3, upgrade.getLevel()); + assertTrue(guild.getUpgradeQueue().isEmpty()); + verify(fixture.inventory.getUpdater()) + .inventorySound( + "minecraft:block.note_block.chime", + net.tfminecraft.simplefactions.enums.SFGUI.UPGRADE_VIEW); + guild.addQueuedUpgrade(upgrade, 2); + guild.tick(false); + assertEquals(1, guild.getUpgradeQueue().getFirst().getTimeLeft()); + guild.enqueueUpgrade(upgrade); + guild.enqueueUpgrade(upgrade); + guild.enqueueUpgrade(upgrade); + guild.addQueuedUpgrade(upgrade, 4); + assertEquals(3, guild.getUpgradeQueue().size()); + assertFalse(guild.cancelUpgradeQueue(-1)); + assertFalse(guild.cancelUpgradeQueue(3)); + assertTrue(guild.cancelUpgradeQueue(1)); + assertEquals(2, guild.getUpgradeQueue().size()); + guild.setUpgradeQueue(new ArrayList<>()); + guild.tick(); + assertTrue(guild.getUpgradeQueue().isEmpty()); + MercenaryCompany company = mock(MercenaryCompany.class); + guild.setCompany(company); + guild.tick(); + verify(company).tick(); + assertSame(company, guild.getCompany()); + assertFalse(guild.hasCompany()); + assertFalse(guild.isFoundingCompany()); + when(company.isFormed()).thenReturn(true); + when(company.isForming()).thenReturn(true); + assertTrue(guild.hasCompany()); + assertTrue(guild.isFoundingCompany()); + guild.setCompany(null); + assertFalse(guild.hasCompany()); + assertFalse(guild.isFoundingCompany()); + } + + @Test + void capitalChangesNotifyOnlyDeparturesAndTheBaseDelegatesToItsFaction() { + Guild guild = fixture.guild("guild", 0); + guild.setCapital(8); + assertEquals(8, guild.getCapital()); + assertTrue(guild.hasCapital()); + verify(fixture.host.getSettlementHandler()).onGuildDepartedCapital(7); + guild.setCapital(8); + verify(fixture.host.getSettlementHandler(), times(1)).onGuildDepartedCapital(anyInt()); + guild.setCapital(-1, false); + assertFalse(guild.hasCapital()); + verify(fixture.provinces, times(3)).recalculateForSingleGuild(guild, true); + Guild base = fixture.guild("realm", 0); + base.setCapital(-1, false); + verify(fixture.host).setCapital(-1, true, false); + when(fixture.host.getCapital()).thenReturn(3); + when(fixture.host.hasCapital()).thenReturn(true); + assertEquals(3, base.getCapital()); + assertTrue(base.hasCapital()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void relocationChangesMembershipAndPreservesSettlementsOnlyWhenRequested(boolean keep) { + Guild guild = fixture.guild("guild", 0); + Faction other = mock(Faction.class, RETURNS_DEEP_STUBS); + when(other.getId()).thenReturn("other"); + if (keep) guild.relocateKeepingSettlements(other, 8); + else guild.relocate(other, 8); + if (keep) verify(fixture.host.getGuildHandler()).removeGuild("merchants", false, false); + else verify(fixture.host.getGuildHandler()).removeGuild("merchants"); + verify(other.getGuildHandler()).addGuild(guild); + assertSame(other, guild.getFaction()); + assertEquals(8, guild.getCapital()); + if (keep) verify(other.getSettlementHandler(), never()).onGuildDepartedCapital(anyInt()); + else verify(other.getSettlementHandler()).onGuildDepartedCapital(7); + } + + @Test + void relocationWithAnActorReportsTheSettlementResultAndBaseGuildsStayPut() { + Faction other = mock(Faction.class, RETURNS_DEEP_STUBS); + when(other.getId()).thenReturn("other"); + Guild guild = fixture.guild("guild", 0); + when(other.getSettlementHandler().onGuildRelocateTo(fixture.player, guild, 8, "New City")) + .thenReturn(CapitalResult.ok("Relocated")); + guild.relocate(other, 8, fixture.player, "New City"); + verify(fixture.player).sendMessage("Relocated"); + guild.relocate(other, 9); + assertEquals(8, guild.getCapital()); + guild.relocate(fixture.host, -1); + assertEquals(-1, guild.getCapital()); + Guild base = fixture.guild("realm", 0); + base.relocate(other, 8); + assertSame(fixture.host, base.getFaction()); + assertFalse(base.relocateWithinFaction(fixture.player, 8, null).isSuccess()); + when(fixture.host.getSettlementHandler().onGuildRelocateTo(fixture.player, guild, 9, null)) + .thenReturn(CapitalResult.ok("Within realm")); + assertTrue(guild.relocateWithinFaction(fixture.player, 9, null).isSuccess()); + verify(fixture.player).sendMessage("Within realm"); + } + + @Test + void nonBaseGuildStabilityCombinesSharesAndHonoursStanceAndInfluence() { + Guild guild = fixture.guild("guild", 0); + guild.setWealth(50.0); + when(fixture.host.getWealth()).thenReturn(100.0); + when(fixture.host.getVassalWealth()).thenReturn(0.0); + when(fixture.host.getGuildHandler().getTotalTradePower()).thenReturn(100.0); + guild.getTradeBreakdown().setTradePower(50); + assertEquals(60, guild.getStabilityEffect()); + assertEquals(30, guild.getRepressFavourCost()); + when(fixture.host.getModifier( + FactionModifiers.STABILITY_INFLUENCE, "merchants", Scope.DOMESTIC_GUILDS, null)) + .thenReturn(0.5); + assertEquals(10.5, guild.getStabilityModifier(fixture.host)); + guild.switchStance(); + assertEquals(Stance.SUPPORT, guild.getStance(fixture.host)); + assertEquals(30, guild.getStabilityModifier(fixture.host)); + guild.switchStance(); + assertEquals(-30, guild.getStabilityModifier(fixture.host)); + guild.switchStance(); + guild.setStance(null); + assertEquals(Stance.NEUTRAL, guild.getStance(fixture.host)); + when(fixture.host.getMembers()).thenReturn(List.of()); + assertEquals(0, guild.getMemberPercentage()); + when(fixture.host.getWealth()).thenReturn(0.0); + when(fixture.host.getGuildHandler().getTotalTradePower()).thenReturn(0.0); + assertEquals(0, guild.getStabilityEffect()); + } + + @Test + void baseGuildStabilitySupportsItsHostAndScalesItsInfluenceAbroad() { + Guild guild = fixture.guild("realm", 0); + assertEquals(Stance.SUPPORT, guild.getStance(fixture.host)); + assertEquals(30, guild.getStabilityModifier(fixture.host)); + Faction other = mock(Faction.class); + when(other.getId()).thenReturn("other"); + when(other.getModifier(FactionModifiers.STABILITY_INFLUENCE, "merchants", Scope.VASSALS, null)) + .thenReturn(2.0); + when(fixture.host.getGovernment().getStability()).thenReturn(50.0); + guild.setStance(Stance.SUPPORT); + assertEquals(30, guild.getStabilityModifier(other)); + } + + @Test + void incomePreviewChangesNeitherLiveTradeNorFavoursAndRepression() { + Guild guild = fixture.guild("guild", 0); + TradeBreakdown live = new TradeBreakdown(); + live.setIncome(25); + guild.setTradeBreakdown(live); + IncomePreviewContext previous = IncomePreviewContext.current(); + try { + IncomePreviewContext.open(IncomePreviewContext.favour(guild, true)); + assertTrue(guild.isFavoured()); + assertFalse(guild.isRepressed()); + assertNotSame(live, guild.getTradeBreakdown()); + guild.getTradeBreakdown().setIncome(100); + } finally { + if (previous == null) IncomePreviewContext.clear(); + else IncomePreviewContext.open(previous); + } + assertSame(live, guild.getTradeBreakdown()); + assertEquals(25, live.getIncome()); + assertFalse(guild.isFavoured()); + guild.setRepressed(true); + guild.setFavoured(true); + assertTrue(guild.isFavoured()); + assertTrue(guild.isRepressed()); + try (MockedStatic<InactivityService> inactivity = mockStatic(InactivityService.class)) { + inactivity.when(() -> InactivityService.outputFactor(guild)).thenReturn(0.5); + assertEquals(2.5, guild.getModifier(GuildModifier.TRADE_POWER)); + assertEquals(0, guild.getModifier(GuildModifier.TRADE_UPKEEP)); + } + } + + /** + * Real saved guilds and definitions; only server and faction integration boundaries are mocked. + */ + @Test + void removingTheLastExpansionDropsItsHistoricalWealthContribution() { + Guild guild = fixture.guild("guild", 1); + guild.updateWealth(); + assertEquals(100, guild.getWealth()); + guild.liquidateRandom(); + assertEquals(0, guild.getSize()); + assertEquals(80, guild.getBank().getWealth()); + assertEquals(80, guild.getWealth()); + } + + @Test + void financialLimitsAndDividendEligibilityUseFiniteValuesAndCopies() { + GuildData data = fixture.data("guild", 0); + data.dividendEligible = null; + Guild guild = new Guild(data, fixture.host); + assertEquals(List.of("Leader", "Alice"), guild.getDividendEligibleSnapshot()); + for (double value : + new double[] {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, -1, 0}) { + assertEquals(0, guild.setDividendPercent(value)); + assertEquals(0, guild.setDonationAmount(value)); + } + assertEquals(100, guild.setDividendPercent(120)); + assertEquals(33.3, guild.setDividendPercent(33.3)); + assertEquals(12.35, guild.setDonationAmount(12.345)); + assertEquals(0, fixture.guild("realm", 0).setDonationAmount(20)); + boolean previous = Cache.dividendRequirePreviousTickMembership; + try { + Cache.dividendRequirePreviousTickMembership = true; + guild.addMember("Bob"); + guild.kick("Alice"); + guild.getMembers().add(null); + assertEquals(List.of("Leader"), guild.getDividendEligibleMembers()); + guild.getDividendEligibleSnapshot().clear(); + assertEquals(2, guild.getDividendEligibleSnapshot().size()); + guild.refreshDividendEligibility(); + assertEquals(List.of("Leader", "Bob"), guild.getDividendEligibleMembers()); + Cache.dividendRequirePreviousTickMembership = false; + List<String> eligible = guild.getDividendEligibleMembers(); + assertEquals(Arrays.asList("Leader", "Bob", null), eligible); + eligible.clear(); + assertEquals(3, guild.getMembers().size()); + } finally { + Cache.dividendRequirePreviousTickMembership = previous; + } + assertFalse(guild.isBankrupt()); + Bank bank = mock(Bank.class); + guild.setBank(bank); + when(bank.getWealth()).thenReturn(null); + assertFalse(guild.isBankrupt()); + when(bank.getWealth()).thenReturn(-1.0); + assertTrue(guild.isBankrupt()); + guild.setBank(null); + assertFalse(guild.isBankrupt()); + } + + @Test + void bannerPatternsRoundTripAndIgnoreUnknownOrMalformedPatternEntries() { + assertNotNull(PatternType.CROSS); + PatternType custom = + TestRegistryAccess.registerPattern(new NamespacedKey("tfmc", "guild_crest")); + Guild guild = fixture.guild("guild", 0); + guild.setBannerPatterns( + new ArrayList<>( + List.of( + "red", + "blue.cross", + "white.guild_crest", + "bad", + "invalid.cross", + "blue.not_known"))); + assertEquals(Material.RED_BANNER, guild.getBanner().getType()); + BannerMeta loaded = (BannerMeta) guild.getBanner().getItemMeta(); + assertEquals(2, loaded.getPatterns().size()); + assertEquals(new Pattern(DyeColor.BLUE, PatternType.CROSS), loaded.getPatterns().getFirst()); + ItemStack picked = new ItemStack(Material.BLUE_BANNER); + BannerMeta selected = (BannerMeta) picked.getItemMeta(); + selected.addPattern(new Pattern(DyeColor.RED, custom)); + selected.addPattern(new Pattern(DyeColor.WHITE, mock(PatternType.class))); + picked.setItemMeta(selected); + guild.setBanner(picked); + assertEquals(List.of("BLUE.BASE", "RED.GUILD_CREST"), guild.getBannerPatterns()); + assertEquals(1, ((BannerMeta) guild.getBanner().getItemMeta()).getPatterns().size()); + Guild base = fixture.guild("realm", 0); + base.setBanner(picked); + assertSame(fixture.host.getBanner(), base.getBanner()); + assertSame(fixture.host.getBannerPatterns(), base.getBannerPatterns()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void dummyMembershipCommandsChooseAvailableNamesAndMoveLeadership(boolean base) { + Guild guild = fixture.guild(base ? "realm" : "guild", 0); + guild.getMembers().clear(); + guild.getMembers().addAll(List.of("Leader", "existing_dummy")); + fixture.factions.when(() -> FactionManager.getGuildByMember("dummy_1")).thenReturn(guild); + guild.dummify(fixture.player); + assertEquals(List.of("dummy_2", "existing_dummy"), guild.getMembers()); + verify(fixture.player).sendMessage("§adummy_2 replaced Leader"); + verify(fixture.player).sendMessage("§adummy_2 became leader"); + if (base) verify(fixture.host).setLeader("dummy_2"); + else assertEquals("dummy_2", guild.getLeader()); + fixture.factions.when(() -> FactionManager.getGuildByMember("dummy_2")).thenReturn(guild); + guild.dummyLeader(fixture.player); + assertTrue(guild.getMembers().contains("dummy_3")); + if (base) verify(fixture.host).setLeader("dummy_3"); + else assertEquals("dummy_3", guild.getLeader()); + verify(fixture.player).sendMessage("§adummy_3 became leader"); + guild.setLeader(null); + if (!base) assertFalse(guild.isLeader("Leader")); + } + + @ParameterizedTest + @ValueSource(strings = {"", "Original Guild"}) + void convertingTheRealmGuildKeepsItsOwnIdentityAndReplacesIncompatibleDefinitions(String name) { + YamlConfiguration config = new YamlConfiguration(); + config.set("realm_trade.allowed-types", List.of("realm")); + config.set("realm_trade.group", 1); + config.set("guild_trade.allowed-types", List.of("guild")); + config.set("guild_trade.group", 1); + for (String id : List.of("realm_trade", "guild_trade")) + BranchLoader.map.put(id, new Branch(id, config.getConfigurationSection(id))); + config.set("realm_road.allowed-types", List.of("realm")); + UpgradeLoader.map.put( + "realm_road", new Upgrade("realm_road", config.getConfigurationSection("realm_road"))); + Guild guild = fixture.guild("realm", 1); + guild.setName(name); + guild.getBranch(1).levelUp(); + guild.getUpgrade("realm_road").setLevel(3); + when(fixture.host.getCapital()).thenReturn(42); + when(fixture.host.getLeaderCharacter()).thenReturn("Duke Rowan"); + when(fixture.host.getLeaderCharacterOf()).thenReturn("Leader"); + guild.convert(GuildLoader.getDefaultType()); + assertFalse(guild.isBase()); + assertEquals(name.isEmpty() ? "The Realm" : name, guild.getOwnName()); + assertEquals(42, guild.getCapital()); + assertEquals("Leader", guild.getLeader()); + assertEquals("1,2,3", guild.getRGB()); + assertEquals("Duke Rowan", guild.getLeaderCharacter()); + assertEquals("Leader", guild.getLeaderCharacterOf()); + assertEquals("guild_trade", guild.getBranch(1).getId()); + assertEquals(1, guild.getBranch(1).getLevel()); + assertEquals(0, guild.getUpgrade("realm_road").getLevel()); + assertTrue(guild.getUpgrades().isEmpty()); + guild.convert(GuildLoader.getDefaultType()); + assertEquals(42, guild.getCapital()); + guild.setRGB("5,6,7"); + guild.rememberLeaderCharacter("Captain", "Successor"); + assertEquals("5,6,7", guild.getRGB()); + assertEquals("Captain", guild.getLeaderCharacter()); + assertEquals("Successor", guild.getLeaderCharacterOf()); + assertNull(guild.getBranch("missing")); + } + + @Test + void elevationAndEvictionValidateGovernmentMovementAndCapitalOwnership() { + Guild guild = fixture.guild("guild", 2); + Guild base = fixture.guild("realm", 0); + assertFalse(base.canBeEvicted(fixture.player)); + assertFalse(base.canBeElevated(fixture.player)); + assertFalse(guild.canBeElevated(fixture.player)); + assertNull(guild.elevate(false)); + verify(fixture.player).sendMessage("§cHost faction cannot have vassals"); + when(fixture.host.hasFactionRule(Rules.CAN_HAVE_VASSALS)).thenReturn(true); + when(fixture.host.getCapital()).thenReturn(7); + assertFalse(guild.canBeElevated(fixture.player)); + when(fixture.host.getCapital()).thenReturn(8); + Guild sibling = fixture.guild("guild", 0); + GuildData otherData = fixture.data("guild", 0); + otherData.id = "other"; + sibling = new Guild(otherData, fixture.host); + when(fixture.host.getGuildHandler().getGuilds()).thenReturn(List.of(base, guild, sibling)); + assertFalse(guild.canBeElevated(fixture.player)); + sibling.setCapital(9); + assertTrue(guild.canBeElevated(fixture.player)); + when(fixture.host.getGovernment().getMovementByMember("Leader")) + .thenReturn(mock(Movement.class)); + assertFalse(guild.canBeEvicted(fixture.player)); + verify(fixture.player).sendMessage("§cGuild is currently involved in a movement"); + when(fixture.host.getGovernment().getMovementByMember("Leader")).thenReturn(null); + assertTrue(guild.canBeEvicted(fixture.player)); + assertEquals( + Cache.elevationBase + Math.pow(2 * Cache.elevationSizeMultiplier, Cache.elevationExponent), + guild.getElevationCost()); + assertEquals(guild.getElevationCost() * Cache.evictionMultiplier, guild.getEvictionCost()); + assertEquals(Cache.elevationBase, fixture.guild("guild", 0).getElevationCost()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void landlessConversionClearsPoliticalStatusAndOptionallyCreatesTheSubjectRelation( + boolean subjugate) { + Guild guild = fixture.guild("guild", 1); + guild.setFavoured(true); + guild.setRepressed(true); + RelationType relation = mock(RelationType.class); + try (MockedConstruction<Faction> created = + mockConstruction( + Faction.class, + (f, context) -> { + // Faction(Guild) rebinds the guild before returning; preserve that constructor + // effect. + assertSame(guild, context.arguments().getFirst()); + guild.setHost(f); + }); + MockedStatic<RelationLoader> definitions = mockStatic(RelationLoader.class); + MockedStatic<RelationManager> relations = mockStatic(RelationManager.class)) { + definitions.when(RelationLoader::getElevationTarget).thenReturn(relation); + Faction result = guild.toLandless(subjugate); + assertSame(created.constructed().getFirst(), result); + assertSame(result, guild.getFaction()); + assertFalse(guild.hasCapital()); + assertFalse(guild.isFavoured()); + assertFalse(guild.isRepressed()); + verify(fixture.host.getGuildHandler()).removeGuild("merchants"); + fixture.factions.verify(() -> FactionManager.addFaction(result)); + relations.verify( + () -> RelationManager.setRelation(null, relation, result, fixture.host, false), + times(subjugate ? 1 : 0)); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void elevationTransfersTheCapitalAndItsInstallationsWithOptionalSubjugation(boolean subjugate) { + Guild guild = fixture.guild("guild", 1); + guild.setFavoured(true); + guild.setRepressed(true); + when(fixture.host.hasFactionRule(Rules.CAN_HAVE_VASSALS)).thenReturn(true); + RelationType relation = mock(RelationType.class); + try (MockedConstruction<Faction> created = + mockConstruction( + Faction.class, + withSettings().defaultAnswer(RETURNS_DEEP_STUBS), + (f, context) -> { + // Faction(Guild) rebinds the guild before returning; preserve that constructor + // effect. + assertSame(guild, context.arguments().getFirst()); + guild.setHost(f); + }); + MockedStatic<RelationLoader> definitions = mockStatic(RelationLoader.class); + MockedStatic<RelationManager> relations = mockStatic(RelationManager.class); + MockedStatic<InstallationTransferService> installations = + mockStatic(InstallationTransferService.class)) { + definitions.when(RelationLoader::getElevationTarget).thenReturn(relation); + Faction result = guild.elevate(subjugate); + assertSame(created.constructed().getFirst(), result); + assertSame(result, guild.getFaction()); + assertFalse(guild.isFavoured()); + assertFalse(guild.isRepressed()); + verify(result.getProvinceHandler()).addProvince(7); + verify(fixture.host.getProvinceHandler()).removeProvince(7, false); + installations.verify(() -> InstallationTransferService.transfer(fixture.host, result, 7)); + fixture.factions.verify(() -> FactionManager.addFaction(result)); + relations.verify( + () -> RelationManager.setRelation(null, relation, result, fixture.host, false), + times(subjugate ? 1 : 0)); + } + } + + @ParameterizedTest + @ValueSource(strings = {"", "Merchant Republic"}) + void rebellionPreservesSettlementsAndReturnsTheOriginalGuildName(String name) { + Guild guild = fixture.guild("guild", 1); + guild.setName(name); + guild.setFavoured(true); + guild.setRepressed(true); + try (MockedConstruction<Faction> created = mockConstruction(Faction.class)) { + Guild.RebelNation rebels = guild.rebel(); + assertSame(created.constructed().getFirst(), rebels.faction()); + assertEquals(name.isEmpty() ? "#a3a184merchants" : name, rebels.ownName()); + assertFalse(guild.isFavoured()); + assertFalse(guild.isRepressed()); + verify(fixture.host.getGuildHandler()).removeGuild("merchants", false, false); + fixture.factions.verify(() -> FactionManager.addFaction(rebels.faction())); + } + guild.setHost(null); + assertNull(guild.rebel()); + guild.setHost(mock(Faction.class)); + assertNull(guild.rebel()); + } + + @Test + void savedGuildRestoresCompanyDefaultsAndTicksPillageDecay() { + GuildData data = fixture.data("guild", 0); + data.company = new MercenaryCompanyData(); + data.dividendEligible = null; + data.pillageHits = null; + data.upgrades = null; + data.favoured = null; + data.repressed = null; + try (MockedStatic<MercenaryCompany> templates = mockStatic(MercenaryCompany.class); + MockedConstruction<MercenaryCompany> companies = mockConstruction(MercenaryCompany.class)) { + Guild guild = new Guild(data, fixture.host); + assertSame(companies.constructed().getFirst(), guild.getCompany()); + assertFalse(guild.isFavoured()); + assertFalse(guild.isRepressed()); + assertEquals(50, guild.getLoanHandler().getCreditScore()); + guild.getPillageHits().add(new StabilityModifier("Pillage", -3, 2)); + guild.tickPillageHits(); + assertEquals(-1, guild.getPillageHits().getFirst().getModifier()); + guild.tickPillageHits(); + assertTrue(guild.getPillageHits().isEmpty()); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void newlyCreatedGuildsRetainConfiguredBranchesAfterAGroupGap(boolean base) { + YamlConfiguration config = new YamlConfiguration(); + config.set("trade.group", 4); + config.set("trade.allowed-types", List.of("guild", "realm")); + BranchLoader.map.clear(); + BranchLoader.map.put("trade", new Branch("trade", config.getConfigurationSection("trade"))); + try (MockedStatic<RandomRGB> rgb = mockStatic(RandomRGB.class); + MockedStatic<BannerFetcher> banners = mockStatic(BannerFetcher.class)) { + rgb.when(RandomRGB::random).thenReturn("1,2,4"); + rgb.when(() -> RandomRGB.similarButDistinct("1,2,3")).thenReturn("1,2,4"); + rgb.when(() -> RandomRGB.isFree("1,2,4")).thenReturn(true); + banners.when(BannerFetcher::placeholder).thenReturn(new ArrayList<>(List.of("white"))); + Guild created = + base ? new Guild(fixture.host) : new Guild("New_Guild", fixture.player, fixture.host, 7); + assertNull(created.getBranch(0)); + assertNotNull(created.getBranch(4)); + assertEquals("trade", created.getBranch(4).getId()); + assertEquals(0, created.getSize()); + } + } + + @Test + void configuredUpgradeEffectsCombineWithBranchLevelsAndOnlyBenefitsScaleWithInactivity() { + Upgrade template = fixture.upgrade("roads", false, 2); + template + .getModifiers() + .put( + GuildModifier.TRADE_POWER, + new net.tfminecraft.simplefactions.guild.branch.BranchModifier(2, 3)); + template + .getModifiers() + .put( + GuildModifier.TRADE_UPKEEP, + new net.tfminecraft.simplefactions.guild.branch.BranchModifier(2, 1)); + Guild guild = fixture.guild("guild", 1); + guild.getUpgrade("roads").setLevel(2); + assertEquals(Map.of(0, guild.getBranch(0)), guild.getBranches()); + try (MockedStatic<InactivityService> inactivity = mockStatic(InactivityService.class)) { + inactivity.when(() -> InactivityService.outputFactor(guild)).thenReturn(.5); + assertEquals(7.5, guild.getModifier(GuildModifier.TRADE_POWER)); + assertEquals(4, guild.getModifier(GuildModifier.TRADE_UPKEEP)); + } + } + + public static final class GuildFixture implements AutoCloseable { + public final GuiTestFixture ui; + public final Player player; + public final Faction host; + public final Guild main; + public final ProvinceManager provinces; + public final InventoryManager inventory; + public final MockedStatic<FactionManager> factions; + private final Map<String, GuildType> oldTypes = GuildLoader.map; + private final Map<String, Branch> oldBranches = BranchLoader.map; + private final Map<String, Upgrade> oldUpgrades = UpgradeLoader.map; + private final String oldWorld = Cache.worldName; + private final double oldCost = Cache.branchUpgradeCost; + private final double oldExponent = Cache.branchUpgradeExponent; + + public GuildFixture() { + Cache.worldName = "world"; + Cache.branchUpgradeCost = 100; + Cache.branchUpgradeExponent = 2; + ui = new GuiTestFixture(); + player = ui.player("Leader"); + when(Bukkit.getLogger()).thenReturn(Logger.getLogger("GuildCoverageTest")); + when(ui.world.getChunkAt(0, 0)).thenReturn(mock(Chunk.class)); + provinces = mock(ProvinceManager.class); + when(ui.plugin.getProvinceManager()).thenReturn(provinces); + host = mock(Faction.class, RETURNS_DEEP_STUBS); + when(host.getId()).thenReturn("realm"); + when(host.getName()).thenReturn("The Realm"); + when(host.getLeader()).thenReturn("Leader"); + when(host.getRGB()).thenReturn("1,2,3"); + when(host.getMembers()).thenReturn(new ArrayList<>(List.of("Leader", "Alice"))); + when(host.getBannerPatterns()).thenReturn(new ArrayList<>(List.of("white"))); + ItemStack banner = new ItemStack(Material.WHITE_BANNER); + when(host.getBanner()).thenReturn(banner); + main = mock(Guild.class); + when(host.getOrCreateMainGuild()).thenReturn(main); + inventory = mock(InventoryManager.class, RETURNS_DEEP_STUBS); + factions = mockStatic(FactionManager.class); + factions.when(FactionManager::getInv).thenReturn(inventory); + GuildLoader.map = new LinkedHashMap<>(); + BranchLoader.map = new LinkedHashMap<>(); + UpgradeLoader.map = new LinkedHashMap<>(); + YamlConfiguration types = new YamlConfiguration(); + types.set("realm.name", "Realm"); + types.set("realm.base", true); + types.set("guild.name", "Guild"); + types.set("guild.default", true); + for (String id : List.of("realm", "guild")) + GuildLoader.map.put(id, new GuildType(id, types.getConfigurationSection(id))); + YamlConfiguration branch = new YamlConfiguration(); + branch.set("commerce.name", "Commerce"); + branch.set("commerce.allowed-types", List.of("guild", "realm")); + branch.set("commerce.group", 0); + branch.set("commerce.icon", "emerald.0"); + branch.set("commerce.description", List.of("Trade offices")); + branch.set("commerce.modifiers", List.of("TRADE_POWER 5 2")); + BranchLoader.map.put( + "commerce", new Branch("commerce", branch.getConfigurationSection("commerce"))); + } + + public GuildData data(String type, int branchLevel) { + GuildData data = new GuildData(); + data.id = "merchants"; + data.name = "Merchants"; + data.leader = "Leader"; + data.type = type; + data.capital = 7; + data.rgb = "3,4,5"; + data.banner = new ArrayList<>(List.of("white")); + data.members = new ArrayList<>(List.of("Leader", "Alice")); + GuildBranchData branch = new GuildBranchData(); + branch.id = "commerce"; + branch.level = (double) branchLevel; + data.branches.add(branch); + return data; + } + + public Guild guild(String type, int branchLevel) { + return new Guild(data(type, branchLevel), host); + } + + public Upgrade upgrade(String id, boolean war, int time) { + YamlConfiguration config = new YamlConfiguration(); + config.set(id + ".name", "Roads"); + config.set(id + ".allowed-types", List.of("guild", "realm")); + config.set(id + ".icon", "stone.0"); + config.set(id + ".upkeep", 3); + config.set(id + ".war-related", war); + config.set(id + ".expansion-time", time); + Upgrade upgrade = new Upgrade(id, config.getConfigurationSection(id)); + UpgradeLoader.map.put(id, upgrade); + return upgrade; + } + + @Override + public void close() { + try { + factions.close(); + } finally { + try { + ui.close(); + } finally { + GuildLoader.map = oldTypes; + BranchLoader.map = oldBranches; + UpgradeLoader.map = oldUpgrades; + Cache.worldName = oldWorld; + Cache.branchUpgradeCost = oldCost; + Cache.branchUpgradeExponent = oldExponent; + } + } + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/GuildDeleteCommandTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/GuildDeleteCommandTest.java new file mode 100644 index 00000000..d0f42f77 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/GuildDeleteCommandTest.java @@ -0,0 +1,265 @@ +package net.tfminecraft.simplefactions.guild; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.guild.loans.Loan; +import net.tfminecraft.simplefactions.guild.loans.LoanHandler; +import net.tfminecraft.simplefactions.managers.CommandManager; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.handler.GuildHandler; +import net.tfminecraft.simplefactions.objects.handler.ProvinceHandler; +import net.tfminecraft.simplefactions.utils.Permissions; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +class GuildDeleteCommandTest { + @ParameterizedTest + @EnumSource(FinancialBlock.class) + void aLeaderCannotDeleteGuildFinancesThatStillNeedSettling(FinancialBlock block) { + try (Fixture fixture = new Fixture("Leader", false)) { + fixture.block(block); + + assertTrue(fixture.run("delete", "smiths")); + + verify(fixture.player).sendMessage(block.message); + fixture.assertUntouched(); + } + } + + @ParameterizedTest + @ValueSource(strings = {"Leader", "lEaDeR"}) + void aLeaderCanDeleteAnEmptyDebtFreeGuildAndReturnItsMembers(String name) { + try (Fixture fixture = new Fixture(name, false)) { + assertTrue(fixture.run("delete", "smiths")); + + fixture.assertDeleted(); + } + } + + @Test + void aLeaderCanDeleteAGuildWithoutABank() { + try (Fixture fixture = new Fixture("Leader", false)) { + when(fixture.guild.getBank()).thenReturn(null); + + assertTrue(fixture.run("delete", "smiths")); + + fixture.assertDeleted(); + } + } + + @Test + void anAdministratorCanBypassTheFinancialGuards() { + try (Fixture fixture = new Fixture("Administrator", true)) { + for (FinancialBlock block : FinancialBlock.values()) fixture.block(block); + + assertTrue(fixture.run("delete", "smiths")); + + fixture.assertDeleted(); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void evenAdministratorsCannotDeleteABaseGuild(boolean admin) { + try (Fixture fixture = new Fixture(admin ? "Administrator" : "Leader", admin)) { + when(fixture.guild.isBase()).thenReturn(true); + + assertTrue(fixture.run("delete", "smiths")); + + verify(fixture.player).sendMessage("§cYou cannot delete the base guild of a faction"); + fixture.assertUntouched(); + } + } + + @ParameterizedTest + @ValueSource(strings = {"Member", "Outsider"}) + void membershipAloneDoesNotAuthorizeDeletion(String name) { + try (Fixture fixture = new Fixture(name, false)) { + assertTrue(fixture.run("delete", "smiths")); + + verify(fixture.player).sendMessage("§cYou are not the leader of this guild"); + fixture.assertUntouched(); + } + } + + @Test + void deletingAnUnknownGuildLeavesExistingGuildsUntouched() { + try (Fixture fixture = new Fixture("Leader", false)) { + assertTrue(fixture.run("delete", "missing")); + + verify(fixture.player).sendMessage("§cNo guild by the id missing"); + fixture.assertUntouched(); + } + } + + @Test + void joiningAnUnknownGuildReportsTheMissingGuildWithoutRemovingCurrentMembership() { + try (Fixture fixture = new Fixture("Joiner", false)) { + Guild previous = mock(Guild.class); + fixture.factions.when(() -> FactionManager.canJoinGuild(fixture.player)).thenReturn(true); + fixture.factions.when(() -> FactionManager.getGuildByMember("Joiner")).thenReturn(previous); + + assertTrue(fixture.run("join", "missing")); + + verify(fixture.player).sendMessage("§cNo guild by the id missing"); + verifyNoInteractions(previous); + fixture.assertUntouched(); + } + } + + @Test + void anExistingInvitationStillTransfersMembershipAndNotifiesTheRealm() { + try (Fixture fixture = new Fixture("Joiner", false)) { + Guild previous = mock(Guild.class); + fixture.factions.when(() -> FactionManager.canJoinGuild(fixture.player)).thenReturn(true); + fixture.factions.when(() -> FactionManager.getGuildByMember("Joiner")).thenReturn(previous); + when(fixture.guild.consumeInvite("Joiner")).thenReturn(true); + fixture.bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(fixture.member)); + + assertTrue(fixture.run("join", "smiths")); + + verify(previous).kick("Joiner"); + verify(fixture.guild).consumeInvite("Joiner"); + assertEquals(List.of("Leader", "Member", "Offline", "Joiner"), fixture.members); + verify(fixture.faction).updatePrestige(); + verify(fixture.player).sendMessage("§aJoined Smiths"); + verify(fixture.member).sendMessage("§aJoiner joined the faction!"); + assertSame(fixture.guild, fixture.guilds.getGuild("smiths")); + verifyNoInteractions(fixture.provinces); + } + } + + private enum FinancialBlock { + BANKRUPTCY("§cCannot delete a guild that is in bankruptcy!"), + BANK_BALANCE("§cCannot delete a guild while the bank balance is above 0"), + ACTIVE_LOAN("§cCannot delete a guild with active loans"); + + private final String message; + + FinancialBlock(String message) { + this.message = message; + } + } + + private static final class Fixture implements AutoCloseable { + private final Player player = mock(Player.class); + private final Player leader = mock(Player.class); + private final Player member = mock(Player.class); + private final Command command = mock(Command.class); + private final Faction faction = mock(Faction.class); + private final Guild guild = mock(Guild.class); + private final Bank bank = mock(Bank.class); + private final LoanHandler loans = mock(LoanHandler.class); + private final ProvinceHandler provinces = mock(ProvinceHandler.class); + private final GuildHandler guilds = new GuildHandler(faction); + private final List<String> members = new ArrayList<>(List.of("Leader", "Member", "Offline")); + private final List<String> returnedMembers = new ArrayList<>(); + private final MockedStatic<FactionManager> factions; + private final MockedStatic<Bukkit> bukkit; + private final MockedStatic<SimpleFactions> plugin; + + private Fixture(String name, boolean admin) { + when(player.getName()).thenReturn(name); + when(player.hasPermission(Permissions.Permission_Admin)).thenReturn(admin); + when(leader.getName()).thenReturn("Leader"); + when(member.getName()).thenReturn("Member"); + when(command.getName()).thenReturn("guild"); + when(guild.getId()).thenReturn("smiths"); + when(guild.getName()).thenReturn("Smiths"); + when(guild.getLeader()).thenReturn("Leader"); + when(guild.getFaction()).thenReturn(faction); + when(guild.getBank()).thenReturn(bank); + when(guild.getLoanHandler()).thenReturn(loans); + when(loans.getLoansTaken()).thenReturn(List.of()); + when(guild.getMembers()).thenReturn(members); + doAnswer( + call -> { + members.add(call.getArgument(0)); + return null; + }) + .when(guild) + .addMember(anyString()); + when(faction.getName()).thenReturn("Realm"); + when(faction.getMembers()).thenAnswer(call -> new ArrayList<>(members)); + when(faction.getGuildHandler()).thenReturn(guilds); + when(faction.getProvinceHandler()).thenReturn(provinces); + doAnswer( + call -> { + returnedMembers.add(call.getArgument(0)); + return null; + }) + .when(faction) + .addMember(anyString()); + guilds.addGuild(guild); + + factions = mockStatic(FactionManager.class); + factions + .when(() -> FactionManager.getGuildByString("smiths")) + .thenAnswer(call -> guilds.getGuild("smiths")); + bukkit = mockStatic(Bukkit.class); + bukkit.when(() -> Bukkit.getPlayerExact("Leader")).thenReturn(leader); + bukkit.when(() -> Bukkit.getPlayerExact("Member")).thenReturn(member); + plugin = mockStatic(SimpleFactions.class); + } + + private void block(FinancialBlock block) { + switch (block) { + case BANKRUPTCY -> when(guild.isBankrupt()).thenReturn(true); + case BANK_BALANCE -> when(bank.getWealth()).thenReturn(0.01); + case ACTIVE_LOAN -> when(loans.getLoansTaken()).thenReturn(List.of(mock(Loan.class))); + } + } + + private boolean run(String... args) { + return new CommandManager().onCommand(player, command, "guild", args); + } + + private void assertUntouched() { + assertSame(guild, guilds.getGuild("smiths")); + assertEquals(List.of("Leader", "Member", "Offline"), members); + assertTrue(returnedMembers.isEmpty()); + verifyNoInteractions(provinces, leader, member); + bukkit.verifyNoInteractions(); + } + + private void assertDeleted() { + assertNull(guilds.getGuild("smiths")); + assertEquals(List.of("Leader", "Member", "Offline"), returnedMembers); + verify(provinces, atLeastOnce()).revalidateClaims(); + verify(member).sendMessage("§cYour guild has been deleted!"); + verify(member).sendMessage("§aJoined Realm"); + verify(leader, never()).sendMessage(anyString()); + verify(player).sendMessage("§cGuild Smiths §cdeleted!"); + } + + @Override + public void close() { + plugin.close(); + bukkit.close(); + factions.close(); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/hub/HighwayRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HighwayRuntimeCoverageTest.java new file mode 100644 index 00000000..ebd6a309 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/hub/HighwayRuntimeCoverageTest.java @@ -0,0 +1,600 @@ +package net.tfminecraft.simplefactions.guild.hub; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.zip.GZIPOutputStream; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.enums.Terrain; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.hub.HubTransport.Mode; +import net.tfminecraft.simplefactions.guild.network.RailRoutes; +import net.tfminecraft.simplefactions.guild.network.TradeGraph; +import net.tfminecraft.simplefactions.guild.network.TradeGraphBuilder; +import net.tfminecraft.simplefactions.guild.network.TradeGraphBuilder.ProvinceData; +import net.tfminecraft.simplefactions.guild.network.TradeGraphBuilder.Site; +import net.tfminecraft.simplefactions.installation.Installation; +import net.tfminecraft.simplefactions.installation.InstallationKind; +import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader; +import net.tfminecraft.simplefactions.managers.ProvinceManager; +import net.tfminecraft.simplefactions.map.ProvinceGrid; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.map.provinces.ProvinceDataEntry; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.tracks.TrackJunction; +import net.tfminecraft.vehicleframework.tracks.TrackRegistry; +import net.tfminecraft.vehicleframework.tracks.TrackSample; +import net.tfminecraft.vehicleframework.tracks.TrackSegment; +import net.tfminecraft.vehicleframework.tracks.TrackSpline; +import net.tfminecraft.vehicleframework.tracks.TrackStore; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +class HighwayRuntimeCoverageTest { + @TempDir Path temporary; + private FactionDomainFixture fixture; + private MockedStatic<VehicleFramework> vehicles; + private final Map<Field, Object> saved = new LinkedHashMap<>(); + private final List<TrackRegistry> registries = new ArrayList<>(); + private Map<Object, Object> installationKinds; + + @BeforeEach + void setUp() throws Exception { + fixture = new FactionDomainFixture(); + vehicles = mockStatic(VehicleFramework.class); + remember(HubTransport.class, "rates"); + for (String field : List.of("enabled", "share", "keptPer1000", "rangeBlocks")) + remember(OpenTrackSettings.class, field); + remember(Cache.class, "supplyHubCorridorShare"); + remember(InstallationConfigLoader.class, "consentProximityBlocks"); + remember(InstallationConfigLoader.class, "transferRequestTimeoutSeconds"); + installationKinds = new LinkedHashMap<>(kindMap()); + HubTransport.resetConfig(); + OpenTrackSettings.reset(); + when(Bukkit.getPluginManager().isPluginEnabled("VehicleFramework")).thenReturn(true); + configureInstallations(); + } + + @AfterEach + void close() throws Exception { + for (TrackRegistry registry : registries) registry.close(); + kindMap().clear(); + kindMap().putAll(installationKinds); + for (var entry : saved.entrySet()) entry.getKey().set(null, entry.getValue()); + vehicles.close(); + fixture.close(); + } + + @Test + void stationCanBoardTheMiddleOfAnUnbrokenCoarseTrackSegment() { + TrackSpline track = spline(0, 200); + Map<Integer, Double> reached = + VehicleFrameworkTracks.reach( + List.of(track), List.of(), 100, 0, 10, 60, (x, z) -> x < 80 ? 2 : x > 120 ? 3 : 1, 1); + + assertTrue( + reached.containsKey(2), "The station touches the segment towards the western province"); + assertTrue( + reached.containsKey(3), "The same station can travel east along the unbroken segment"); + assertTrue(reached.values().stream().allMatch(distance -> distance > 0 && distance <= 60)); + assertFalse(reached.containsKey(1), "The home province is not an open-track destination"); + } + + @Test + void midpointStationCanReachANearbyJunctionWithoutDetouringThroughRemoteEndpoints() { + TrackSpline stem = spline(0, 200); + TrackSpline branch = + new TrackSpline( + UUID.randomUUID(), + "world", + false, + List.of(new TrackSample(120, 0, 0, 0, 0, 0), new TrackSample(120, 0, 40, 0, 0, 40)), + null); + TrackJunction junction = + new TrackJunction( + UUID.randomUUID(), stem.getId(), 120, 0, TrackJunction.Side.LEFT, branch.getId(), true); + Map<Integer, Double> reached = + VehicleFrameworkTracks.reach( + List.of(stem, branch), List.of(junction), 100, 0, 5, 50, (x, z) -> z >= 20 ? 2 : 1, 1); + assertTrue( + reached.containsKey(2), + "The junction is 20 blocks east and the destination 20 blocks up its branch"); + assertTrue(reached.get(2) >= 30 && reached.get(2) <= 50); + assertFalse(reached.containsKey(1)); + } + + @Test + void aStationTouchingOnlyABrokenSegmentCannotBoardOrReachItsJunction() { + TrackSpline intact = spline(0, 200); + TrackSpline broken = + new TrackSpline( + intact.getId(), + "world", + false, + intact.getSamples(), + List.of(new TrackSegment(0, true, 1))); + TrackSpline branch = + new TrackSpline( + UUID.randomUUID(), + "world", + false, + List.of(new TrackSample(120, 0, 0, 0, 0, 0), new TrackSample(120, 0, 40, 0, 0, 40)), + null); + TrackJunction junction = + new TrackJunction( + UUID.randomUUID(), + broken.getId(), + 120, + 0, + TrackJunction.Side.LEFT, + branch.getId(), + true); + assertTrue( + VehicleFrameworkTracks.reach( + List.of(broken, branch), + List.of(junction), + 100, + 0, + 5, + 100, + (x, z) -> z >= 20 || x < 80 || x > 140 ? 2 : 1, + 1) + .isEmpty()); + } + + @Test + void continuousBoardingMeasuresDistanceFromTheCircleEdgeAndHonorsTangency() { + TrackSpline line = spline(0, 200); + TrackReach.At endpoints = (x, z) -> x == 0 ? 2 : x == 200 ? 3 : 1; + assertDistances( + Map.of(2, 90.0, 3, 90.0), + VehicleFrameworkTracks.reach(List.of(line), List.of(), 100, 0, 10, 100, endpoints, 1)); + assertDistances( + Map.of(2, 100.0, 3, 100.0), + VehicleFrameworkTracks.reach(List.of(line), List.of(), 100, 0, 0, 100, endpoints, 1)); + assertDistances( + Map.of(2, 100.0, 3, 100.0), + VehicleFrameworkTracks.reach(List.of(line), List.of(), 100, 10, 10, 100, endpoints, 1)); + assertTrue( + VehicleFrameworkTracks.reach(List.of(line), List.of(), 100, 11, 10, 200, endpoints, 1) + .isEmpty()); + assertTrue( + VehicleFrameworkTracks.reach(List.of(line), List.of(), 220, 0, 10, 200, endpoints, 1) + .isEmpty()); + } + + @Test + void aLoopUsesItsShortClosingEdgeAndZeroLengthSamplesDoNotCreateExtraDistance() { + TrackSpline loop = + TrackSpline.fromPoints( + UUID.randomUUID(), + "world", + true, + List.of( + new double[] {0, 0, 0}, new double[] {100, 0, 0}, + new double[] {100, 0, 100}, new double[] {0, 0, 100})); + assertEquals( + Map.of(2, 100.0), + VehicleFrameworkTracks.reach( + List.of(loop), List.of(), 0, 0, 0, 100, (x, z) -> x == 0 && z == 100 ? 2 : 1, 1)); + TrackSpline repeated = + TrackSpline.fromPoints( + UUID.randomUUID(), + "world", + false, + List.of(new double[] {0, 0, 0}, new double[] {0, 0, 0}, new double[] {80, 0, 0})); + assertEquals( + Map.of(2, 80.0), + VehicleFrameworkTracks.reach( + List.of(repeated), List.of(), 0, 0, 0, 80, (x, z) -> x == 80 ? 2 : 1, 1)); + assertTrue( + VehicleFrameworkTracks.reach( + List.of(repeated), List.of(), -10, 0, 1, 80, (x, z) -> x == 80 ? 2 : 1, 1) + .isEmpty()); + } + + @Test + void realRegistryRoutesAndSamplesOnlyKnownLandInTheSelectedWorld() throws Exception { + TrackRegistry registry = registry(spline(0, 100, 200)); + Installation west = station("west", 1, 0); + Installation east = station("east", 3, 200); + double length = VehicleFrameworkTracks.distance("world", west, east).orElseThrow(); + RailRoutes.Route route = VehicleFrameworkTracks.route("world", west, east).orElseThrow(); + assertEquals(length, route.length(), 1e-9); + assertTrue(length > 190 && length <= 200); + assertFalse(route.points().isEmpty()); + assertTrue(route.points().stream().allMatch(point -> point.y() == 0 && point.z() == 0)); + assertTrue(route.points().getFirst().x() < route.points().getLast().x()); + ProvinceGrid grid = grid(); + Map<Integer, Province> provinces = + Map.of( + 1, new Province(1, "PLAINS", 50), + 2, new Province(2, "SEA", 0), + 3, new Province(3, "PLAINS", 50)); + assertEquals(Set.of(1, 3), VehicleFrameworkTrackProvinces.sample("world", grid, provinces)); + assertEquals(Set.of(), VehicleFrameworkTracks.sample("elsewhere", grid, provinces)); + assertEquals(Set.of(), VehicleFrameworkTracks.sample(null, grid, provinces)); + assertTrue(VehicleFrameworkTracks.distance(null, west, east).isEmpty()); + assertTrue(VehicleFrameworkTracks.route(null, west, east).isEmpty()); + assertTrue(VehicleFrameworkTracks.route("elsewhere", west, east).isEmpty()); + assertEquals(1, registry.inWorld("world").size()); + } + + @Test + void absentOrOlderVehicleFrameworkDisablesRoutesWithoutLosingTheCallersState() { + Installation west = station("west", 1, 0); + Installation east = station("east", 2, 200); + assertTrue(VehicleFrameworkTracks.distance("world", west, east).isEmpty()); + assertTrue(VehicleFrameworkTracks.sample("world", null, Map.of()).isEmpty()); + assertTrue(VehicleFrameworkTracks.route("world", west, east).isEmpty()); + vehicles.when(VehicleFramework::getTrackRegistry).thenThrow(new NoSuchMethodError("older API")); + assertTrue(VehicleFrameworkTracks.route("world", west, east).isEmpty()); + vehicles + .when(VehicleFramework::getTrackRegistry) + .thenThrow(new IllegalStateException("stopping")); + assertTrue(VehicleFrameworkTracks.route("world", west, east).isEmpty()); + when(Bukkit.getPluginManager().isPluginEnabled("VehicleFramework")).thenReturn(false); + assertTrue(VehicleFrameworkTracks.route("world", west, east).isEmpty()); + assertEquals("west", west.getId()); + assertEquals(200, east.getCenterX()); + } + + @Test + void openTrackSnapshotsIncludeOnlyStationsAndHonorWorldAndInputGuards() throws Exception { + TrackRegistry registry = registry(spline(0, 100, 200)); + TrackReach.At provinceAt = (x, z) -> (int) (x / 100) + 1; + TradeGraph.Node station = node("west", InstallationKind.TRAIN_STATION, 1, 0); + TradeGraph.Node port = node("port", InstallationKind.PORT, 1, 0); + List<TradeGraph.OpenTrack> found = + VehicleFrameworkTracks.openTracks( + "world", Arrays.asList(null, port, station), 2, 130, provinceAt); + assertEquals(1, found.size()); + assertEquals("home", found.getFirst().ownerFactionId()); + assertEquals("west", found.getFirst().installationId()); + assertTrue(found.getFirst().distances().containsKey(2)); + assertFalse(found.getFirst().distances().containsKey(3)); + assertThrows( + UnsupportedOperationException.class, () -> found.getFirst().distances().put(7, 2.0)); + assertTrue( + VehicleFrameworkTracks.openTracks(null, List.of(station), 2, 100, provinceAt).isEmpty()); + assertTrue(VehicleFrameworkTracks.openTracks("world", null, 2, 100, provinceAt).isEmpty()); + assertTrue( + VehicleFrameworkTracks.openTracks("world", List.of(station), 2, 100, null).isEmpty()); + assertTrue( + VehicleFrameworkTracks.openTracks("world", List.of(station), -1, 100, provinceAt) + .isEmpty()); + assertTrue( + VehicleFrameworkTracks.openTracks("world", List.of(station), 2, 0, provinceAt).isEmpty()); + assertTrue( + VehicleFrameworkTracks.openTracks("absent", List.of(station), 2, 100, provinceAt) + .isEmpty()); + vehicles.when(VehicleFramework::getTrackRegistry).thenReturn(null); + assertTrue( + VehicleFrameworkTracks.openTracks("world", List.of(station), 2, 100, provinceAt).isEmpty()); + assertEquals(1, registry.inWorld("world").size()); + } + + @Test + void malformedExternalJunctionRowsDoNotDiscardAValidIndependentTrack() { + TrackSpline line = spline(0, 100); + TrackJunction dangling = + new TrackJunction( + UUID.randomUUID(), + line.getId(), + 0, + 0, + TrackJunction.Side.LEFT, + UUID.randomUUID(), + true); + TrackJunction self = + new TrackJunction( + UUID.randomUUID(), line.getId(), 0, 0, TrackJunction.Side.LEFT, line.getId(), true); + TrackJunction missing = + new TrackJunction( + UUID.randomUUID(), line.getId(), 0, 0, TrackJunction.Side.LEFT, null, true); + Map<Integer, Double> reached = + VehicleFrameworkTracks.reach( + Arrays.asList(null, line), + Arrays.asList(null, dangling, self, missing), + 0, + 0, + 1, + 100, + (x, z) -> x < 50 ? 1 : 2, + 1); + assertTrue(reached.get(2) >= 48 && reached.get(2) <= 64); + assertTrue(VehicleFrameworkTracks.reach(null, null, 0, 0, 1, 100, (x, z) -> 2, 1).isEmpty()); + assertTrue(VehicleFrameworkTracks.reach(List.of(line), null, 0, 0, 1, 100, null, 1).isEmpty()); + TrackSpline broken = + new TrackSpline( + line.getId(), "world", false, line.getSamples(), List.of(new TrackSegment(0, true, 1))); + assertTrue( + VehicleFrameworkTracks.reach( + List.of(broken), null, 0, 0, 1, 100, (x, z) -> x < 50 ? 1 : 2, 1) + .isEmpty()); + } + + @Test + void shortestTrackTravelSupersedesAnEarlierLongerPathAndRejectsInvalidEdges() { + List<List<TrackReach.Edge>> graph = new ArrayList<>(); + for (int i = 0; i < 4; i++) graph.add(new ArrayList<>()); + TrackReach.link(graph, 0, 1, 9); + TrackReach.link(graph, 0, 2, 2); + TrackReach.link(graph, 2, 1, 2); + TrackReach.link(graph, 1, 3, 20); + int edges = graph.stream().mapToInt(List::size).sum(); + TrackReach.link(graph, 0, 0, 1); + TrackReach.link(graph, 0, 3, -1); + TrackReach.link(graph, 0, 3, Double.NaN); + assertEquals(edges, graph.stream().mapToInt(List::size).sum()); + assertArrayEquals( + new double[] {0, 4, 2, Double.POSITIVE_INFINITY}, + TrackReach.travel(graph, new boolean[] {true}, 10)); + assertTrue( + Arrays.stream(TrackReach.travel(graph, new boolean[] {true}, -1)) + .allMatch(value -> value == Double.POSITIVE_INFINITY)); + } + + @Test + void previewSelectsBestModeOnceAndReturnsStableDirectedHubIdentities() { + TradeGraph graph = parallelGraph(); + TradeGraph.Node first = graph.node("home", "a"); + TradeGraph.Node last = graph.node("home", "b"); + var hubs = Set.of(new Highway.HubSite("HOME", "A"), new Highway.HubSite("home", "B")); + assertTrue(new Highway.HubSite("HOME", "A").matches(first)); + assertFalse(new Highway.HubSite(null, null).matches(first)); + assertFalse(new Highway.HubSite(null, null).matches(null)); + List<HubTransport.Link> links = Highway.links(graph, hubs); + assertEquals(2, links.size()); + assertEquals(List.of(1, 2), links.stream().map(HubTransport.Link::fromProvince).toList()); + assertEquals(List.of(2, 1), links.stream().map(HubTransport.Link::toProvince).toList()); + assertTrue(links.stream().allMatch(link -> link.mode() == Mode.RAIL)); + assertEquals("a", links.getFirst().fromInstallationId()); + assertEquals("b", links.getFirst().toInstallationId()); + assertThrows(UnsupportedOperationException.class, () -> links.clear()); + assertTrue(Highway.links(null, hubs).isEmpty()); + assertTrue(Highway.links(graph, null).isEmpty()); + assertTrue(Highway.links(graph, Set.of(new Highway.HubSite("home", "a"))).isEmpty()); + assertTrue( + Highway.links( + graph, + Set.of(new Highway.HubSite("home", "a"), new Highway.HubSite("home", "missing"))) + .isEmpty()); + assertNull(Highway.bestLink(null, first, last)); + assertNull(Highway.bestLink(graph, null, last)); + assertNull(Highway.bestLink(graph, first, null)); + assertEquals(0, Highway.hopFactor(null, 1, 1)); + assertEquals(0, Highway.hopFactor(links.getFirst(), Double.NaN, 1)); + assertEquals(0.1, Highway.hopFactor(links.getFirst(), 2, .25), 1e-9); + Faction home = fixture.saved("home", "Alice"); + Guild guild = fixture.guild(home, "traders", "Bob"); + ProvinceManager provinces = provinces(1, 2); + provinces.get(1).setData(guild.getId(), new ProvinceDataEntry(guild, 100, 10)); + assertEquals(40, Highway.wouldArrive(provinces, guild, graph, hubs, last, 0), 1e-9); + assertEquals(0, Highway.wouldArrive(provinces, guild, graph, hubs, first, 0)); + assertEquals(0, Highway.wouldArrive(null, guild, graph, hubs, last, 0)); + assertEquals(0, Highway.wouldArrive(provinces, null, graph, hubs, last, 0)); + assertEquals(0, Highway.wouldArrive(provinces, guild, null, hubs, last, 0)); + assertEquals(0, Highway.wouldArrive(provinces, guild, graph, hubs, null, 0)); + assertEquals(100, provinces.get(1).getRawGuildTrade(guild)); + assertEquals(0, provinces.get(2).getRawGuildTrade(guild)); + } + + @Test + void equalTradeModesSelectRailInBothDirections() { + YamlConfiguration config = new YamlConfiguration(); + config.set("installation-trade.transport.rail.trade", .4); + config.set("installation-trade.transport.sea.trade", .4); + HubTransport.loadConfig(config); + TradeGraph graph = parallelGraph(); + var links = + Highway.links( + graph, Set.of(new Highway.HubSite("home", "a"), new Highway.HubSite("home", "b"))); + assertEquals(2, links.size()); + assertTrue(links.stream().allMatch(link -> link.mode() == Mode.RAIL)); + assertTrue(links.stream().allMatch(link -> link.tradeFactor() == .4)); + } + + @Test + void transportLinksKeepTheirInstallationIdentityAndUseCurrentDistanceLossForBonuses() { + Installation west = station("west", 1, 0); + Installation east = station("east", 2, 1000); + HubTransport.Link link = HubTransport.link(west, "home", east, "guest", Mode.RAIL, 1000); + assertEquals("home", link.fromFactionId()); + assertEquals("west", link.fromInstallationId()); + assertEquals("guest", link.toFactionId()); + assertEquals("east", link.toInstallationId()); + assertEquals(1, link.fromProvince()); + assertEquals(2, link.toProvince()); + assertEquals(.36, link.tradeFactor(), 1e-9); + assertEquals(.72, link.boostedTradeFactor(1), 1e-9); + assertEquals(.72, link.boostedProductionFactor(1), 1e-9); + assertEquals(.36, HubTransport.link(1, 2, Mode.RAIL, 1000).tradeFactor(), 1e-9); + YamlConfiguration config = new YamlConfiguration(); + config.set("installation-trade.transport.rail.kept-per-1000-blocks", 0); + HubTransport.loadConfig(config); + assertEquals( + 0, + link.boostedTradeFactor(1), + "A previously cached link cannot divide by zero after reload"); + assertEquals(0, link.boostedProductionFactor(1)); + } + + @Test + void zeroAccessOrMissingDeliveryInputsPreserveRealGuildTradeAndProduction() { + Faction home = fixture.saved("home", "Alice"); + Guild guild = fixture.guild(home, "traders", "Bob"); + ProvinceManager provinces = provinces(1, 2); + provinces.get(1).setData(guild.getId(), new ProvinceDataEntry(guild, 100, 10)); + TradeGraph graph = TestGraphs.linked("home", 0, station("a", 1, 0), station("b", 2, 0)); + for (TradeGraph missing : + Arrays.asList( + null, + TradeGraphBuilder.build( + List.of(), Map.of(), (left, right) -> Optional.empty(), point -> 0))) { + Highway.deliver(provinces, guild, missing, Map.of("home", 1.0)); + Highway.deliverProduction(provinces, guild, missing, Map.of("home", 1.0)); + } + Highway.deliver(null, guild, graph, Map.of()); + Highway.deliver(provinces, null, graph, Map.of()); + Highway.deliverProduction(null, guild, graph, Map.of()); + Highway.deliverProduction(provinces, null, graph, Map.of()); + Highway.deliver(provinces, guild, graph, null); + Highway.deliverProduction(provinces, guild, graph, Map.of("home", 0.0)); + assertEquals(100, provinces.get(1).getRawGuildTrade(guild)); + assertEquals(10, provinces.get(1).getGuildProduction(guild)); + assertEquals(0, provinces.get(2).getRawGuildTrade(guild)); + assertEquals(0, provinces.get(2).getGuildProduction(guild)); + Highway.deliver(provinces, guild, graph, Map.of("home", 1.0)); + Highway.deliverProduction(provinces, guild, graph, Map.of("home", 1.0)); + assertEquals(40, provinces.get(2).getRawGuildTrade(guild), 1e-9); + assertEquals(4, provinces.get(2).getGuildProduction(guild), 1e-9); + } + + @Test + void malformedLineInputsFailClearlyAndNonFiniteConfigurationCannotCreateTrade() { + assertThrows( + IllegalArgumentException.class, + () -> + Highway.lineDeliveries(new double[] {0}, new double[] {}, new double[] {1}, .4, .9, 1)); + assertArrayEquals( + new double[] {0, 0}, + Highway.lineDeliveries( + new double[] {0, 1}, new double[] {100, 0}, new double[] {1, 1}, .4, .9, Double.NaN)); + Cache.supplyHubCorridorShare = Double.NaN; + assertEquals(.5, Highway.corridorShare()); + Cache.supplyHubCorridorShare = -1; + assertEquals(0, Highway.corridorShare()); + Cache.supplyHubCorridorShare = 3; + assertEquals(1, Highway.corridorShare()); + HubTransport.loadConfig(null); + assertEquals(new HubTransport.Rates(.4, .4, .9), HubTransport.rates(Mode.RAIL)); + YamlConfiguration config = new YamlConfiguration(); + config.set("installation-trade.transport.rail.trade", Double.NaN); + config.set("installation-trade.transport.rail.production", -1); + config.set("installation-trade.transport.rail.kept-per-1000-blocks", 0); + HubTransport.loadConfig(config); + assertEquals(new HubTransport.Rates(0, 0, 0), HubTransport.rates(Mode.RAIL)); + var legacy = new HubTransport.Link(1, 2, Mode.RAIL, 1000, .5, .5); + assertEquals(0, legacy.boostedTradeFactor(1)); + assertEquals(0, legacy.boostedProductionFactor(1)); + assertEquals(0, HubTransport.delivered(.4, 0, 0)); + } + + private TradeGraph parallelGraph() { + Installation left = new Installation("a", "West", InstallationKind.PORT, 1, 0, 0, 1); + Installation right = new Installation("b", "East", InstallationKind.PORT, 2, 0, 0, 1); + return TradeGraphBuilder.build( + List.of(new Site("home", left, true), new Site("home", right, true)), + Map.of( + 1, + new ProvinceData(Terrain.PLAINS, Set.of(9)), + 9, + new ProvinceData(Terrain.SEA, Set.of(1, 2)), + 2, + new ProvinceData(Terrain.PLAINS, Set.of(9))), + (from, to) -> Optional.of(new RailRoutes.Route(0, List.of())), + point -> 0); + } + + private ProvinceManager provinces(int... ids) { + Map<Integer, Province> data = new LinkedHashMap<>(); + for (int id : ids) data.put(id, new Province(id, "PLAINS", 50)); + ProvinceManager provinces = new ProvinceManager(); + provinces.start(data); + when(fixture.ui.plugin.getProvinceManager()).thenReturn(provinces); + fixture.provincesEnabled(true); + return provinces; + } + + private TrackRegistry registry(TrackSpline... tracks) { + Path directory = temporary.resolve("registry-" + registries.size()); + TrackStore store = new TrackStore(directory.toFile()); + for (TrackSpline track : tracks) store.save(track); + store.close(); + TrackRegistry registry = new TrackRegistry(directory.toFile()); + registry.loadFromDisk(); + registries.add(registry); + vehicles.when(VehicleFramework::getTrackRegistry).thenReturn(registry); + return registry; + } + + private ProvinceGrid grid() throws Exception { + ByteBuffer buffer = ByteBuffer.allocate(8 + 201 * 2).order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(201).putInt(1); + for (int x = 0; x <= 200; x++) buffer.putShort((short) (x / 100 + 1)); + Path path = temporary.resolve("grid.bin.gz"); + try (GZIPOutputStream stream = new GZIPOutputStream(Files.newOutputStream(path))) { + stream.write(buffer.array()); + } + return ProvinceGrid.load(path.toFile()); + } + + private void configureInstallations() throws Exception { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("consent-proximity-blocks", 20); + yaml.set("transfer-request-timeout-seconds", 60); + for (InstallationKind kind : InstallationKind.values()) { + String key = kind.getCommandName(); + yaml.set(key + ".daily-upkeep", 1); + yaml.set(key + ".construction-time", 1); + yaml.set(key + ".radius", 2); + yaml.createSection(key + ".slots"); + } + Path path = temporary.resolve("installations.yml"); + yaml.save(path.toFile()); + InstallationConfigLoader.load(path.toFile()); + } + + private void remember(Class<?> owner, String name) throws Exception { + Field field = owner.getDeclaredField(name); + field.setAccessible(true); + saved.put(field, field.get(null)); + } + + @SuppressWarnings("unchecked") + private Map<Object, Object> kindMap() throws Exception { + Field field = InstallationConfigLoader.class.getDeclaredField("byKind"); + field.setAccessible(true); + return (Map<Object, Object>) field.get(null); + } + + private static Installation station(String id, int province, int x) { + return new Installation(id, id, InstallationKind.TRAIN_STATION, province, x, 0, 1); + } + + private static TradeGraph.Node node(String id, InstallationKind kind, int province, int x) { + return new TradeGraph.Node("home", id, id, kind, province, x, 0, 1, 0); + } + + private static void assertDistances(Map<Integer, Double> expected, Map<Integer, Double> actual) { + assertEquals(expected.keySet(), actual.keySet()); + expected.forEach((province, distance) -> assertEquals(distance, actual.get(province), 1e-9)); + } + + private static TrackSpline spline(double... xs) { + java.util.ArrayList<TrackSample> samples = new java.util.ArrayList<>(); + for (double x : xs) samples.add(new TrackSample(x, 0, 0, 0, 0, x)); + return new TrackSpline(UUID.randomUUID(), "world", false, samples, null); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/hub/OpenTrackReachTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/hub/OpenTrackReachTest.java index 598179e9..5350d0a6 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/hub/OpenTrackReachTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/hub/OpenTrackReachTest.java @@ -47,8 +47,9 @@ void reachFollowsJunctionsSkipsBreaksAndSamplesProvincesBetweenPoints() { Map<Integer, Double> reached = VehicleFrameworkTracks.reach( List.of(stem, branch), List.of(junction), 0, 0, 10, 2500, (x, z) -> province(x, z), 1); - assertEquals(100, reached.get(2), 1e-6); - assertEquals(200, reached.get(3), 1e-6); + // Boarding begins where the intact track enters the station's ten-block radius. + assertEquals(90, reached.get(2), 1e-6); + assertEquals(190, reached.get(3), 1e-6); assertTrue(reached.get(4) > 100 && reached.get(4) <= 200); assertFalse(reached.containsKey(1)); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewLifecycleCoverageTest.java new file mode 100644 index 00000000..241b23f9 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/IncomePreviewLifecycleCoverageTest.java @@ -0,0 +1,493 @@ +package net.tfminecraft.simplefactions.guild.income; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.database.GuildData; +import net.tfminecraft.simplefactions.enums.GuildModifier; +import net.tfminecraft.simplefactions.enums.Terrain; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.GuildModifierOverride; +import net.tfminecraft.simplefactions.guild.branch.Branch; +import net.tfminecraft.simplefactions.guild.income.entry.PlayerEntry; +import net.tfminecraft.simplefactions.managers.ProvinceManager; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class IncomePreviewLifecycleCoverageTest { + @AfterEach + void clearPreviewState() { + IncomePreviewContext.clear(); + GuildModifierOverride.clear(); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void aTradePreviewChangesTheCounterpartyExactlyWhenTheRealTreatyWould(boolean mutual) { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + var home = fixture.saved("home", "Leader"); + var other = fixture.saved("other", "OtherLeader"); + var previous = fixture.relationType("old", Map.of("name", "Old treaty")); + var counterpart = fixture.relationType("counterpart", Map.of("name", "Counterpart")); + var proposed = + fixture.relationType( + "proposed", + mutual + ? Map.of("mutual", true) + : Map.of("mutual", false, "link", counterpart.getId())); + other.getDiplomacyHandler().setTradeRelation(home, previous); + IncomePreviewContext.open(IncomePreviewContext.trade(home, other, proposed)); + try { + assertSame(proposed, home.getDiplomacyHandler().getTradeRelation(other.getId())); + assertSame( + mutual ? proposed : previous, + other.getDiplomacyHandler().getTradeRelation(home.getId())); + } finally { + IncomePreviewContext.clear(); + } + assertNull(home.getDiplomacyHandler().getTradeRelation(other.getId())); + assertSame(previous, other.getDiplomacyHandler().getTradeRelation(home.getId())); + } + } + + @Test + void frozenPreviewModifierWeightsDoNotFollowLaterCallerMapEdits() { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + var faction = fixture.saved("home", "Leader"); + var guild = fixture.guild(faction, "merchants", "Merchant"); + double live = guild.getModifier(GuildModifier.PRODUCTION); + EnumMap<GuildModifier, Double> captured = new EnumMap<>(GuildModifier.class); + captured.put(GuildModifier.PRODUCTION, 12.5); + GuildModifierOverride.use(guild, captured); + try { + captured.put(GuildModifier.PRODUCTION, 999.0); + assertEquals(12.5, GuildModifierOverride.resolve(guild, GuildModifier.PRODUCTION)); + assertEquals(live, guild.getModifier(GuildModifier.PRODUCTION)); + } finally { + GuildModifierOverride.clear(); + } + assertEquals(live, GuildModifierOverride.resolve(guild, GuildModifier.PRODUCTION)); + } + } + + @ParameterizedTest + @CsvSource({ + "false,false,true,true,false", + "true,false,true,false,false", + "false,true,true,false,true", + "false,false,false,false,true", + "false,true,false,false,false", + "true,false,false,true,false" + }) + void favourPreviewsRespectMutualExclusionAndNeverToggleTheLiveGuild( + boolean favoured, + boolean repressed, + boolean favourMode, + boolean expectedFavour, + boolean expectedRepress) { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + var home = fixture.saved("home", "Leader"); + var guild = fixture.guild(home, "merchant", "Merchant"); + var other = fixture.guild(home, "other", "Other"); + guild.setFavoured(favoured); + guild.setRepressed(repressed); + IncomePreviewContext.open(IncomePreviewContext.favour(guild, favourMode)); + assertEquals(expectedFavour, guild.isFavoured()); + assertEquals(expectedRepress, guild.isRepressed()); + assertNull(IncomePreviewContext.overlayFavoured(other)); + assertNull(IncomePreviewContext.overlayRepressed(other)); + assertFalse(other.isFavoured()); + assertFalse(other.isRepressed()); + IncomePreviewContext.clear(); + assertEquals(favoured, guild.isFavoured()); + assertEquals(repressed, guild.isRepressed()); + } + } + + @Test + void scratchTradeBooksArePerGuildAndDiscardedWithoutTouchingLiveIncome() { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + var home = fixture.saved("home", "Leader"); + var first = fixture.guild(home, "first", "First"); + var second = fixture.guild(home, "second", "Second"); + TradeBreakdown original = first.getTradeBreakdown(); + original.setIncome(100); + original.registerIncome(home, 100); + IncomePreviewContext.open(IncomePreviewContext.scratch()); + assertNull(IncomePreviewContext.scratchBreakdown(null)); + TradeBreakdown scratch = first.getTradeBreakdown(); + assertNotSame(original, scratch); + scratch.setIncome(25); + scratch.registerIncome(home, 25); + assertSame(scratch, first.getTradeBreakdown()); + assertEquals(0.0, second.getTradeBreakdown().getIncome()); + assertEquals(100.0, original.getIncome()); + IncomePreviewContext.clear(); + assertSame(original, first.getTradeBreakdown()); + assertEquals(100.0, first.getTradeBreakdown().getIncomeByFaction(home)); + IncomePreviewContext.open(IncomePreviewContext.tax(home, TaxTarget.GUILDS, null, 20)); + assertSame( + original, + first.getTradeBreakdown(), + "Tax-only previews must retain the actual trade book"); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void endingATradeAgreementPreviewsOnlyTheSidesThatWouldActuallyBeRemoved(boolean mutual) { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + var home = fixture.saved("home", "Leader"); + var other = fixture.saved("other", "Other"); + var treaty = fixture.relationType("trade", Map.of("mutual", mutual)); + var retained = fixture.relationType("independent", Map.of("name", "Independent")); + home.getDiplomacyHandler().setTradeRelation(other, treaty); + other.getDiplomacyHandler().setTradeRelation(home, retained); + IncomePreviewContext.open(IncomePreviewContext.trade(home, other, null)); + assertFalse(home.getDiplomacyHandler().hasTradeRelation(other.getId())); + assertEquals(!mutual, other.getDiplomacyHandler().hasTradeRelation(home.getId())); + assertNull(IncomePreviewContext.overlayTrade(home.getDiplomacyHandler(), "unrelated")); + assertFalse(IncomePreviewContext.overridesTrade(null, "other")); + assertFalse(IncomePreviewContext.overridesTrade(home.getDiplomacyHandler(), null)); + IncomePreviewContext.clear(); + assertSame(treaty, home.getDiplomacyHandler().getTradeRelation(other.getId())); + assertSame(retained, other.getDiplomacyHandler().getTradeRelation(home.getId())); + assertNull(IncomePreviewContext.overlayTrade(home.getDiplomacyHandler(), other.getId())); + } + } + + @Test + void linkedMutualTradePreviewsTheConfiguredCounterpartWithoutChangingStoredRelations() { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + var home = fixture.saved("home", "Leader"); + var other = fixture.saved("other", "Other"); + var counterpart = + fixture.relationType("receiving", Map.of("mutual", true, "link", "granting")); + var agreement = fixture.relationType("granting", Map.of("mutual", true, "link", "receiving")); + IncomePreviewContext.open(IncomePreviewContext.trade(home, other, agreement)); + assertSame(agreement, home.getDiplomacyHandler().getTradeRelation("other")); + assertSame(counterpart, other.getDiplomacyHandler().getTradeRelation("home")); + assertTrue(home.getDiplomacyHandler().getTradeRelations().isEmpty()); + assertTrue(other.getDiplomacyHandler().getTradeRelations().isEmpty()); + } + } + + @ParameterizedTest + @CsvSource({"GUILDS,GUILD_ID", "VASSALS,VASSAL_ID", "TARIFFS,TARIFF_ID"}) + void genericAndSpecificTaxPreviewsRespectOverridesAndOnlyAffectTheirFaction( + TaxTarget category, TaxTarget specific) { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + var home = fixture.saved("home", "Leader"); + var other = fixture.saved("other", "Other"); + var taxes = home.getTaxHandler(); + taxes.setTaxRate(category, null, 20); + taxes.setTaxRate(specific, "vip", 5); + IncomePreviewContext context = IncomePreviewContext.tax(home, category, null, 35); + IncomePreviewContext.open(context); + assertTrue(context.affects(home)); + assertFalse(context.affects(other)); + assertFalse(context.affects(null)); + assertFalse(context.previewsLaw(home)); + assertEquals(35.0, taxes.getTaxRate(category, "ordinary", false)); + assertEquals(35.0, taxes.getTaxRate(specific, "ordinary", false)); + assertEquals(5.0, taxes.getTaxRate(category, "vip", false)); + assertEquals(5.0, taxes.getTaxRate(specific, "vip", false)); + assertEquals( + other.getTaxHandler().getTaxRate(category, null, false), + other.getTaxHandler().getTaxRate(category, "ordinary", false)); + assertEquals(5.0, taxes.getTaxRate(TaxTarget.CITIZENS, null, false)); + IncomePreviewContext.open(IncomePreviewContext.tax(home, specific, "vip", 11)); + assertEquals(11.0, taxes.getTaxRate(category, "vip", false)); + assertEquals(11.0, taxes.getTaxRate(specific, "VIP", false)); + assertEquals(20.0, taxes.getTaxRate(category, "ordinary", false)); + assertEquals(20.0, taxes.getTaxRate(category, null, false)); + IncomePreviewContext.clear(); + assertEquals(20.0, taxes.getTaxRate(category, "ordinary", false)); + assertEquals(5.0, taxes.getTaxRate(category, "vip", false)); + } + } + + @ParameterizedTest + @CsvSource({ + "CITIZENS,citizen_tax", + "GUILDS,guild_tax", + "GUILD_ID,guild_tax", + "VASSALS,vassal_tax", + "VASSAL_ID,vassal_tax", + "DIVIDENDS,dividend_tax", + "TARIFFS,tariffs", + "TARIFF_ID,tariffs" + }) + void proposedLawLimitsAndVetoesAreTemporaryAndApplyToEveryTaxAlias(TaxTarget target, String key) { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + fixture.lawGroup("taxes", Map.of()); + var home = fixture.saved("home", "Leader"); + var other = fixture.saved("other", "Other"); + var group = home.getLawHandler().getGroup("taxes"); + var original = group.getCurrent(); + var candidate = + fixture.law("taxes", "capped", Map.of("effects.faction.brackets." + key, "10-20")); + var context = IncomePreviewContext.law(home, group, candidate); + IncomePreviewContext.open(context); + assertTrue(context.affects(home)); + assertTrue(context.previewsLaw(home)); + assertFalse(context.previewsLaw(other)); + assertSame(candidate, group.getCurrent()); + assertNull(IncomePreviewContext.overlayLaw(other.getLawHandler().getGroup("taxes"))); + assertEquals(10.0, context.adjustTax(home, home.getTaxHandler(), target, "specific", 5)); + assertEquals(20.0, context.adjustTax(home, home.getTaxHandler(), target, "specific", 30)); + assertEquals(15.0, context.adjustTax(home, home.getTaxHandler(), target, "specific", 15)); + assertEquals(30.0, context.adjustTax(other, other.getTaxHandler(), target, "specific", 30)); + assertEquals(30.0, context.adjustTax(null, null, target, null, 30)); + IncomePreviewContext.clear(); + assertSame(original, group.getCurrent()); + var forbidden = + fixture.law( + "taxes", "forbidden", Map.of("effects.faction.rules", List.of(key + " false"))); + IncomePreviewContext.open(IncomePreviewContext.law(home, group, forbidden)); + assertEquals(0.0, home.getTaxHandler().getTaxRate(target, "specific", false)); + IncomePreviewContext.clear(); + assertTrue(home.getTaxHandler().getTaxRate(target, "specific", false) > 0); + var irrelevant = + fixture.law( + "taxes", "foreign_only", Map.of("effects.vassals.rules", List.of(key + " false"))); + IncomePreviewContext.open(IncomePreviewContext.law(home, group, irrelevant)); + assertEquals( + 30.0, + IncomePreviewContext.current().adjustTax(home, home.getTaxHandler(), target, null, 30)); + } + } + + @Test + void independentThreadsCannotSeeOrClearAnotherThreadsTaxPreview() throws Exception { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + var home = fixture.saved("home", "Leader"); + var original = IncomePreviewContext.tax(home, TaxTarget.GUILDS, null, 35); + IncomePreviewContext.open(original); + var worker = Executors.newSingleThreadExecutor(); + try { + assertEquals( + 10.0, + worker + .submit( + () -> { + assertNull(IncomePreviewContext.current()); + IncomePreviewContext.open( + IncomePreviewContext.tax(home, TaxTarget.GUILDS, null, 60)); + try { + assertEquals( + 60.0, home.getTaxHandler().getTaxRate(TaxTarget.GUILDS, null, false)); + } finally { + IncomePreviewContext.clear(); + } + return home.getTaxHandler().getTaxRate(TaxTarget.GUILDS, null, false); + }) + .get(5, TimeUnit.SECONDS)); + assertSame(original, IncomePreviewContext.current()); + assertEquals(35.0, home.getTaxHandler().getTaxRate(TaxTarget.GUILDS, null, false)); + } finally { + worker.shutdownNow(); + } + IncomePreviewContext.clear(); + assertEquals(10.0, home.getTaxHandler().getTaxRate(TaxTarget.GUILDS, null, false)); + } + } + + @Test + void tradeBreakdownsAccumulateByCounterpartyAndClearAllTotalsTogether() { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + var first = fixture.saved("first", "First"); + var second = fixture.saved("second", "Second"); + TradeBreakdown book = new TradeBreakdown(); + book.setIncome(123.456); + book.setUpkeep(12.345); + book.setTariffs(1.234); + book.setTradePower(9.876); + book.registerIncome(first, 20.125); + book.registerIncome(first, 10.0); + book.registerIncome(second, 50.0); + book.registerTariffs(first, 2.125); + book.registerTariffs(first, 1.0); + assertEquals(30.13, book.getIncomeByFaction(first)); + assertEquals(3.13, book.getTariffsByFaction(first)); + assertEquals(List.of(second, first), book.getFactionsByIncomeDesc()); + assertEquals(109.88, book.getNetTradeIncome()); + assertEquals(9.88, book.getTradePower()); + assertEquals(12.35, book.getUpkeep()); + assertEquals(1.23, book.getTariffs()); + assertEquals(2, book.getIncomes().size()); + assertEquals(1, book.getTariffsByFactionMap().size()); + book.clear(); + assertEquals(0.0, book.getIncome()); + assertEquals(0.0, book.getNetTradeIncome()); + assertEquals(0.0, book.getTradePower()); + assertTrue(book.getIncomes().isEmpty()); + assertTrue(book.getTariffsByFactionMap().isEmpty()); + } + } + + @Test + void playerTaxEntriesCanBeAdjustedWithoutChangingTheirOrigin() { + PlayerEntry entry = new PlayerEntry("Alice", 12.5); + assertEquals("Alice", entry.getOrigin()); + assertEquals(12.5, entry.getAmount()); + entry.setAmount(8.25); + assertEquals("Alice", entry.getOrigin()); + assertEquals(8.25, entry.getAmount()); + } + + @Test + void anAbsentTaxTargetLeavesAllStoredRatesAndLedgersUnchanged() { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + var home = fixture.saved("home", "Leader"); + var guild = fixture.guild(home, "merchant", "Merchant"); + guild.getLedger().setCitizenTaxes(Map.of("Merchant", 100.0)); + double original = guild.getLedger().getNetIncome(); + Map<?, Double> changes = EconomicPreview.tax(home, null, null, 75); + assertEquals(0.0, changes.get(guild)); + assertEquals(original, guild.getLedger().getNetIncome()); + assertNull(IncomePreviewContext.current()); + assertEquals(10.0, home.getTaxHandler().getTaxRate(TaxTarget.GUILDS, null, false)); + } + } + + @Test + void aSavedGuildWithoutAnAttachedRealmHasNoTaxUntilItsHostIsRestored() { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + GuildData stored = + new com.google.gson.Gson() + .fromJson( + """ + {"id":"merchants","name":"Merchants","leader":"Merchant", + "type":"guild","rgb":"4,5,6","capital":-1,"banner":["white"]} + """, + GuildData.class); + Guild detached = new Guild(stored, null); + assertNull(detached.getFaction()); + assertEquals(0.0, BranchIncomePreview.taxFraction(detached)); + var home = fixture.saved("home", "Leader"); + detached.setHost(home); + assertEquals(0.1, BranchIncomePreview.taxFraction(detached)); + home.getTaxHandler().setTaxRate(TaxTarget.GUILD_ID, "merchants", 25); + assertEquals(0.25, BranchIncomePreview.taxFraction(detached)); + } + } + + @Test + void aPreparedBranchPreviewUsesFrozenProvinceDataAndNeverMutatesItsGuildOrBank() { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + Double previousCarry = Cache.tradeCarry.get(Terrain.PLAINS); + try { + fixture.provincesEnabled(true); + Cache.tradeCarry.put(Terrain.PLAINS, 0.8); + Province capital = new Province(91001, "PLAINS", 40, 0, 0); + Province neighbor = new Province(91002, "PLAINS", 40, 16, 0); + capital.addNeighbour(neighbor.getId()); + neighbor.addNeighbour(capital.getId()); + capital.setProsperity(12.5); + fixture.provinceData.put(capital.getId(), capital); + fixture.provinceData.put(neighbor.getId(), neighbor); + var data = fixture.data("home", "Leader"); + data.provinces.add(capital.getId()); + data.provinces.add(neighbor.getId()); + var home = fixture.saved(data); + // A real guild's trade is suppressed if its realm has no capacity to support it. + var realm = home.getOrCreateMainGuild(); + realm.getBranches().put(0, new Branch(realm.getBranches().get(0), 10)); + var guild = fixture.guild(home, "fields", "Farmer"); + guild.setCapital(capital.getId()); + YamlConfiguration config = new YamlConfiguration(); + config.set("name", "Fields"); + config.set("group", 1); + config.set( + "modifiers", + List.of( + "PRODUCTION 2 2", "TRADE_POWER 8 0", "TRADE_CARRY 1.2 0", "TRADE_UPKEEP 0.05 0")); + Branch branch = new Branch(new Branch("fields", config), 2); + guild.getBranches().clear(); + guild.getBranches().put(1, branch); + guild.getBank().deposit(250.0); + TradeBreakdown original = guild.getTradeBreakdown(); + original.setIncome(80); + ProvinceManager live = new ProvinceManager(); + live.start(Map.of(capital.getId(), capital, neighbor.getId(), neighbor)); + BranchIncomePreview.Prepared prepared = BranchIncomePreview.prepare(live); + Map<GuildModifier, Double> current = BranchIncomePreview.modifiers(guild); + Map<GuildModifier, Double> raised = BranchIncomePreview.adjust(current, branch, 2, 1); + Map<GuildModifier, Double> lowered = BranchIncomePreview.adjust(current, branch, 2, -10); + assertEquals(8.0, raised.get(GuildModifier.PRODUCTION)); + assertEquals(2.0, lowered.get(GuildModifier.PRODUCTION)); + double beforeTax = BranchIncomePreview.estimate(prepared, guild, current, raised, 0); + double afterTax = BranchIncomePreview.estimate(prepared, guild, branch, 1); + assertTrue(beforeTax > 0); + double effectiveTax = home.getTaxRate(TaxTarget.GUILDS, guild.getId(), true) / 100.0; + assertTrue(effectiveTax > 0 && effectiveTax < 1); + assertTrue(afterTax < beforeTax); + assertEquals(Math.round(beforeTax * (1 - effectiveTax) * 100.0) / 100.0, afterTax, 0.02); + assertTrue(BranchIncomePreview.estimate(live, guild, branch, -1) < 0); + neighbor.setProsperity(1000); + live.start(Map.of()); + assertEquals(0.0, BranchIncomePreview.estimate(live, guild, branch, 1)); + assertEquals(afterTax, BranchIncomePreview.estimate(prepared, guild, branch, 1)); + assertEquals(2, branch.getLevel()); + assertEquals(250.0, guild.getBank().getWealth()); + assertSame(original, guild.getTradeBreakdown()); + assertEquals(80.0, original.getIncome()); + assertTrue(capital.getAllData().isEmpty()); + assertTrue(neighbor.getAllData().isEmpty()); + assertEquals(6.0, GuildModifierOverride.resolve(guild, GuildModifier.PRODUCTION)); + } finally { + if (previousCarry == null) Cache.tradeCarry.remove(Terrain.PLAINS); + else Cache.tradeCarry.put(Terrain.PLAINS, previousCarry); + } + } + } + + @Test + void cancelledDonationsNotifyOnlyTheCurrentOnlineLeader() { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + var home = fixture.saved("home", "Leader"); + var leader = fixture.player("Merchant"); + var guild = fixture.guild(home, "merchant", "Merchant"); + assertEquals(10.05, GuildDonation.fee(100.5)); + assertEquals(110.55, GuildDonation.cost(100.5)); + GuildDonation.notifyCancelled(guild, 100.5); + verify(leader) + .sendMessage( + argThat( + (String message) -> + message.contains("Daily donation") && message.contains("cancelled"))); + clearInvocations(leader); + GuildDonation.notifyCancelled(null, 100); + GuildDonation.notifyCancelled(guild, 0); + fixture.online.remove("Merchant"); + GuildDonation.notifyCancelled(guild, 100); + guild.setLeader(null); + GuildDonation.notifyCancelled(guild, 100); + guild.setLeader(" "); + GuildDonation.notifyCancelled(guild, 100); + guild.setLeader("Merchant"); + when(Bukkit.getServer()).thenReturn(null); + GuildDonation.notifyCancelled(guild, 100); + verify(leader, never()).sendMessage(anyString()); + for (double invalid : List.of(0.0, -1.0, Double.NaN, Double.POSITIVE_INFINITY)) { + assertEquals(0.0, GuildDonation.fee(invalid)); + assertEquals(0.0, GuildDonation.cost(invalid)); + } + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerSettlementCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerSettlementCoverageTest.java new file mode 100644 index 00000000..49120910 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerSettlementCoverageTest.java @@ -0,0 +1,250 @@ +package net.tfminecraft.simplefactions.guild.income; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.diplomacy.*; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.loans.Loan; +import net.tfminecraft.simplefactions.guild.upgrade.Upgrade; +import net.tfminecraft.simplefactions.loaders.*; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.mercenary.contract.MercenaryEngagements; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.utils.*; +import net.tfminecraft.simplefactions.war.resolution.WarReparationsObligation; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.*; + +class LedgerSettlementCoverageTest { + private FactionDomainFixture fixture; + private Faction home; + private Guild capital, guild; + private boolean previousEligibility; + private PostSettlementPayouts.PlayerUuidLookup previousLookup; + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + fixture.lawGroup( + "tax", + Map.of( + "effects.faction.rules", + List.of( + "CITIZEN_TAX true", + "GUILD_TAX true", + "VASSAL_TAX true", + "DIVIDEND_TAX true", + "TARIFFS true"))); + YamlConfiguration u = new YamlConfiguration(); + u.set("u.upkeep", 3); + u.set("u.allowed-types", List.of("guild")); + UpgradeLoader.map.put("workshop", new Upgrade("workshop", u.getConfigurationSection("u"))); + home = fixture.saved("home", "Alice"); + capital = home.getOrCreateMainGuild(); + guild = fixture.guild(home, "traders", "Bob"); + previousEligibility = Cache.dividendRequirePreviousTickMembership; + Cache.dividendRequirePreviousTickMembership = false; + previousLookup = MercenaryEngagements.uuidLookup(); + capital.getBank().setWealth(1000.0); + guild.getBank().setWealth(1000.0); + } + + @AfterEach + void close() { + Cache.dividendRequirePreviousTickMembership = previousEligibility; + MercenaryEngagements.setUuidLookup(previousLookup); + fixture.close(); + } + + @Test + void independentDailyEntriesAccumulateAndSettlementClearsThemExactlyOnce() { + Ledger ledger = capital.getLedger(); + Map<String, Double> saved = new LinkedHashMap<>(); + saved.put(null, 2.0); + saved.put("", 2.0); + saved.put("null", null); + saved.put("negative", -2.0); + saved.put("zero", 0.0); + saved.put("Alice", 2.0); + ledger.setCitizenTaxes(saved); + ledger.addCitizenTaxEntry("Alice", 3.0); + ledger.addCitizenTaxEntry("Bob", 9.0); + assertEquals(Map.of("Alice", 5.0, "Bob", 9.0), ledger.getCitizenTaxesCopy()); + assertEquals( + List.of("Bob", "Alice"), + ledger.getCitizenTaxEntriesDescending().stream().map(Map.Entry::getKey).toList()); + ledger.addLoanPaymentEntry("borrower", 10.0); + ledger.addLoanPaymentEntry("borrower", 2.0); + ledger.addInterestPaymentEntry("borrower", 3.0); + ledger.addInterestPaymentEntry("borrower", 4.0); + ledger.addMercenaryPaymentEntry("hirer", 5.0); + ledger.addMercenaryPaymentEntry("hirer", 2.0); + ledger.addRefundEntry("hirer", 4.0); + ledger.addRefundEntry("hirer", 3.0); + ledger.addCasinoProfitEntry(null); + ledger.addCasinoProfitEntry(-1.0); + ledger.addCasinoProfitEntry(20.0); + ledger.addCasinoProfitEntry(5.0); + assertEquals(12, ledger.getIncome(Cashflow.LOANS)); + assertEquals(7, ledger.getIncome(Cashflow.INTEREST)); + assertEquals(14, ledger.getIncome(Cashflow.CITIZENS)); + assertEquals(25, ledger.getIncome(Cashflow.GAMBLING)); + assertEquals(-7, ledger.getIncome(Cashflow.MERCENARY_PAYMENTS)); + assertEquals(7, ledger.getIncome(Cashflow.REFUNDS)); + var detached = ledger.getCitizenTaxesCopy(); + detached.clear(); + assertEquals(14, ledger.getIncome(Cashflow.CITIZENS)); + DailyGuildTransfers first = new DailyGuildTransfers(); + ledger.populateDailyTransfers(first); + // Citizen receipts plus loan principal and interest already paid outside guild transfers. + assertEquals(33, first.getExternalDeltas().get(capital)); + assertEquals(0, ledger.getCasinoProfit()); + assertTrue(ledger.getCitizenTaxesCopy().isEmpty()); + for (Cashflow kind : + List.of(Cashflow.LOANS, Cashflow.INTEREST, Cashflow.MERCENARY_PAYMENTS, Cashflow.REFUNDS)) + assertEquals(0, ledger.getIncome(kind)); + DailyGuildTransfers second = new DailyGuildTransfers(); + ledger.populateDailyTransfers(second); + assertTrue(second.getExternalDeltas().isEmpty()); + ledger.addCitizenTaxEntry("Alice", 3.0); + ledger.clearDailyIncome(); + assertTrue(ledger.getCitizenTaxesCopy().isEmpty()); + } + + @Test + void vassalTaxAndGuildTaxHistoryMatchActualBufferedTransfers() { + Faction subject = fixture.saved("subject", "Cara"); + fixture.subject(home, subject); + Guild subjectCapital = subject.getOrCreateMainGuild(); + subjectCapital.getTradeBreakdown().setIncome(100); + Guild merchants = fixture.guild(subject, "merchants", "Dan"); + merchants.getTradeBreakdown().setIncome(80); + assertEquals(0.75, subject.getGovernment().getTaxEfficiency()); + assertEquals(6, merchants.getLedger().getIncome(Cashflow.GUILD_PAYMENTS) * -1); + assertEquals(21.2, subjectCapital.getLedger().getOverlordTax(), 0.001); + assertEquals(21.2, capital.getLedger().getIncome(Cashflow.VASSALS), 0.001); + var history = Ledger.collectHistoryDay(List.of(subjectCapital, merchants)); + assertEquals( + 21.2, history.get(capital).get(LedgerHistory.Source.VASSALS).get(subject.getName()), 0.001); + assertEquals( + 6, + history.get(subjectCapital).get(LedgerHistory.Source.GUILD_TAXES).get(merchants.getName()), + 0.001); + DailyGuildTransfers transfers = new DailyGuildTransfers(); + subjectCapital.getLedger().populateDailyTransfers(transfers); + merchants.getLedger().populateDailyTransfers(transfers); + assertEquals(21.2, transfers.getTransfers().get(subjectCapital).get(capital), 0.001); + assertEquals(6, transfers.getTransfers().get(merchants).get(subjectCapital), 0.001); + assertEquals(0, capital.getLedger().getOverlordTax()); + } + + @Test + void bankruptPayersAndGuildsWithoutTradeDoNotPayReparations() { + Faction creditor = fixture.saved("creditor", "Cara"); + Guild receiver = creditor.getOrCreateMainGuild(); + home.addWarReparationsObligation(new WarReparationsObligation("creditor", 25, 4)); + guild.getTradeBreakdown().setIncome(120); + guild.getBank().setWealth(-1.0); + assertTrue(guild.isBankrupt()); + assertEquals(0, receiver.getLedger().getWarReparationsReceived()); + DailyGuildTransfers transfers = new DailyGuildTransfers(); + capital.getLedger().populateDailyTransfers(transfers); + guild.getLedger().populateDailyTransfers(transfers); + assertTrue(transfers.getTransfers().isEmpty()); + assertEquals(0, receiver.getBank().getWealth()); + } + + @Test + void tributeTariffsAndReparationsUseCurrentRecipientsAndPreserveConservation() { + Faction creditor = fixture.saved("creditor", "Cara"); + Guild creditorCapital = creditor.getOrCreateMainGuild(); + YamlConfiguration y = new YamlConfiguration(); + y.set("r.recieve-modifiers", List.of("TRIBUTE(10)", "PRESTIGE(2)")); + home.getDiplomacyHandler() + .setRelation( + creditor, + new Relation( + new RelationType("tribute", y.getConfigurationSection("r")), + RelationLoader.getDefaultAttitude())); + capital.getTradeBreakdown().setIncome(200); + capital.getTradeBreakdown().registerTariffs(creditor, 4); + home.getWarReparationsObligations().add(new WarReparationsObligation(creditor.getId(), 25, 10)); + home.getWarReparationsObligations().add(new WarReparationsObligation("missing", 0, 10)); + assertEquals(20, capital.getLedger().getTributeTax(), 0.001); + assertEquals(20, creditorCapital.getLedger().getTributeRecieved(), 0.001); + assertEquals(50, creditorCapital.getLedger().getWarReparationsReceived(), 0.001); + assertEquals(0, guild.getLedger().getWarReparationsReceived()); + assertEquals(200, capital.getLedger().getReparationsTaxableIncome(), 0.001); + var history = Ledger.collectHistoryDay(List.of(capital)); + assertEquals( + 20, + history.get(creditorCapital).get(LedgerHistory.Source.TRIBUTES).get(home.getName()), + 0.001); + assertEquals( + 4, + history.get(creditorCapital).get(LedgerHistory.Source.TARIFFS).get(home.getName()), + 0.001); + DailyGuildTransfers transfers = new DailyGuildTransfers(); + capital.getLedger().populateDailyTransfers(transfers); + assertEquals(74, transfers.getTransfers().get(capital).get(creditorCapital), 0.001); + assertEquals(1000, capital.getBank().getWealth()); + } + + @Test + void dividendsReserveAPoolOnlyForEligibleMembersAndUpkeepUsesUpgradeLevels() { + guild.getTradeBreakdown().setIncome(100); + guild.setDividendPercent(50); + assertEquals(DividendBreakdown.none(), capital.getLedger().getDividendBreakdown()); + for (Upgrade upgrade : guild.getUpgrades()) upgrade.setLevel(2); + assertEquals(-6, guild.getLedger().getIncome(Cashflow.UPGRADES_UPKEEP)); + var breakdown = guild.getLedger().getDividendBreakdown(); + assertTrue(breakdown.pool() > 0); + assertEquals(1, breakdown.eligibleCount()); + DailyGuildTransfers transfers = new DailyGuildTransfers(); + guild.getLedger().populateDailyTransfers(transfers); + assertEquals(breakdown.pool(), transfers.getPendingDividendPools().get(guild)); + guild.getMembers().clear(); + var empty = guild.getLedger().breakdownForPool(10); + assertEquals(0, empty.eligibleCount()); + assertEquals(0, empty.payout()); + } + + @Test + void bankruptGuildKeepsCitizenTaxAndStopsAutomaticLoanPayments() { + Loan debt = new Loan(700, capital, guild, System.currentTimeMillis(), 7, 7, 0, true); + Loan manual = new Loan(20, capital, guild, System.currentTimeMillis(), 7, 7, 0, false); + capital.getLoanHandler().issueLoan(debt); + capital.getLoanHandler().issueLoan(manual); + guild.getBank().setWealth(-1.0); + guild.getLedger().addCitizenTaxEntry("Bob", 3.0); + assertEquals(0, guild.getLedger().getInternalTaxableIncome()); + assertEquals(DividendBreakdown.none(), guild.getLedger().getDividendBreakdown()); + DailyGuildTransfers transfers = new DailyGuildTransfers(); + guild.getLedger().populateDailyTransfers(transfers); + assertFalse(debt.isAutoPay()); + assertFalse(manual.isAutoPay()); + assertTrue(transfers.getTransfers().isEmpty()); + assertTrue(transfers.getExternalDeltas().isEmpty()); + assertEquals(Map.of("Bob", 3.0), guild.getLedger().getCitizenTaxesCopy()); + } + + @Test + void realCompanyWagesReachOnlyResolvablePlayersAndClearAfterSettlement() { + var regiment = fixture.regiment("guards", false, 2, 1); + MercenaryCompany company = new MercenaryCompany(guild, "Guards", regiment, 0); + guild.setCompany(company); + assertTrue(company.isFormed()); + company.accrueWage("Bob", 12); + company.accrueWage("Missing", 8); + UUID bob = UUID.randomUUID(); + MercenaryEngagements.setUuidLookup(name -> name.equals("Bob") ? bob : null); + DailyGuildTransfers transfers = new DailyGuildTransfers(); + guild.getLedger().populateDailyTransfers(transfers); + assertEquals(Map.of(bob, 12.0), transfers.getPlayerPayouts().get(guild)); + assertTrue(company.getPendingWages().isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/loans/LoanBookTermsTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/loans/LoanBookTermsTest.java index 0150c33e..3229cea6 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/loans/LoanBookTermsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/loans/LoanBookTermsTest.java @@ -58,4 +58,27 @@ void aLoanWithoutAnIdGetsARandomOne() { assertNotNull(loan.getId()); } + + @Test + void malformedNumericTermsCannotSilentlyBecomeAnInterestFreeOrFeeFreeAgreement() { + assertNull(LoanBook.createLoanFromString(page("invalid", "30", "6", "2"), null, null)); + assertNull(LoanBook.createLoanFromString(page("1000", "invalid", "6", "2"), null, null)); + assertNull(LoanBook.createLoanFromString(page("1000", "30", "invalid", "2"), null, null)); + assertNull(LoanBook.createLoanFromString(page("1000", "30", "6", "invalid"), null, null)); + assertNull(LoanBook.createLoanFromString(page("1000", "30", "", "2"), null, null)); + } + + + @Test + void aBookWithoutItsTermsPageCannotIssueALoan() { + try (var fixture = new net.tfminecraft.simplefactions.testsupport.FactionDomainFixture()) { + var guild = fixture.saved("issuer", "Alice").getOrCreateMainGuild(); + var item = new org.bukkit.inventory.ItemStack(org.bukkit.Material.WRITABLE_BOOK); + var meta = (org.bukkit.inventory.meta.BookMeta) item.getItemMeta(); + meta.addPage("Instructions only"); + meta.getPersistentDataContainer().set(net.tfminecraft.simplefactions.keys.Keys.STRING_KEY, org.bukkit.persistence.PersistentDataType.STRING, guild.getId()); + assertNull(LoanBook.createLoanFromBook(meta, null)); + } + } + } diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/network/TradeGraphCommandTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/network/TradeGraphCommandTest.java index e92eb23f..ec8ec57b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/network/TradeGraphCommandTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/network/TradeGraphCommandTest.java @@ -110,4 +110,28 @@ private static TradeGraph sample() { new Site("beta", new Installation("b", "b", InstallationKind.PORT, 4, 300, 400, 1L), true)), provinces, (from, to) -> Optional.empty(), point -> 0); } + + @Test + void multipleConnectionsAreSortedAndFractionalDistancesRemainReadable() { + Map<Integer, ProvinceData> provinces = Map.of( + 1, new ProvinceData(Terrain.PLAINS, java.util.Set.of(2)), + 2, new ProvinceData(Terrain.SEA, java.util.Set.of(1, 3)), + 3, new ProvinceData(Terrain.SEA, java.util.Set.of(2, 4)), + 4, new ProvinceData(Terrain.PLAINS, java.util.Set.of(3))); + TradeGraph.setLiveForTests(TradeGraphBuilder.build(List.of( + new Site("alpha", new Installation("a", "a", InstallationKind.PORT, 1, 0, 0, 1L), true), + new Site("gamma", new Installation("c", "c", InstallationKind.PORT, 4, 400, 0, 1L), true), + new Site("beta", new Installation("b", "b", InstallationKind.PORT, 4, 300, 401, 1L), true)), + provinces, (from, to) -> Optional.empty(), point -> 0)); + CommandSender sender = admin(); + assertTrue(TradeGraphCommand.handle(sender, new String[] {"tradegraph"})); + ArgumentCaptor<String> messages = ArgumentCaptor.forClass(String.class); + verify(sender, org.mockito.Mockito.atLeastOnce()).sendMessage(messages.capture()); + List<String> lines = messages.getAllValues(); + int b = lines.indexOf("§7 b, sea, 500.8 blocks, 2 provinces"); + int c = lines.indexOf("§7 c, sea, 400 blocks, 2 provinces"); + assertTrue(b >= 0, lines.toString()); + assertTrue(c > b, lines.toString()); + } + } diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/network/TradeNetworkRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/network/TradeNetworkRuntimeCoverageTest.java new file mode 100644 index 00000000..9a706526 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/network/TradeNetworkRuntimeCoverageTest.java @@ -0,0 +1,395 @@ +package net.tfminecraft.simplefactions.guild.network; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.zip.GZIPOutputStream; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.database.InstallationConstructionData; +import net.tfminecraft.simplefactions.enums.Region; +import net.tfminecraft.simplefactions.enums.Scope; +import net.tfminecraft.simplefactions.guild.hub.HubTransport.Mode; +import net.tfminecraft.simplefactions.guild.hub.OpenTrackSettings; +import net.tfminecraft.simplefactions.installation.Installation; +import net.tfminecraft.simplefactions.installation.InstallationKind; +import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.ProvinceManager; +import net.tfminecraft.simplefactions.map.ProvinceGrid; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.tracks.TrackRegistry; +import net.tfminecraft.vehicleframework.tracks.TrackSample; +import net.tfminecraft.vehicleframework.tracks.TrackSpline; +import net.tfminecraft.vehicleframework.tracks.TrackStore; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +class TradeNetworkRuntimeCoverageTest { + @TempDir Path temporary; + private FactionDomainFixture fixture; + private MockedStatic<VehicleFramework> vehicles; + private ProvinceManager provinces; + private final Map<Field, Object> saved = new LinkedHashMap<>(); + private Map<Object, Object> installationKinds; + private Map<Object, Object> routeCache; + private Map<Object, Object> savedRoutes; + private TradeGraph previousLive; + private TrackRegistry registry; + private TrackSpline track; + + @BeforeEach + void setUp() throws Exception { + fixture = new FactionDomainFixture(); + vehicles = mockStatic(VehicleFramework.class); + previousLive = TradeGraph.live(); + for (String field : List.of("enabled", "share", "keptPer1000", "rangeBlocks")) + remember(OpenTrackSettings.class, field); + remember(InstallationConfigLoader.class, "consentProximityBlocks"); + remember(InstallationConfigLoader.class, "transferRequestTimeoutSeconds"); + installationKinds = new LinkedHashMap<>(kindMap()); + routeCache = routeCache(); + savedRoutes = new LinkedHashMap<>(routeCache); + TradeGraph.forgetRoutes(); + OpenTrackSettings.reset(); + configureInstallations(); + when(Bukkit.getPluginManager().isPluginEnabled("VehicleFramework")).thenReturn(true); + when(Bukkit.isPrimaryThread()).thenReturn(true); + Map<Integer, Province> data = new LinkedHashMap<>(); + data.put(0, new Province()); + for (int id = 1; id <= 5; id++) data.put(id, new Province(id, "PLAINS", 50, (id - 1) * 100, 0)); + provinces = new ProvinceManager(); + provinces.start(data); + when(fixture.ui.plugin.getProvinceManager()).thenReturn(provinces); + when(fixture.ui.plugin.getProvinceGrid()).thenReturn(grid()); + track = + new TrackSpline( + UUID.randomUUID(), + "world", + false, + List.of( + new TrackSample(0, 0, 0, 0, 0, 0), + new TrackSample(100, 0, 0, 0, 0, 100), + new TrackSample(200, 0, 0, 0, 0, 200)), + null); + TrackStore store = new TrackStore(temporary.resolve("tracks").toFile()); + store.save(track); + store.close(); + registry = new TrackRegistry(temporary.resolve("tracks").toFile()); + registry.loadFromDisk(); + vehicles.when(VehicleFramework::getTrackRegistry).thenReturn(registry); + } + + @AfterEach + void close() throws Exception { + registry.close(); + TradeGraph.setLiveForTests(previousLive); + routeCache.clear(); + routeCache.putAll(savedRoutes); + kindMap().clear(); + kindMap().putAll(installationKinds); + for (var entry : saved.entrySet()) entry.getKey().set(null, entry.getValue()); + vehicles.close(); + fixture.close(); + } + + @Test + void refreshReadsFinishedInstallationsRealTrackRoutesAndProvinceGrid() { + Faction home = stations(); + home.getInstallationHandler() + .acceptTransferred(new Installation("fort", "Keep", InstallationKind.FORT, 2, 100, 0, 2)); + InstallationConstructionData pending = new InstallationConstructionData(); + pending.id = "pending"; + pending.name = "Future station"; + pending.kind = InstallationKind.TRAIN_STATION.getCommandName(); + pending.province = 5; + pending.centerX = 400; + pending.centerZ = 0; + pending.timeLeft = 60; + pending.startedAt = 1L; + home.getInstallationHandler().loadConstruction(pending); + FactionManager.factions.add(null); + + TradeGraph.refresh(provinces); + + TradeGraph graph = TradeGraph.live(); + assertEquals(2, graph.nodes().size()); + assertNull(graph.node("home", "fort")); + assertNull(graph.node("home", "pending")); + assertNotNull(home.getInstallationHandler().getPendingConstruction()); + assertEquals(1, graph.edges().size()); + TradeGraph.Edge edge = graph.edges().getFirst(); + assertEquals(Mode.RAIL, edge.mode()); + assertEquals(List.of(2), edge.provinces()); + assertTrue(edge.length() > 190 && edge.length() <= 200); + assertTrue(graph.openTrack("HOME", "WEST").containsKey(2)); + assertTrue(graph.openTrack("home", "west").containsKey(3)); + assertFalse(graph.openTrack("home", "west").containsKey(1)); + assertEquals(2, graph.networkOf(graph.node("home", "west")).size()); + assertFalse(graph.networkOf(graph.node("home", "west")).global()); + } + + @Test + void routeCacheRetainsSuccessfulMeasurementsUntilExplicitTrackRefresh() { + stations(); + TradeGraph.refresh(provinces); + TradeGraph before = TradeGraph.live(); + assertEquals(1, before.edges().size()); + assertTrue(registry.delete(track.getId())); + TradeGraph.refresh(provinces); + assertEquals(before.edges(), TradeGraph.live().edges()); + assertTrue(TradeGraph.live().openTrack("home", "west").isEmpty()); + TradeGraph.forgetRoutes(); + TradeGraph.refresh(provinces); + assertEquals(2, TradeGraph.live().nodes().size()); + assertTrue(TradeGraph.live().edges().isEmpty()); + assertTrue( + before.openTrack("home", "west").containsKey(2), "The old immutable snapshot stays usable"); + } + + @Test + void liveRefreshRequiresTheActualManagerAndPrimaryThread() { + stations(); + TradeGraph.refresh(provinces); + TradeGraph before = TradeGraph.live(); + TradeGraph.refreshIfLive(new ProvinceManager()); + assertSame(before, TradeGraph.live()); + when(Bukkit.isPrimaryThread()) + .thenReturn(false) + .thenThrow(new IllegalStateException("server unavailable")) + .thenReturn(true); + TradeGraph.refreshIfLive(provinces); + assertSame(before, TradeGraph.live()); + TradeGraph.refreshIfLive(provinces); + assertSame(before, TradeGraph.live()); + TradeGraph.refreshIfLive(provinces); + assertNotSame(before, TradeGraph.live()); + assertEquals(before.edges(), TradeGraph.live().edges()); + before = TradeGraph.live(); + SimpleFactions.plugin = null; + TradeGraph.refreshIfLive(provinces); + assertSame(before, TradeGraph.live()); + SimpleFactions.plugin = fixture.ui.plugin; + } + + @Test + void unavailableVehiclePluginLeavesIndependentNodesInsteadOfFailingRefresh() { + stations(); + when(Bukkit.getPluginManager().isPluginEnabled("VehicleFramework")).thenReturn(false); + TradeGraph.refresh(provinces); + assertIsolatedStations(); + TradeGraph.forgetRoutes(); + when(Bukkit.getPluginManager().isPluginEnabled("VehicleFramework")) + .thenThrow(new NoClassDefFoundError("older installation")); + TradeGraph.refresh(provinces); + assertIsolatedStations(); + TradeGraph.forgetRoutes(); + var plugins = Bukkit.getPluginManager(); + doReturn(true).when(plugins).isPluginEnabled("VehicleFramework"); + vehicles + .when(VehicleFramework::getTrackRegistry) + .thenThrow(new IllegalStateException("stopping")); + TradeGraph.refresh(provinces); + assertIsolatedStations(); + } + + @Test + void missingStationConfigurationUsesTheDocumentedOpenTrackRadiusFallback() throws Exception { + Faction home = fixture.saved("home", "Alice"); + home.getInstallationHandler() + .acceptTransferred( + new Installation("west", "West", InstallationKind.TRAIN_STATION, 1, 40, 0, 1)); + kindMap().clear(); + TradeGraph.refresh(provinces); + assertEquals(1, TradeGraph.live().nodes().size()); + assertTrue(TradeGraph.live().openTrack("home", "west").containsKey(3)); + assertFalse(TradeGraph.live().openTrack("home", "west").containsKey(1)); + } + + @Test + void disabledOpenTracksNullWorldAndAbsentGridHaveExplicitEmptyReach() { + stations(); + YamlConfiguration config = new YamlConfiguration(); + config.set("installation-trade.open-track.enabled", false); + OpenTrackSettings.load(config); + TradeGraph.refresh(provinces); + assertEquals(1, TradeGraph.live().edges().size()); + assertTrue(TradeGraph.live().openTrack("home", "west").isEmpty()); + OpenTrackSettings.reset(); + Cache.worldName = null; + TradeGraph.forgetRoutes(); + TradeGraph.refresh(provinces); + assertIsolatedStations(); + Cache.worldName = "world"; + when(fixture.ui.plugin.getProvinceGrid()).thenReturn(null); + TradeGraph.forgetRoutes(); + TradeGraph.refresh(provinces); + assertEquals(1, TradeGraph.live().edges().size()); + assertTrue(TradeGraph.live().edges().getFirst().provinces().isEmpty()); + assertTrue(TradeGraph.live().openTrack("home", "west").isEmpty()); + SimpleFactions.plugin = null; + TradeGraph.refresh(provinces); + assertEquals(2, TradeGraph.live().nodes().size()); + assertTrue(TradeGraph.live().openTrack("home", "west").isEmpty()); + SimpleFactions.plugin = fixture.ui.plugin; + } + + @Test + void anUninitializedFactionRegistryProducesAnEmptyImmutableSnapshot() { + FactionManager.factions = null; + TradeGraph.refresh(provinces); + assertTrue(TradeGraph.live().nodes().isEmpty()); + assertTrue(TradeGraph.live().edges().isEmpty()); + assertThrows(UnsupportedOperationException.class, () -> TradeGraph.live().nodes().clear()); + } + + @Test + void publicSnapshotsFilterBadDistancesMergeDuplicateStationsAndRejectForeignEdgeNodes() { + stations(); + TradeGraph.refresh(provinces); + TradeGraph before = TradeGraph.live(); + TradeGraph graph = + before.withOpenTracks( + Arrays.asList( + null, + new TradeGraph.OpenTrack(null, "west", Map.of(7, 1.0)), + new TradeGraph.OpenTrack("home", null, Map.of(7, 1.0)), + new TradeGraph.OpenTrack("home", "west", null), + new TradeGraph.OpenTrack("HOME", "WEST", Map.of(2, 10.0, 3, Double.NaN, 4, -2.0)), + new TradeGraph.OpenTrack("home", "west", Map.of(2, 5.0, 5, 20.0)))); + assertEquals(Map.of(2, 5.0, 5, 20.0), graph.openTrack("home", "west")); + assertTrue(graph.openTrack(null, "west").isEmpty()); + assertTrue(graph.openTrack("home", null).isEmpty()); + assertTrue(graph.withOpenTracks(null).openTrack("home", "west").isEmpty()); + assertThrows( + UnsupportedOperationException.class, () -> graph.openTrack("home", "west").put(6, 2.0)); + assertNotEquals(before.openTrack("home", "west"), graph.openTrack("home", "west")); + TradeGraph.Node outsider = + new TradeGraph.Node("elsewhere", "other", "Other", InstallationKind.PORT, 5, 0, 0, 1, 0); + TradeGraph.Edge edge = graph.edges().getFirst(); + assertThrows(IllegalArgumentException.class, () -> edge.other(outsider)); + assertThrows(IllegalArgumentException.class, () -> edge.provincesFrom(outsider)); + assertNull(graph.node(null, "west")); + assertNull(graph.node("home", null)); + assertNull(graph.networkOf(outsider)); + assertEquals(Set.of(1, 3), Set.of(edge.first().provinceId(), edge.second().provinceId())); + } + + @Test + void openTrackConfigurationFallsBackForNonFiniteSharesAndDisablesInvalidRange() { + YamlConfiguration config = new YamlConfiguration(); + config.set("installation-trade.open-track.share", Double.NaN); + config.set("installation-trade.open-track.kept-per-1000-blocks", -1); + config.set("installation-trade.open-track.range-blocks", Double.POSITIVE_INFINITY); + OpenTrackSettings.load(config); + assertEquals(OpenTrackSettings.DEFAULT_SHARE, OpenTrackSettings.share()); + assertEquals(OpenTrackSettings.DEFAULT_KEPT, OpenTrackSettings.keptPer1000()); + assertEquals(0, OpenTrackSettings.rangeBlocks()); + stations(); + TradeGraph.refresh(provinces); + assertTrue(TradeGraph.live().openTrack("home", "west").isEmpty()); + config.set("installation-trade.open-track.share", -1); + config.set("installation-trade.open-track.kept-per-1000-blocks", Double.NaN); + OpenTrackSettings.load(config); + assertEquals(OpenTrackSettings.DEFAULT_SHARE, OpenTrackSettings.share()); + assertEquals(OpenTrackSettings.DEFAULT_KEPT, OpenTrackSettings.keptPer1000()); + } + + @Test + void missingRealmAndUnrelatedLawScopesCannotInventInstallationAccess() { + assertNull(InstallationAccess.topRealm(null)); + var law = fixture.lawGroup("economy", Map.of()).getLaw("current"); + assertNotNull(law); + assertEquals(0, InstallationAccess.amount(law, Scope.DOMESTIC_GUILDS, Region.OUR_TERRITORY)); + Faction home = fixture.saved("home", "Alice"); + Faction foreign = fixture.saved("foreign", "Bob"); + assertEquals(0, InstallationAccess.of(home, foreign)); + assertEquals(1, InstallationAccess.of(home, home)); + } + + private Faction stations() { + Faction home = fixture.saved("home", "Alice"); + home.getInstallationHandler() + .acceptTransferred( + new Installation("west", "West", InstallationKind.TRAIN_STATION, 1, 0, 0, 1)); + home.getInstallationHandler() + .acceptTransferred( + new Installation("east", "East", InstallationKind.TRAIN_STATION, 3, 200, 0, 1)); + return home; + } + + private void assertIsolatedStations() { + assertEquals(2, TradeGraph.live().nodes().size()); + assertTrue(TradeGraph.live().edges().isEmpty()); + assertTrue(TradeGraph.live().openTrack("home", "west").isEmpty()); + } + + private ProvinceGrid grid() throws Exception { + ByteBuffer buffer = ByteBuffer.allocate(8 + 201 * 2).order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(201).putInt(1); + for (int x = 0; x <= 200; x++) buffer.putShort((short) (x / 100 + 1)); + Path path = temporary.resolve("grid.bin.gz"); + try (GZIPOutputStream stream = new GZIPOutputStream(Files.newOutputStream(path))) { + stream.write(buffer.array()); + } + return ProvinceGrid.load(path.toFile()); + } + + private void configureInstallations() throws Exception { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("consent-proximity-blocks", 20); + yaml.set("transfer-request-timeout-seconds", 60); + for (InstallationKind kind : InstallationKind.values()) { + String key = kind.getCommandName(); + yaml.set(key + ".daily-upkeep", 1); + yaml.set(key + ".construction-time", 1); + yaml.set(key + ".radius", 2); + yaml.createSection(key + ".slots"); + } + Path path = temporary.resolve("installations.yml"); + yaml.save(path.toFile()); + InstallationConfigLoader.load(path.toFile()); + } + + private void remember(Class<?> owner, String name) throws Exception { + Field field = owner.getDeclaredField(name); + field.setAccessible(true); + saved.put(field, field.get(null)); + } + + @SuppressWarnings("unchecked") + private Map<Object, Object> kindMap() throws Exception { + Field field = InstallationConfigLoader.class.getDeclaredField("byKind"); + field.setAccessible(true); + return (Map<Object, Object>) field.get(null); + } + + @SuppressWarnings("unchecked") + private Map<Object, Object> routeCache() throws Exception { + Field field = LiveTradeGraph.class.getDeclaredField("routes"); + field.setAccessible(true); + Object cache = field.get(null); + field = RailRouteCache.class.getDeclaredField("routes"); + field.setAccessible(true); + return (Map<Object, Object>) field.get(cache); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityCoverageTest.java new file mode 100644 index 00000000..96e3645b --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/inactivity/InactivityCoverageTest.java @@ -0,0 +1,499 @@ +package net.tfminecraft.simplefactions.inactivity; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.database.FactionData; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.PersistenceFilesFixture; +import net.tfminecraft.simplefactions.utils.FactionCleanup; +import net.tfminecraft.simplefactions.war.core.War; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class InactivityCoverageTest { + private PersistenceFilesFixture disk; + private FactionDomainFixture fixture; + private Field historyField; + private Object originalHistory; + + @BeforeEach + void setup() throws Exception { + disk = new PersistenceFilesFixture(); + Files.createDirectories(disk.root.resolve("Data")); + fixture = new FactionDomainFixture(); + when(fixture.ui.plugin.getLogger()).thenReturn(mock(Logger.class)); + historyField = FactionCleanup.class.getDeclaredField("offlineDays"); + historyField.setAccessible(true); + originalHistory = historyField.get(null); + historyField.set(null, null); + } + + @AfterEach + void cleanup() throws Exception { + try { + if (historyField != null) historyField.set(null, originalHistory); + if (fixture != null) fixture.close(); + } finally { + if (disk != null) disk.close(); + } + } + + private void state(String guild, int output, String faction, int prestige, long due) + throws Exception { + InactivityService.SavedState state = new InactivityService.SavedState(); + InactivityService.SavedClock guildClock = new InactivityService.SavedClock(); + guildClock.percent = output; + guildClock.nextAt = due; + state.guilds.put(guild, guildClock); + InactivityService.SavedClock factionClock = new InactivityService.SavedClock(); + factionClock.percent = prestige; + factionClock.nextAt = due; + state.factions.put(faction, factionClock); + disk.write("Cache/inactivity.json", JsonUtil.GSON.toJson(state)); + } + + private InactivityService.SavedState saved() throws Exception { + return JsonUtil.readJson( + disk.root.resolve("Cache/inactivity.json").toFile(), InactivityService.SavedState.class); + } + + private void history(Map<String, Integer> days) throws Exception { + disk.write("Cache/logins.json", JsonUtil.GSON.toJson(days)); + historyField.set(null, null); + } + + @Test + void failedReloadPreservesUsableClocksAndDoesNotOverwriteCorruptHistory() throws Exception { + Faction faction = fixture.saved("home", "Alice"); + Guild guild = faction.getOrCreateMainGuild(); + state(guild.getId(), 40, faction.getId(), 25, 12345); + InactivityService.load(); + assertEquals(40, InactivityService.guildPercent(guild)); + assertEquals(25, InactivityService.prestigePercent(faction)); + String corrupt = "{\"guilds\": {\"" + guild.getId() + "\": broken}"; + disk.write("Cache/inactivity.json", corrupt); + + assertDoesNotThrow(InactivityService::load); + + assertEquals(40, InactivityService.guildPercent(guild)); + assertEquals(25, InactivityService.prestigePercent(faction)); + InactivityService.save(); + assertEquals(corrupt, Files.readString(disk.root.resolve("Cache/inactivity.json"))); + } + + @Test + void coldStartupWithCorruptHistoryDoesNotDestroyItDuringShutdownSave() throws Exception { + String corrupt = "{\"factions\": {\"old_realm\":{\"percent\":75}, unfinished}"; + disk.write("Cache/inactivity.json", corrupt); + assertDoesNotThrow(InactivityService::load); + InactivityService.save(); + assertEquals(corrupt, Files.readString(disk.root.resolve("Cache/inactivity.json"))); + } + + @Test + void unreadableHistoryDoesNotClearTheLastSuccessfullyLoadedPenalties() throws Exception { + Faction faction = fixture.saved("home", "Alice"); + Guild guild = faction.getOrCreateMainGuild(); + state(guild.getId(), 40, faction.getId(), 25, 12345); + InactivityService.load(); + disk.remove("Cache/inactivity.json"); + Files.createDirectory(disk.root.resolve("Cache/inactivity.json")); + InactivityService.load(); + assertEquals(40, InactivityService.guildPercent(guild)); + assertEquals(25, InactivityService.prestigePercent(faction)); + assertTrue(Files.isDirectory(disk.root.resolve("Cache/inactivity.json"))); + } + + @Test + void repairedHistoryCanBeReloadedAndSavedAfterAnEarlierReadFailure() throws Exception { + Faction faction = fixture.saved("home", "Alice"); + Guild guild = faction.getOrCreateMainGuild(); + disk.write("Cache/inactivity.json", "broken {"); + assertDoesNotThrow(InactivityService::load); + state(guild.getId(), 12, faction.getId(), 18, 54321); + InactivityService.load(); + assertEquals(12, InactivityService.guildPercent(guild)); + assertEquals(.88, InactivityService.outputFactor(guild)); + assertEquals(18, InactivityService.prestigePercent(faction)); + InactivityService.save(); + assertEquals(54321, saved().guilds.get(guild.getId()).nextAt); + assertEquals(18, saved().factions.get(faction.getId()).percent); + } + + @Test + void missingNullAndPartialSavedStatesUseDefaultsAndClampPersistedPenalties() throws Exception { + Faction faction = fixture.saved("home", "Alice"); + Guild guild = faction.getOrCreateMainGuild(); + Field loaded = InactivityService.class.getDeclaredField("loaded"); + loaded.setAccessible(true); + loaded.setBoolean(null, false); + assertEquals(0, InactivityService.guildPercent(guild)); + assertEquals(0, InactivityService.prestigePercent(faction)); + assertEquals(1, InactivityService.outputFactor(null)); + assertEquals(0, InactivityService.guildPercent((Guild) null)); + assertEquals(0, InactivityService.prestigePercent(null)); + assertFalse(Files.exists(disk.root.resolve("Cache/inactivity.json"))); + InactivityService.save(); + assertTrue(saved().guilds.isEmpty()); + assertTrue(saved().factions.isEmpty()); + disk.write("Cache/inactivity.json", "null"); + InactivityService.load(); + assertEquals(0, InactivityService.guildPercent(guild)); + disk.write("Cache/inactivity.json", "{\"guilds\":null,\"factions\":null}"); + InactivityService.load(); + assertEquals(0, InactivityService.prestigePercent(faction)); + state(guild.getId(), 180, faction.getId(), -5, -10); + InactivityService.load(); + assertEquals(100, InactivityService.guildPercent(guild)); + assertEquals(0, InactivityService.outputFactor(guild)); + assertEquals(0, InactivityService.prestigePercent(faction)); + InactivityService.save(); + assertEquals(0, saved().guilds.get(guild.getId()).nextAt); + assertTrue(saved().factions.isEmpty()); + disk.write( + "Cache/inactivity.json", "{\"guilds\":{\"removed\":null,\"empty\":{}},\"factions\":{}}"); + InactivityService.load(); + InactivityService.save(); + assertTrue(saved().guilds.isEmpty()); + } + + @Test + void inactivityMembershipSkipsPlaceholdersAndUsesCurrentOnlinePresence() throws Exception { + history(Map.of("alice", 21, "bob", 20, "cara", 35)); + assertFalse(InactivityService.isMemberInactive(null)); + assertFalse(InactivityService.isMemberInactive(" ")); + assertFalse(InactivityService.isMemberInactive("DUMMY_guard")); + assertTrue(InactivityService.isMemberInactive("ALICE")); + assertFalse(InactivityService.isMemberInactive("Bob")); + assertFalse(InactivityService.isFullyInactive(null)); + assertFalse(InactivityService.isFullyInactive(List.of("dummy_guard"))); + assertFalse(InactivityService.isFullyInactive(List.of("Alice", "Bob"))); + assertTrue( + InactivityService.isFullyInactive(Arrays.asList(null, "dummy_guard", "Alice", "Cara"))); + Player online = fixture.player("aLiCe"); + when(Bukkit.getOnlinePlayers()).thenAnswer(call -> Arrays.asList(null, online)); + assertFalse(InactivityService.isMemberInactive("ALICE")); + when(Bukkit.getServer()).thenReturn(null); + assertTrue(InactivityService.isMemberInactive("Alice")); + when(Bukkit.getServer()).thenReturn(fixture.ui.server); + when(Bukkit.getOnlinePlayers()).thenThrow(new IllegalStateException("server stopping")); + assertTrue(InactivityService.isMemberInactive("Alice")); + } + + @Test + void armingAndDueTicksApplyOneStepWithoutKickingMembersOrChangingTheirBanks() throws Exception { + Faction faction = fixture.saved("home", "Alice"); + Guild base = faction.getOrCreateMainGuild(); + Guild guild = fixture.guild(faction, "merchants", "Bob"); + base.getBank().deposit(37.0); + guild.getBank().deposit(19.0); + history(Map.of("alice", 21, "bob", 22)); + InactivityService.armAll(null, 1000); + InactivityService.armAll(Arrays.asList(null, faction), 1000); + assertEquals( + 1000 + InactivityRules.DECAY_INTERVAL_MILLIS, saved().factions.get(faction.getId()).nextAt); + assertEquals( + 1000 + InactivityRules.DECAY_INTERVAL_MILLIS, saved().guilds.get(guild.getId()).nextAt); + assertEquals(0, InactivityService.guildPercent(base)); + String armed = Files.readString(disk.root.resolve("Cache/inactivity.json")); + InactivityService.armAll(List.of(faction), 2000); + assertEquals(armed, Files.readString(disk.root.resolve("Cache/inactivity.json"))); + InactivityService.tickIfDue(List.of(faction)); + assertEquals(1, InactivityService.prestigePercent(faction)); + assertEquals(1, InactivityService.guildPercent(base)); + assertEquals(1, InactivityService.guildPercent(guild)); + assertEquals(.99, InactivityService.outputFactor(guild)); + assertEquals(List.of("Alice", "Bob"), faction.getMembers()); + assertEquals(37, base.getBank().getWealth()); + assertEquals(19, guild.getBank().getWealth()); + verify(fixture.provinces).recalculate(); + InactivityService.tickIfDue(List.of(faction)); + assertEquals(1, InactivityService.guildPercent(guild)); + verify(fixture.provinces, times(1)).recalculate(); + } + + @Test + void dueSweepArmsUntrackedInactiveGroupsAndSkipsGroupsStillWaiting() throws Exception { + Faction armed = fixture.saved("armed", "Alice"); + Faction fresh = fixture.saved("fresh", "Bob"); + history(Map.of("alice", 21, "bob", 22)); + state(armed.getOrCreateMainGuild().getId(), 0, armed.getId(), 0, 1); + InactivityService.load(); + InactivityService.tickIfDue(null); + InactivityService.tickIfDue(Arrays.asList(null, armed, fresh)); + assertEquals(1, InactivityService.prestigePercent(armed)); + assertEquals(0, InactivityService.prestigePercent(fresh)); + assertTrue(saved().factions.get(fresh.getId()).nextAt > System.currentTimeMillis()); + assertEquals(0, InactivityService.guildPercent(fresh.getOrCreateMainGuild())); + // A separately due faction triggers the sweep while the fresh clocks remain unchanged. + var snapshot = saved(); + snapshot.factions.get(armed.getId()).nextAt = 1; + disk.write("Cache/inactivity.json", JsonUtil.GSON.toJson(snapshot)); + InactivityService.load(); + InactivityService.tickIfDue(List.of(armed, fresh)); + assertEquals(2, InactivityService.prestigePercent(armed)); + assertEquals(0, InactivityService.guildPercent(fresh.getOrCreateMainGuild())); + } + + @Test + void activityReconciliationClearsBothPenaltiesAndRefreshesDerivedOutput() throws Exception { + Faction faction = fixture.saved("home", "Alice"); + Guild guild = faction.getOrCreateMainGuild(); + history(Map.of("alice", 20)); + state(guild.getId(), 30, faction.getId(), 40, 1); + InactivityService.load(); + InactivityService.tickIfDue(List.of(faction)); + assertEquals(0, InactivityService.guildPercent(guild)); + assertEquals(0, InactivityService.prestigePercent(faction)); + assertTrue(saved().guilds.isEmpty()); + assertTrue(saved().factions.isEmpty()); + verify(fixture.provinces).recalculate(); + state(guild.getId(), 30, faction.getId(), 40, 100000); + InactivityService.load(); + InactivityService.armAll(List.of(faction), 50); + assertEquals(0, InactivityService.guildPercent(guild)); + assertTrue(saved().factions.isEmpty()); + InactivityService.armAll(List.of(faction), 60); + assertTrue(saved().factions.isEmpty()); + } + + @Test + void loginClearsOnlyTheReturningMembersGuildAndItsFactionAndPersistsActivity() throws Exception { + Faction faction = fixture.saved("home", "Alice"); + Guild guild = fixture.guild(faction, "merchants", "Bob"); + Guild base = faction.getOrCreateMainGuild(); + history(Map.of("alice", 22, "bob", 30, "stranger", 21, "cara", 20)); + state(guild.getId(), 35, faction.getId(), 40, 1); + var snapshot = saved(); + InactivityService.SavedClock baseClock = new InactivityService.SavedClock(); + baseClock.percent = 45; + baseClock.nextAt = 1; + snapshot.guilds.put(base.getId(), baseClock); + disk.write("Cache/inactivity.json", JsonUtil.GSON.toJson(snapshot)); + InactivityService.load(); + Player bob = fixture.player("Bob"); + InactivityService.onLogin(bob); + assertEquals(0, FactionCleanup.daysOffline("bob")); + assertEquals(0, InactivityService.guildPercent(guild)); + assertEquals(0, InactivityService.prestigePercent(faction)); + assertEquals(45, InactivityService.guildPercent(base)); + verify(bob) + .sendMessage( + argThat( + (String message) -> + message.contains("output penalty cleared") + && message.contains("prestige penalty cleared"))); + verify(fixture.provinces).recalculate(); + assertFalse(saved().guilds.containsKey(guild.getId())); + InactivityService.onLogin(null); + Player active = fixture.player("Cara"); + InactivityService.onLogin(active); + verify(active, never()).sendMessage(anyString()); + Player stranger = fixture.player("Stranger"); + InactivityService.onLogin(stranger); + verify(stranger).sendMessage("§aYou are active again."); + history(Map.of("bob", 21)); + InactivityService.onLogin(bob); + verify(bob).sendMessage("§aYou are active again."); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void provinceDecayPreservesTheCapitalUntilLastAndSavesTheNewClaims(boolean capitalOnly) + throws Exception { + int previousCost = Cache.provinceCost; + try { + Cache.provinceCost = 100000; + Faction faction = fixture.saved("home", "Alice"); + fixture.provinceData.put(1, new Province(1, "PLAINS", 1)); + fixture.provinceData.put(2, new Province(2, "PLAINS", 1)); + faction.addProvince(1); + if (!capitalOnly) faction.addProvince(2); + faction.setCapital(1, false, false); + Guild guild = fixture.guild(faction, "merchants", "Bob"); + if (!capitalOnly) guild.setCapital(2, false); + history(Map.of("alice", 21, "bob", 21)); + state(guild.getId(), 0, faction.getId(), 0, 1); + InactivityService.load(); + InactivityService.tickIfDue(List.of(faction)); + assertEquals(capitalOnly ? List.of() : List.of(1), faction.getProvinces()); + assertEquals(capitalOnly ? -1 : 1, faction.getCapital()); + assertEquals(-1, guild.getCapital()); + assertTrue(Files.isRegularFile(disk.root.resolve("Data/home.json"))); + FactionData persisted = + JsonUtil.readJson(disk.root.resolve("Data/home.json").toFile(), FactionData.class); + assertEquals( + faction.getProvinces(), persisted.provinces.stream().map(Number::intValue).toList()); + verify(fixture.map, atLeastOnce()).enqueue("nation", faction.getRGB()); + } finally { + Cache.provinceCost = previousCost; + } + } + + @Test + void affordableAndCivilWarLockedProvincesAreRetainedDuringPenaltyTicks() throws Exception { + int previousCost = Cache.provinceCost; + try { + Cache.provinceCost = 0; + Faction faction = fixture.saved("home", "Alice"); + fixture.provinceData.put(1, new Province(1, "PLAINS", 1)); + faction.addProvince(1); + history(Map.of("alice", 21)); + state(faction.getOrCreateMainGuild().getId(), 0, faction.getId(), 0, 1); + InactivityService.load(); + InactivityService.tickIfDue(List.of(faction)); + assertEquals(List.of(1), faction.getProvinces()); + Cache.provinceCost = 100000; + War civil = new War(13, faction, fixture.saved("enemy", "Bob")); + civil.setMovementId("rebellion"); + WarManager.get().add(civil); + state(faction.getOrCreateMainGuild().getId(), 1, faction.getId(), 1, 1); + InactivityService.load(); + InactivityService.tickIfDue(List.of(faction)); + assertEquals(List.of(1), faction.getProvinces()); + assertEquals(2, InactivityService.prestigePercent(faction)); + } finally { + Cache.provinceCost = previousCost; + } + } + + @Test + void persistenceAndEconomyFailuresAreLoggedWithoutEscapingTheLifecycle() throws Exception { + Faction faction = fixture.saved("home", "Alice"); + Guild guild = faction.getOrCreateMainGuild(); + history(Map.of("alice", 21)); + state(guild.getId(), 10, faction.getId(), 20, 1); + InactivityService.load(); + doThrow(new IllegalStateException("province refresh unavailable")) + .when(fixture.provinces) + .recalculate(); + assertDoesNotThrow(() -> InactivityService.tickIfDue(List.of(faction))); + assertEquals(11, InactivityService.guildPercent(guild)); + verify(fixture.ui.plugin.getLogger()) + .log(eq(Level.WARNING), contains("Could not refresh guild output"), any(Throwable.class)); + disk.remove("Cache/inactivity.json"); + Files.createDirectory(disk.root.resolve("Cache/inactivity.json")); + assertDoesNotThrow(InactivityService::save); + assertTrue(Files.isDirectory(disk.root.resolve("Cache/inactivity.json"))); + verify(fixture.ui.plugin.getLogger()) + .log(eq(Level.WARNING), contains("Could not save inactivity timers"), any(Throwable.class)); + SimpleFactions previous = SimpleFactions.plugin; + SimpleFactions.plugin = null; + try { + assertDoesNotThrow(InactivityService::save); + assertDoesNotThrow(InactivityService::load); + } finally { + SimpleFactions.plugin = previous; + } + } + + @ParameterizedTest + @ValueSource(strings = {"", "null"}) + void emptyOrNullSavedHistoryIsPreservedUntilAValidExplicitReload(String contents) + throws Exception { + Faction faction = fixture.saved("home", "Alice"); + Guild guild = faction.getOrCreateMainGuild(); + state(guild.getId(), 30, faction.getId(), 40, 1); + InactivityService.load(); + disk.write("Cache/inactivity.json", contents); + InactivityService.load(); + InactivityService.save(); + assertEquals(contents, Files.readString(disk.root.resolve("Cache/inactivity.json"))); + assertEquals(30, InactivityService.guildPercent(guild)); + assertEquals(40, InactivityService.prestigePercent(faction)); + state(guild.getId(), 4, faction.getId(), 7, 100); + InactivityService.load(); + InactivityService.save(); + assertEquals(4, saved().guilds.get(guild.getId()).percent); + assertEquals(7, saved().factions.get(faction.getId()).percent); + } + + @Test + void failedAtomicSaveKeepsTheCompletePreviousHistoryAndCanBeRetried() throws Exception { + Faction faction = fixture.saved("home", "Alice"); + Guild guild = faction.getOrCreateMainGuild(); + history(Map.of("alice", 21)); + state(guild.getId(), 10, faction.getId(), 20, 1); + InactivityService.load(); + var file = disk.root.resolve("Cache/inactivity.json"); + String original = Files.readString(file); + Assumptions.assumeTrue(Files.getFileAttributeView(file, PosixFileAttributeView.class) != null); + Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(file); + try { + Files.setPosixFilePermissions(file, Set.of(PosixFilePermission.OWNER_READ)); + Assumptions.assumeFalse(Files.isWritable(file), "User bypasses POSIX write permissions"); + InactivityService.tickIfDue(List.of(faction)); + assertEquals(original, Files.readString(file)); + assertEquals(11, InactivityService.guildPercent(guild)); + assertEquals(21, InactivityService.prestigePercent(faction)); + try (var cache = Files.list(file.getParent())) { + assertFalse(cache.anyMatch(path -> path.getFileName().toString().endsWith(".tmp"))); + } + } finally { + Files.setPosixFilePermissions(file, permissions); + } + InactivityService.save(); + assertEquals(11, saved().guilds.get(guild.getId()).percent); + assertEquals(21, saved().factions.get(faction.getId()).percent); + } + + @Test + void oneFailingGuildLogDoesNotPreventTheNextFactionFromAdvancing() throws Exception { + Faction failing = fixture.saved("failing", "Alice"); + Faction next = fixture.saved("next", "Bob"); + history(Map.of("alice", 21, "bob", 21)); + InactivityService.armAll(List.of(failing, next), 0); + String failedGuildName = failing.getOrCreateMainGuild().getName(); + Logger logger = fixture.ui.plugin.getLogger(); + doThrow(new IllegalStateException("log sink unavailable")) + .when(logger) + .log(eq(Level.INFO), startsWith(failedGuildName + " inactivity output")); + assertDoesNotThrow(() -> InactivityService.tickIfDue(List.of(failing, next))); + assertEquals(1, InactivityService.guildPercent(failing.getOrCreateMainGuild())); + assertEquals(1, InactivityService.guildPercent(next.getOrCreateMainGuild())); + assertEquals(1, InactivityService.prestigePercent(next)); + verify(fixture.ui.plugin.getLogger()) + .log(eq(Level.SEVERE), eq("Inactivity tick failed for failing"), any(Throwable.class)); + } + + @Test + void duePenaltiesCanBeLoggedDuringShutdownWithoutALivePlugin() throws Exception { + Faction faction = fixture.saved("home", "Alice"); + history(Map.of("alice", 21)); + state(faction.getOrCreateMainGuild().getId(), 0, faction.getId(), 0, 1); + InactivityService.load(); + SimpleFactions previous = SimpleFactions.plugin; + SimpleFactions.plugin = null; + try { + assertDoesNotThrow(() -> InactivityService.tickIfDue(List.of(faction))); + assertEquals(1, InactivityService.prestigePercent(faction)); + assertEquals(1, InactivityService.guildPercent(faction.getOrCreateMainGuild())); + } finally { + SimpleFactions.plugin = previous; + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/integration/AdapterBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/integration/AdapterBoundaryCoverageTest.java new file mode 100644 index 00000000..346e84e3 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/integration/AdapterBoundaryCoverageTest.java @@ -0,0 +1,269 @@ +package net.tfminecraft.simplefactions.integration; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.CopyOption; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import net.tfminecraft.rpcharacters.chat.ChatChannel; +import net.tfminecraft.rpcharacters.chat.ChatChannelPreferenceManager; +import net.tfminecraft.rpcharacters.chat.ChatRecipientFilters; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.api.GatewayClient; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.identity.LeaderCharacters; +import net.tfminecraft.simplefactions.integration.rpcharacters.chat.FactionChatRecipientResolver; +import net.tfminecraft.simplefactions.integration.rpcharacters.chat.GuildChatRecipientResolver; +import net.tfminecraft.simplefactions.rest.BannerFetcher; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import net.tfminecraft.simplefactions.utils.Formatter; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +class AdapterBoundaryCoverageTest { + @TempDir Path directory; + + @Test + void bannerFetchReleasesItsKeyWhenThePluginDisablesBeforeMainThreadDelivery() throws Exception { + String key = "adapter-disable-" + UUID.randomUUID(); + try (var ui = new GuiTestFixture(); + var gateway = mockStatic(GatewayClient.class)) { + AtomicBoolean enabled = new AtomicBoolean(true); + AtomicInteger delivered = new AtomicInteger(); + when(ui.plugin.isEnabled()).thenAnswer(call -> enabled.get()); + gateway + .when(() -> GatewayClient.request("GET", "/generator/banner", null)) + .thenReturn(GatewayClient.Result.success("[\"WHITE.BASE\"]")); + try { + assertTrue(BannerFetcher.fetch(key, patterns -> delivered.incrementAndGet())); + enabled.set(false); + runAsync(ui); + assertTrue(ui.tasks.isEmpty()); + assertEquals(0, delivered.get()); + enabled.set(true); + assertTrue( + BannerFetcher.fetch(key, patterns -> delivered.incrementAndGet()), + "A dropped callback must not block every future request for the same banner"); + runAsync(ui); + ui.runTasks(); + assertEquals(1, delivered.get()); + } finally { + forgetTestRequest(key); + } + } + } + + @Test + void publicBannerFetchUsesAsyncGatewayAndHoldsDeduplicationUntilMainDelivery() throws Exception { + String key = "adapter-banner-" + UUID.randomUUID(); + try (var ui = new GuiTestFixture(); + var gateway = mockStatic(GatewayClient.class)) { + when(ui.plugin.isEnabled()).thenReturn(true); + gateway + .when(() -> GatewayClient.request("GET", "/generator/banner", null)) + .thenReturn(GatewayClient.Result.success("[\"RED.BASE\",\"WHITE.CROSS\"]")); + AtomicReference<List<String>> delivered = new AtomicReference<>(); + try { + assertTrue(BannerFetcher.fetch(key, delivered::set)); + assertFalse(BannerFetcher.fetch(key, delivered::set)); + gateway.verifyNoInteractions(); + runAsync(ui); + assertNull(delivered.get()); + assertEquals(1, ui.tasks.size()); + assertFalse(BannerFetcher.fetch(key, delivered::set)); + ui.runTasks(); + assertEquals(List.of("RED.BASE", "WHITE.CROSS"), delivered.get()); + assertTrue(BannerFetcher.fetch(key, delivered::set)); + runAsync(ui); + ui.runTasks(); + gateway.verify(() -> GatewayClient.request("GET", "/generator/banner", null), times(2)); + } finally { + forgetTestRequest(key); + } + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void unavailablePluginRejectsBannerWorkBeforeSchedulingOrNetworkAccess(boolean absent) { + try (var ui = new GuiTestFixture(); + var gateway = mockStatic(GatewayClient.class)) { + when(ui.plugin.isEnabled()).thenReturn(false); + if (absent) SimpleFactions.plugin = null; + assertFalse( + BannerFetcher.fetch("adapter-unavailable", patterns -> fail("Unexpected callback"))); + assertTrue(ui.asyncTasks.isEmpty()); + assertTrue(ui.tasks.isEmpty()); + gateway.verifyNoInteractions(); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void organizationChatSkipsInvalidOfflineDuplicateAndUnauthorizedMembers(boolean guild) { + try (var fixture = new FactionDomainFixture(); + var preferences = mockStatic(ChatChannelPreferenceManager.class)) { + var faction = fixture.saved("chat", "Alice"); + Player alice = fixture.player("Alice"), + bob = fixture.player("Bob"), + offline = fixture.player("Offline"), + hidden = fixture.player("Hidden"), + denied = fixture.player("Denied"); + faction + .getOrCreateMainGuild() + .getMembers() + .addAll(Arrays.asList(" ", "Bob", "BOB", "Offline", "Hidden", "Denied")); + when(Bukkit.getPlayerExact(anyString())) + .thenAnswer( + call -> + fixture.online.values().stream() + .filter(player -> player.getName().equalsIgnoreCase(call.getArgument(0))) + .findFirst() + .orElse(null)); + when(offline.isOnline()).thenReturn(false); + for (Player player : List.of(alice, bob, offline, hidden)) + when(player.hasPermission("org.read")).thenReturn(true); + when(denied.hasPermission("org.read")).thenReturn(false); + var visibility = mock(ChatChannelPreferenceManager.class); + when(visibility.isChannelVisible(any(Player.class), eq("organization"))) + .thenAnswer(call -> !call.getArgument(0).equals(hidden)); + preferences.when(ChatChannelPreferenceManager::get).thenReturn(visibility); + YamlConfiguration config = new YamlConfiguration(); + config.set("read-perm", "org.read"); + ChatChannel channel = new ChatChannel("organization", config); + Set<Player> result = + guild + ? new GuildChatRecipientResolver().resolve(alice, channel) + : new FactionChatRecipientResolver().resolve(alice, channel); + assertEquals(Set.of(alice, bob), result); + verify(visibility, never()).isChannelVisible(eq(offline), anyString()); + verify(visibility, never()).isChannelVisible(eq(denied), anyString()); + } + } + + @Test + void organizationResolverSuppliesNoCandidatesBeforeBukkitIsAvailable() { + try (var fixture = new FactionDomainFixture(); + var filters = mockStatic(ChatRecipientFilters.class)) { + var faction = fixture.saved("chat", "Alice"); + faction.addMember("Bob"); + var alice = fixture.player("Alice"); + fixture.player("Bob"); + var channel = mock(ChatChannel.class); + when(Bukkit.getServer()).thenReturn(null); + filters + .when( + () -> + ChatRecipientFilters.filterCandidates( + same(alice), same(channel), anyCollection())) + .thenAnswer(call -> Set.copyOf((java.util.Collection<Player>) call.getArgument(2))); + assertTrue(new FactionChatRecipientResolver().resolve(alice, channel).isEmpty()); + filters.verify(() -> ChatRecipientFilters.filterCandidates(alice, channel, List.of())); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void jsonAtomicFallbackKeepsTheOldSaveOnFailureAndRemovesItsTemporaryFile(boolean fallbackFails) + throws Exception { + Path file = directory.resolve("state.json"); + JsonUtil.writeJson(file.toFile(), Map.of("owner", "old")); + String original = Files.readString(file); + AtomicInteger atomicAttempts = new AtomicInteger(); + AtomicInteger fallbackAttempts = new AtomicInteger(); + try (var files = + mockStatic( + Files.class, + call -> { + if (call.getMethod().getName().equals("move")) { + Object[] args = call.getRawArguments(); + if (file.toAbsolutePath().equals(args[1])) { + CopyOption[] options = (CopyOption[]) args[2]; + if (Arrays.asList(options).contains(StandardCopyOption.ATOMIC_MOVE)) { + atomicAttempts.incrementAndGet(); + throw new AtomicMoveNotSupportedException( + args[0].toString(), args[1].toString(), "filesystem boundary"); + } + fallbackAttempts.incrementAndGet(); + if (fallbackFails) throw new IOException("replacement failed"); + } + } + return call.callRealMethod(); + })) { + if (fallbackFails) + assertEquals( + "replacement failed", + assertThrows( + IOException.class, + () -> JsonUtil.writeJsonAtomic(file.toFile(), Map.of("owner", "new"))) + .getMessage()); + else JsonUtil.writeJsonAtomic(file.toFile(), Map.of("owner", "new")); + } + assertEquals(1, atomicAttempts.get()); + assertEquals(1, fallbackAttempts.get()); + if (fallbackFails) assertEquals(original, Files.readString(file)); + assertEquals( + fallbackFails ? "old" : "new", JsonUtil.readJson(file.toFile(), Map.class).get("owner")); + try (var files = Files.list(directory)) { + assertEquals(List.of(file), files.toList()); + } + } + + @Test + void disablingCharacterIntegrationClearsRememberedNamesWithoutChangingOwnership() { + try (var fixture = new FactionDomainFixture()) { + var faction = fixture.saved("realm", "Alice"); + LeaderCharacters.setProbe(player -> "§aLady Rowan"); + assertTrue(LeaderCharacters.refresh(faction)); + assertEquals("Lady Rowan", faction.getLeaderCharacter()); + LeaderCharacters.reset(); + assertTrue(LeaderCharacters.refresh(faction)); + assertNull(faction.getLeaderCharacter()); + assertNull(faction.getLeaderCharacterOf()); + assertEquals("Alice", faction.getLeader()); + } + } + + @ParameterizedTest + @NullSource + @ValueSource(doubles = {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) + void unavailableOrNonFiniteAmountsCannotEscapeIntoMoneyDisplays(Double amount) { + assertEquals(0.0, Formatter.formatDouble(amount)); + if (amount != null) assertEquals("0.00", Formatter.formatMoney(amount)); + } + + private void runAsync(GuiTestFixture ui) { + var tasks = new ArrayList<>(ui.asyncTasks); + ui.asyncTasks.clear(); + tasks.forEach(Runnable::run); + } + + @SuppressWarnings("unchecked") + private void forgetTestRequest(String key) throws Exception { + // Restore only the test's own global request after a failing regression; never invoke private + // code. + var field = BannerFetcher.class.getDeclaredField("inFlight"); + field.setAccessible(true); + ((Set<String>) field.get(null)).remove(key); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/integration/IntegrationBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/integration/IntegrationBoundaryCoverageTest.java new file mode 100644 index 00000000..eb02524d --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/integration/IntegrationBoundaryCoverageTest.java @@ -0,0 +1,269 @@ +package net.tfminecraft.simplefactions.integration; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.DirectoryNotEmptyException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import net.tfminecraft.rpcharacters.chat.*; +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.rpcharacters.objects.PlayerData; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.playtime.PlaytimeService; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.api.GatewayClient; +import net.tfminecraft.simplefactions.identity.RpCharactersLeaderCharacterProbe; +import net.tfminecraft.simplefactions.integration.rpcharacters.chat.*; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.prestige.RpCharactersPlaytimeProbe; +import net.tfminecraft.simplefactions.rest.RestServer; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.freeze.VfBuildersConstructionFreeze; +import net.tfminecraft.vfbuilders.api.ConstructionFreeze; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; +import org.bukkit.plugin.ServicePriority; +import org.bukkit.plugin.ServicesManager; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; + +class IntegrationBoundaryCoverageTest { + private FactionDomainFixture rig; + @TempDir Path folder; + + @BeforeEach + void setup() { + rig = new FactionDomainFixture(); + } + + @AfterEach + void close() { + if (rig != null) rig.close(); + } + + @Test + void constructionProviderHoldsOnlyProjectsWhoseFactionBattleIsPostponed() { + var owner = rig.saved("builder_owner", "Builder"); + var enemy = rig.saved("builder_enemy", "Enemy"); + Player builder = rig.player("Builder"); + when(Bukkit.getOfflinePlayer(builder.getUniqueId())).thenReturn(builder); + ServicesManager services = mock(ServicesManager.class); + when(Bukkit.getServicesManager()).thenReturn(services); + VfBuildersConstructionFreeze.register(rig.ui.plugin); + ArgumentCaptor<ConstructionFreeze> provider = ArgumentCaptor.forClass(ConstructionFreeze.class); + verify(services) + .register( + eq(ConstructionFreeze.class), + provider.capture(), + same(rig.ui.plugin), + eq(ServicePriority.Normal)); + assertNull(provider.getValue().freezeReason(builder.getUniqueId())); + War war = new War(813477, owner, enemy); + WarManager.addWar(war); + war.setPreparationFrozenUntil(Instant.now().plusSeconds(3600)); + assertTrue( + provider.getValue().freezeReason(builder.getUniqueId()).startsWith("battle postponed (")); + war.setPreparationFrozenUntil(Instant.now().minusSeconds(1)); + assertNull(provider.getValue().freezeReason(builder.getUniqueId())); + } + + @Test + void chatHooksRegisterTheCorrectResolversOnlyWhenTheProviderIsEnabled() { + Map<String, ChatRecipientResolver> registered = new LinkedHashMap<>(); + try (var registry = mockStatic(ChatRecipientResolverRegistry.class); + var filters = mockStatic(ChatRecipientFilters.class)) { + registry + .when( + () -> + ChatRecipientResolverRegistry.register( + anyString(), any(ChatRecipientResolver.class))) + .thenAnswer( + call -> { + registered.put(call.getArgument(0), call.getArgument(1)); + return null; + }); + registry + .when(() -> ChatRecipientResolverRegistry.unregister(anyString())) + .thenAnswer( + call -> { + registered.remove(call.getArgument(0)); + return null; + }); + var plugins = Bukkit.getPluginManager(); + when(Bukkit.getServer()).thenReturn(null); + RpCharactersChatIntegration.register(); + when(Bukkit.getServer()).thenReturn(rig.ui.server); + when(Bukkit.getPluginManager()).thenReturn(null); + RpCharactersChatIntegration.register(); + when(Bukkit.getPluginManager()).thenReturn(plugins); + RpCharactersChatIntegration.register(); + assertTrue(registered.isEmpty()); + when(plugins.isPluginEnabled("RPCharacters")).thenReturn(true); + RpCharactersChatIntegration.register(); + assertEquals(Set.of("simplefactions:guild", "simplefactions:faction"), registered.keySet()); + assertInstanceOf(GuildChatRecipientResolver.class, registered.get("simplefactions:guild")); + assertInstanceOf( + FactionChatRecipientResolver.class, registered.get("simplefactions:faction")); + var faction = rig.saved("chat_owner", "Speaker"); + faction.getOrCreateMainGuild().addMember("Listener"); + Player speaker = rig.player("Speaker"), listener = rig.player("Listener"); + ChatChannel channel = mock(ChatChannel.class); + filters + .when(() -> ChatRecipientFilters.filterCandidates(any(), any(), any())) + .thenAnswer(call -> Set.copyOf((java.util.Collection<Player>) call.getArgument(2))); + for (var resolver : registered.values()) { + assertEquals(Set.of(speaker, listener), resolver.resolve(speaker, channel)); + assertTrue(resolver.resolve(null, channel).isEmpty()); + assertTrue(resolver.resolve(rig.player("Outsider"), channel).isEmpty()); + } + RpCharactersChatIntegration.unregister(); + assertTrue(registered.isEmpty()); + } + } + + @Test + void leaderNamesUseLiveCharactersOrCachedLocalFilesWithoutRemoteLookup() throws Exception { + var plugins = Bukkit.getPluginManager(); + var probe = new RpCharactersLeaderCharacterProbe(); + assertNull(probe.activeCharacterName("Online")); + when(plugins.isPluginEnabled("RPCharacters")).thenReturn(true); + Player player = rig.player("Online"); + try (var players = mockStatic(PlayerManager.class)) { + assertNull(probe.activeCharacterName("Online")); + PlayerData data = mock(PlayerData.class); + players.when(() -> PlayerManager.get(player)).thenReturn(data); + assertNull(probe.activeCharacterName("Online")); + when(data.hasActiveCharacter()).thenReturn(true); + assertNull(probe.activeCharacterName("Online")); + RPCharacter character = mock(RPCharacter.class); + when(character.getName()).thenReturn("Aelin of the Reach"); + when(data.getActiveCharacter()).thenReturn(character); + assertEquals("Aelin of the Reach", probe.activeCharacterName("Online")); + } + when(Bukkit.getOfflinePlayer(anyString())) + .thenThrow(new AssertionError("Remote lookup is forbidden")); + assertNull(probe.activeCharacterName("Uncached")); + OfflinePlayer offline = mock(OfflinePlayer.class); + UUID uuid = UUID.randomUUID(); + when(offline.getUniqueId()).thenReturn(uuid); + when(Bukkit.getOfflinePlayerIfCached("Cached")).thenReturn(offline); + Path directory = Path.of("plugins/RPCharacters/data/characterdata", uuid.toString()); + Path saved = directory.resolve("active.json"); + byte[] previous = + Files.exists(saved, LinkOption.NOFOLLOW_LINKS) ? Files.readAllBytes(saved) : null; + List<Path> createdDirectories = new ArrayList<>(); + for (Path ancestor = directory; + ancestor != null && !Files.exists(ancestor, LinkOption.NOFOLLOW_LINKS); + ancestor = ancestor.getParent()) { + createdDirectories.add(ancestor); + } + try { + Files.createDirectories(directory); + Files.writeString(saved, "{\"active\":true,\"name\":\"Brann\"}"); + assertEquals("Brann", probe.activeCharacterName("Cached")); + } finally { + if (previous == null) Files.deleteIfExists(saved); + else Files.write(saved, previous); + for (Path created : createdDirectories) { + if (!Files.isDirectory(created, LinkOption.NOFOLLOW_LINKS)) continue; + try { + Files.deleteIfExists(created); + } catch (DirectoryNotEmptyException retained) { + // Preserve contents added by another fixture; ancestors are visited deepest first. + } + } + } + } + + @Test + void playtimeIntegrationReturnsUnknownWhenDisabledAndTheProvidersValueWhenEnabled() { + var probe = new RpCharactersPlaytimeProbe(); + assertNull(probe.secondsFor("Offline")); + when(Bukkit.getPluginManager().isPluginEnabled("RPCharacters")).thenReturn(true); + try (var playtime = mockStatic(PlaytimeService.class)) { + playtime.when(() -> PlaytimeService.getSeconds("Offline")).thenReturn(7200); + assertEquals(7200, probe.secondsFor("Offline")); + playtime.verify(() -> PlaytimeService.getSeconds("Offline")); + } + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource( + strings = { + "[]", + "{\"settlements\":[],\"installations\":{}}", + "{\"settlements\":[],\"forts\":{}}" + }) + void malformedMapMarkersCannotReachTheGateway(String invalid) throws Exception { + boolean previous = Cache.mapEnabled; + Cache.mapEnabled = true; + var logger = mock(java.util.logging.Logger.class); + when(rig.ui.plugin.getLogger()).thenReturn(logger); + Path file = Files.writeString(folder.resolve("markers.json"), invalid); + try (var gateway = mockStatic(GatewayClient.class)) { + RestServer.upload("map_markers", file.toFile()); + gateway.verifyNoInteractions(); + ArgumentCaptor<Throwable> failure = ArgumentCaptor.forClass(Throwable.class); + verify(logger) + .log( + eq(java.util.logging.Level.WARNING), + eq("Upload failed for map_markers"), + failure.capture()); + assertInstanceOf(IllegalStateException.class, failure.getValue()); + } finally { + Cache.mapEnabled = previous; + } + } + + @Test + void mapGatewayCarriesValidatedPayloadsAndHonorsDisabledUploads() throws Exception { + boolean oldMap = Cache.mapEnabled, oldChronicle = Cache.chronicleEnabled; + String oldRef = Cache.mapRef; + Path upload = Files.writeString(folder.resolve("regions.json"), "{}"); + try (var gateway = mockStatic(GatewayClient.class)) { + Cache.mapEnabled = false; + RestServer.upload("regions", upload.toFile()); + RestServer.commenceRegen("political"); + gateway.verifyNoInteractions(); + Cache.mapEnabled = true; + Cache.chronicleEnabled = false; + RestServer.upload("chronicle", upload.toFile()); + gateway.verifyNoInteractions(); + Cache.mapRef = "boundary_map"; + gateway + .when(() -> GatewayClient.request("GET", "/generator/banner", null)) + .thenReturn(GatewayClient.Result.success("[\"banner_one\",\"banner_two\"]")); + assertEquals(List.of("banner_one", "banner_two"), RestServer.fetchBannerList()); + gateway + .when(() -> GatewayClient.request(eq("POST"), anyString(), anyString())) + .thenReturn(GatewayClient.Result.success("ok")); + RestServer.upload("regions", upload.toFile()); + gateway.verify( + () -> GatewayClient.request("POST", "/boundary_map/data/upload/regions", "{}")); + gateway + .when( + () -> + GatewayClient.request(eq("GET"), contains("/api/regenerate/political"), isNull())) + .thenReturn(GatewayClient.Result.success("ok")); + RestServer.commenceRegen("political"); + gateway.verify( + () -> GatewayClient.request(eq("GET"), contains("/api/regenerate/political"), isNull())); + } finally { + Cache.mapEnabled = oldMap; + Cache.chronicleEnabled = oldChronicle; + Cache.mapRef = oldRef; + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/laws/LawRequirementsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/laws/LawRequirementsCoverageTest.java new file mode 100644 index 00000000..18530d89 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/laws/LawRequirementsCoverageTest.java @@ -0,0 +1,551 @@ +package net.tfminecraft.simplefactions.laws; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.enums.Brackets; +import net.tfminecraft.simplefactions.enums.FactionModifiers; +import net.tfminecraft.simplefactions.enums.Region; +import net.tfminecraft.simplefactions.enums.Rules; +import net.tfminecraft.simplefactions.enums.Scope; +import net.tfminecraft.simplefactions.government.handler.ProposalHandler; +import net.tfminecraft.simplefactions.government.movement.Action; +import net.tfminecraft.simplefactions.government.movement.PoliticalAction; +import net.tfminecraft.simplefactions.government.proposal.FeeChange; +import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.government.proposal.Proposal; +import net.tfminecraft.simplefactions.government.proposal.TaxLawChange; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.loaders.PoliticalActionLoader; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemCreator; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +class LawRequirementsCoverageTest { + private FactionDomainFixture fixture; + + @BeforeEach + void setUp() { + fixture = new FactionDomainFixture(); + } + + @AfterEach + void tearDown() { + fixture.close(); + } + + @Test + void configuredLawScopesDoNotDependOnTheServerLocale() { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("effects.faction.modifiers", List.of("production(7.5)")); + Law law = new Law("economy", "production", yaml); + assertNotNull(law.getScopedEffects().get(Scope.FACTION)); + assertEquals( + 7.5, + law.getScopedEffects() + .get(Scope.FACTION) + .getModifierAmount(FactionModifiers.PRODUCTION, null)); + } finally { + Locale.setDefault(previous); + } + } + + @ParameterizedTest + @ValueSource(strings = {"rule", "bracket", "region"}) + void configuredEffectIdentifiersRemainValidUnderTurkishLocale(String kind) { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("rules", List.of("citizen_tax false")); + yaml.set("brackets.citizen_tax", "5-15"); + yaml.set("foreign_territory", List.of("production(7.5)")); + LawEffect effect = new LawEffect(Scope.FACTION, yaml); + switch (kind) { + case "rule" -> assertEquals(Boolean.FALSE, effect.getRules().get(Rules.CITIZEN_TAX)); + case "bracket" -> { + assertNotNull(effect.getBrackets().get(Brackets.CITIZEN_TAX)); + assertEquals(15.0, effect.getBrackets().get(Brackets.CITIZEN_TAX).getMax()); + } + case "region" -> + assertEquals( + 7.5, + effect.getModifierAmount(FactionModifiers.PRODUCTION, Region.FOREIGN_TERRITORY)); + default -> throw new AssertionError(kind); + } + } finally { + Locale.setDefault(previous); + } + } + + @Test + void unknownRegimentEntriesCannotInsertANullMilitaryTemplate() { + var guard = fixture.regiment("guard", false, 2, 0); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("regiments", List.of("missing 4", "guard 2")); + LawEffect effect = new LawEffect(Scope.FACTION, yaml); + assertEquals(Map.of(guard, 2), effect.getRegiments()); + } + + @ParameterizedTest + @ValueSource(strings = {"NaN-50", "0-Infinity", "30-10"}) + void invalidBracketsDoNotReplaceValidEconomicLimits(String malformed) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("brackets.vassal_tax", malformed); + yaml.set("brackets.guild_tax", "5-20"); + LawEffect effect = new LawEffect(Scope.FACTION, yaml); + assertFalse(effect.getBrackets().containsKey(Brackets.VASSAL_TAX)); + assertEquals(5.0, effect.getBrackets().get(Brackets.GUILD_TAX).getMin()); + assertEquals(20.0, effect.getBrackets().get(Brackets.GUILD_TAX).getMax()); + } + + @Test + void anInvalidBooleanCannotSilentlyBecomeAnExplicitLawVeto() { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("rules", List.of("guild_tax typo", "citizen_tax false")); + LawEffect effect = new LawEffect(Scope.FACTION, yaml); + assertFalse(effect.getRules().containsKey(Rules.GUILD_TAX)); + assertEquals(Boolean.FALSE, effect.getRules().get(Rules.CITIZEN_TAX)); + } + + @ParameterizedTest + @CsvSource({ + "tax,NaN", + "tax,Infinity", + "tax,-Infinity", + "fee,NaN", + "fee,Infinity", + "fee,-Infinity" + }) + void malformedSavedRatesCannotReturnAsLiveFinancialProposals(String kind, String rate) { + var faction = fixture.saved("home", "Leader"); + ProposalHandler handler = new ProposalHandler(faction.getGovernment()); + String target = kind.equals("tax") ? "CITIZENS:null" : "REGISTRATION_FEE:*"; + handler.restoreProposals( + faction, + List.of("Leader:" + kind + ":" + target + ":" + rate, "Leader:tax:CITIZENS:null:12.5")); + assertEquals(1, handler.getProposals().size()); + assertEquals(12.5, handler.getProposals().getFirst().getTaxChange().getNewTax()); + } + + @Test + void availabilityFollowsRequiredAndForbiddenCurrentLawsWithoutChangingEitherGroup() { + fixture.lawGroup("government", Map.of()); + fixture.lawGroup("economy", Map.of()); + var monarchy = fixture.law("government", "monarchy", Map.of()); + var community = fixture.law("government", "community", Map.of()); + var candidate = + fixture.law( + "economy", + "regulated", + Map.of("requirements", List.of(" ", " HAS_LAW MONARCHY ", "not_law community"))); + var home = fixture.saved("home", "Leader"); + var government = home.getLawHandler().getGroup("government"); + var economy = home.getLawHandler().getGroup("economy"); + Law original = economy.getCurrent(); + assertEquals("§cRequires law: MONARCHY.", CanHaveLaw.blockReason(home, candidate)); + government.setCurrent(monarchy); + assertTrue(candidate.isAvailable(home)); + assertSame(original, economy.getCurrent()); + var forbidden = + fixture.law("economy", "forbidden", Map.of("requirements", List.of("not_law MONARCHY"))); + assertEquals("§cCannot have law: MONARCHY.", CanHaveLaw.blockReason(home, forbidden)); + government.setCurrent(community); + assertEquals("§cRequires law: MONARCHY.", CanHaveLaw.blockReason(home, candidate)); + assertEquals("§cRequires law: MONARCHY.", CanHaveLaw.blockReason(null, candidate)); + assertFalse(CanHaveLaw.canHave(home, null)); + assertEquals("§cThat law does not exist.", CanHaveLaw.blockReason(home, null)); + } + + @ParameterizedTest + @ValueSource(strings = {"has_law", "not_law", "unrecognized monarchy"}) + void malformedRequirementsDenyTheCandidateInsteadOfIgnoringTheRule(String requirement) { + fixture.lawGroup("economy", Map.of()); + var candidate = + fixture.law("economy", "regulated", Map.of("requirements", List.of(requirement))); + var home = fixture.saved("home", "Leader"); + assertEquals("§cInvalid law requirement.", CanHaveLaw.blockReason(home, candidate)); + assertEquals("current", home.getLawHandler().getGroup("economy").getCurrent().getId()); + } + + @ParameterizedTest + @ValueSource(strings = {"economy", "military"}) + void communityGovernmentMayRestoreDecentralizationButCannotReplaceIt(String category) { + fixture.lawGroup("government", Map.of()); + fixture.lawGroup(category, Map.of()); + var community = fixture.law("government", "community", Map.of()); + var decentralized = fixture.law(category, "decentralized", Map.of()); + var centralized = fixture.law(category, "centralized", Map.of()); + var home = fixture.saved("home", "Leader"); + var government = home.getLawHandler().getGroup("government"); + var subject = home.getLawHandler().getGroup(category); + government.setCurrent(community); + assertTrue(CanHaveLaw.blockReason(home, centralized).contains("Already off Decentralized")); + assertNull(CanHaveLaw.blockReason(home, decentralized)); + subject.setCurrent(decentralized); + assertTrue(CanHaveLaw.blockReason(home, centralized).contains("stay Decentralized")); + assertNull(CanHaveLaw.blockReason(home, decentralized)); + government.setCurrent(government.getLaw("current")); + assertNull(CanHaveLaw.blockReason(home, centralized)); + assertSame(decentralized, subject.getCurrent()); + } + + @Test + void factionsWithoutAGovernmentGroupCanStillAdoptCompatibleEconomicLaws() { + fixture.lawGroup("economy", Map.of()); + var home = fixture.saved("home", "Leader"); + var denied = fixture.law("economy", "denied", Map.of("compatibility", List.of("current 0"))); + var permitted = + fixture.law("economy", "permitted", Map.of("compatibility", List.of("current 2"))); + assertEquals( + "§cIncompatible with the current law in this group.", CanHaveLaw.blockReason(home, denied)); + assertNull(CanHaveLaw.blockReason(home, permitted)); + assertNull(CanHaveLaw.blockReason(home, fixture.law("unknown", "unregistered", Map.of()))); + } + + @Test + void aCurrentLawRemainsValidWhileTheActualSwitchLockProtectsOtherCandidates() { + fixture.lawGroup("economy", Map.of()); + var home = fixture.saved("home", "Leader"); + var group = home.getLawHandler().getGroup("economy"); + var current = group.getCurrent(); + var replacement = fixture.law("economy", "replacement", Map.of()); + double previous = Cache.lawSwitchLockDays; + try { + Cache.lawSwitchLockDays = 2; + long now = System.currentTimeMillis(); + group.setChangedAt(now - 60_000); + current.getRequirements().add("has_law nonexistent"); + assertNull(CanHaveLaw.blockReason(home, current)); + assertNull(CanHaveLaw.lockReason(home, current, now)); + assertTrue(CanHaveLaw.blockReason(home, replacement).contains("changed recently")); + assertTrue(CanHaveLaw.lockReason(home, replacement, now).contains("1d 23h")); + assertNull(CanHaveLaw.lockReason(home, replacement, now + 2 * 86_400_000L)); + assertNull(CanHaveLaw.lockReason(null, replacement, now)); + assertNull(CanHaveLaw.lockReason(home, null, now)); + assertEquals("1m", CanHaveLaw.formatRemaining(1)); + assertEquals("1h 1m", CanHaveLaw.formatRemaining(3_600_001)); + assertEquals("2d 3h", CanHaveLaw.formatRemaining(2 * 86_400_000L + 3 * 3_600_000L)); + } finally { + Cache.lawSwitchLockDays = previous; + } + } + + @Test + void unavailableInitialLawsHaveADeterministicFallbackAndCopiesPreserveGroupMetadata() { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("name", "Economic Rules"); + yaml.set("description", List.of("Choose how the realm trades.")); + yaml.set("laws.first.requirements", List.of("has_law absent")); + yaml.set("laws.second.requirements", List.of("has_law also_absent")); + var template = new LawGroup("economy", yaml); + var home = fixture.saved("home", "Leader"); + var group = new LawGroup(home, template); + assertEquals("economy", group.getId()); + assertEquals("Economic Rules", group.getName()); + assertTrue(group.hasDescription()); + assertEquals(List.of("Choose how the realm trades."), group.getDescription()); + assertSame(template.getLaw("first"), group.getCurrent()); + group.setCurrent(fixture.law("elsewhere", "unregistered", Map.of())); + assertSame(template.getLaw("first"), group.getCurrent()); + group.switchTo(template.getLaw("second"), 100); + assertSame(template.getLaw("second"), group.getCurrent()); + assertEquals(100, group.getChangedAt()); + group.switchTo(template.getLaw("second"), 200); + assertEquals(100, group.getChangedAt()); + group.switchTo(fixture.law("elsewhere", "another", Map.of()), 300); + assertSame(template.getLaw("second"), group.getCurrent()); + } + + @Test + void lawMetadataAndValidEffectsSurviveMalformedSiblingConfiguration() { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("name", "Trading Charter"); + yaml.set("icon", "v.emerald"); + yaml.set("upkeep", 2.5); + yaml.set("cost", 25); + yaml.set("description", List.of("Trade freely.")); + yaml.set("compatibility", List.of("incomplete", "too many parts", "old bad", "current -1")); + yaml.set("effects.unknown.rules", List.of("guild_tax true")); + yaml.set("effects.faction.modifiers", List.of("production(15)")); + Law law = new Law("economy", "charter", yaml); + assertEquals("Trading Charter", law.getName()); + assertEquals("v.emerald", law.getIconString()); + assertEquals(2.5, law.getUpkeep()); + assertEquals(25.0, law.getCost()); + assertTrue(law.hasDescription()); + assertEquals(List.of("Trade freely."), law.getDescription()); + assertEquals(Map.of("current", -1), law.getCompatibility()); + assertEquals(1, law.getCompatibility("unmentioned")); + assertTrue(law.hasEffects()); + assertEquals(1, law.getScopedEffects().size()); + assertTrue(law.affectsEconomy()); + try (var boundary = mockStatic(TLibs.class)) { + ItemAPI api = mock(ItemAPI.class); + ItemCreator creator = mock(ItemCreator.class); + ItemStack icon = new ItemStack(Material.EMERALD); + boundary.when(TLibs::getItemAPI).thenReturn(api); + when(api.getCreator()).thenReturn(creator); + when(creator.getItemFromPath("v.emerald")).thenReturn(icon); + assertSame(icon, law.getIcon()); + } + assertTrue(fixture.law("government", "community", Map.of()).affectsEconomy()); + assertFalse( + fixture + .law("military", "levies", Map.of("effects.faction.modifiers", List.of("prestige(3)"))) + .affectsEconomy()); + } + + @Test + void effectConfigurationKeepsValidSiblingsAndCombinesRegionalAndGlobalAmounts() { + var guard = fixture.regiment("guard", false, 1, 0); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set( + "rules", List.of("malformed", "unknown true", "guild_tax TRUE", "can_have_vassals false")); + yaml.set("regiments", List.of("malformed", "guard bad", "guard 3")); + yaml.set("brackets.guild_tax", "5-25"); + yaml.set("brackets.unknown", "0-5"); + yaml.set("brackets.vassal_tax", "garbage"); + yaml.set("brackets.tariffs", "bad-10"); + yaml.set("modifiers", List.of("bad-format", "production(10)", "production(2.5)")); + yaml.set("foreign_territory", List.of("missing(7)", "production(4)", "trade_power(9)")); + LawEffect effect = new LawEffect(Scope.FACTION, yaml); + assertEquals(Map.of(guard, 3), effect.getRegiments()); + assertEquals(Map.of(Rules.GUILD_TAX, true, Rules.CAN_HAVE_VASSALS, false), effect.getRules()); + assertEquals(1, effect.getBrackets().size()); + assertTrue(effect.hasGlobalModifiers()); + assertTrue(effect.hasRegionModifiers()); + assertTrue(effect.hasRegiments()); + assertEquals(2, effect.getGlobalModifiers().size()); + assertEquals(2, effect.getRegionModifiers().get(Region.FOREIGN_TERRITORY).size()); + assertEquals(12.5, effect.getModifierAmount(FactionModifiers.PRODUCTION, null)); + assertEquals( + 16.5, effect.getModifierAmount(FactionModifiers.PRODUCTION, Region.FOREIGN_TERRITORY)); + assertNull(effect.getModifierAmount(FactionModifiers.PRESTIGE, null)); + assertTrue(effect.prohibitsVassals()); + assertTrue(effect.affectsEconomy()); + assertFalse(effect.affectsCouncilType()); + } + + @Test + void regionalPrestigeAloneDoesNotRequestAnEconomicPreview() { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("foreign_territory", List.of("prestige(5)")); + LawEffect effect = new LawEffect(Scope.FACTION, yaml); + assertEquals( + 5.0, effect.getModifierAmount(FactionModifiers.PRESTIGE, Region.FOREIGN_TERRITORY)); + assertFalse(effect.affectsEconomy()); + } + + @ParameterizedTest + @CsvSource({ + "appointed_council,APPOINTED_COUNCIL", + "wealth_based_council,WEALTH_BASED_COUNCIL", + "elected_council,ELECTED_COUNCIL", + "has_council,NO_COUNCIL" + }) + void councilRulesDeclareTheirTypeAndConfiguredSize(String configured, Rules expected) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("rules", List.of(configured + " true")); + yaml.set("council-size", 5); + LawEffect effect = new LawEffect(Scope.FACTION, yaml); + assertTrue(effect.affectsCouncilType()); + assertEquals(expected, effect.getCouncilType()); + assertTrue(effect.affectsCouncilSize()); + assertEquals(5, effect.getCouncilSize()); + assertFalse(effect.prohibitsVassals()); + assertFalse(effect.affectsEconomy()); + LawEffect empty = new LawEffect(Scope.FACTION, new YamlConfiguration()); + assertFalse(empty.affectsCouncilType()); + assertFalse(empty.affectsCouncilSize()); + assertEquals(Rules.NO_COUNCIL, empty.getCouncilType()); + assertFalse(empty.prohibitsVassals()); + } + + @ParameterizedTest + @CsvSource({ + "brackets.citizen_tax,false", + "brackets.dividend_tax,false", + "brackets.vehicle_tax,false", + "brackets.registration_fee,false", + "brackets.transfer_fee,false", + "brackets.guild_tax,true", + "brackets.vassal_tax,true", + "brackets.tariffs,true", + "rules,true", + "modifiers,true", + "foreign_territory,true" + }) + void onlyEffectsThatChangeGuildIncomeRequestEconomicPreview(String key, boolean expected) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set( + key, + key.startsWith("brackets") + ? "0-20" + : key.equals("rules") ? List.of("tariffs true") : List.of("production(5)")); + assertEquals(expected, new LawEffect(Scope.FACTION, yaml).affectsEconomy()); + } + + @Test + void proposalQueuesEnforcePerMemberAndPerTargetLimitsWhileMovementQueuesStayIndependent() { + fixture.lawGroup("economy", Map.of()); + fixture.lawGroup("military", Map.of()); + var home = fixture.saved("home", "Leader"); + ProposalHandler handler = new ProposalHandler(home.getGovernment()); + Proposal first = tax(home, "Alice", TaxTarget.GUILD_ID, "merchants", 10); + Proposal second = tax(home, "Alice", TaxTarget.CITIZENS, null, 5); + handler.propose(first); + assertTrue(handler.canPropose("Alice")); + handler.propose(second); + assertFalse(handler.canPropose("Alice")); + assertTrue(handler.canPropose("Bob")); + assertEquals(List.of(first, second), handler.getProposalsByProposer("Alice")); + assertTrue(handler.getProposalsByProposer("Bob").isEmpty()); + assertFalse(handler.canBeProposed(tax(home, "Bob", TaxTarget.GUILD_ID, "MERCHANTS", 20))); + assertFalse(handler.canBeProposed(tax(home, "Bob", TaxTarget.CITIZENS, null, 20))); + assertTrue(handler.canBeProposed(tax(home, "Bob", TaxTarget.GUILD_ID, "others", 20))); + Proposal law = new Proposal("Bob", home.getGovernment()); + law.setLawProposal(fixture.law("economy", "regulated", Map.of())); + assertTrue(handler.canBeProposed(law)); + handler.propose(law); + Proposal sameGroup = new Proposal("Charlie", home.getGovernment()); + sameGroup.setLawProposal(fixture.law("economy", "free", Map.of())); + assertFalse(handler.canBeProposed(sameGroup)); + Proposal otherGroup = new Proposal("Charlie", home.getGovernment()); + otherGroup.setLawProposal(fixture.law("military", "levies", Map.of())); + assertTrue(handler.canBeProposed(otherGroup)); + Proposal fee = new Proposal("Bob", home.getGovernment()); + fee.setFeeProposal(new FeeChange(FeeKind.REGISTRATION_FEE, "tf:boat", 2)); + handler.propose(fee); + Proposal duplicate = new Proposal("Charlie", home.getGovernment()); + duplicate.setFeeProposal(new FeeChange(FeeKind.REGISTRATION_FEE, "TF:BOAT", 3)); + assertFalse(handler.canBeProposed(duplicate)); + duplicate.setFeeProposal(new FeeChange(FeeKind.TRANSFER_FEE, "tf:boat", 3)); + assertTrue(handler.canBeProposed(duplicate)); + assertSame(first, handler.pop()); + assertSame(second, handler.pop()); + assertTrue(handler.hasProposals()); + ProposalHandler movement = new ProposalHandler(home.getGovernment(), true); + movement.propose(law); + movement.propose(law); + assertTrue(movement.canPropose("Bob")); + assertTrue(movement.canBeProposed(sameGroup)); + handler.clearProposals(); + assertFalse(handler.hasProposals()); + assertNull(handler.pop()); + } + + @Test + void savedProposalsRoundTripAllKindsAndSkipMalformedOrRemovedTargetsIndividually() { + fixture.lawGroup("economy", Map.of()); + var law = fixture.law("economy", "regulated", Map.of()); + var home = fixture.saved("home", "Leader"); + ProposalHandler handler = new ProposalHandler(home.getGovernment()); + Proposal proposal = new Proposal("Leader", home.getGovernment()); + proposal.setLawProposal(law); + handler.propose(proposal); + handler.propose(tax(home, "Alice", TaxTarget.GUILD_ID, "merchants", 7.5)); + handler.propose(tax(home, "Alice", TaxTarget.CITIZENS, null, 4)); + Proposal fee = new Proposal("Bob", home.getGovernment()); + fee.setFeeProposal(new FeeChange(FeeKind.REGISTRATION_FEE, "tf:boat", 1.25)); + handler.propose(fee); + Proposal general = new Proposal("Bob", home.getGovernment()); + general.setFeeProposal(new FeeChange(FeeKind.TRANSFER_FEE, null, 2)); + handler.propose(general); + Proposal action = new Proposal("Leader", home.getGovernment()); + action.setPoliticalActionProposal(new PoliticalAction(Action.CHANGE_LEADER)); + action.setTarget("Alice"); + handler.propose(action); + Proposal untargeted = new Proposal("Leader", home.getGovernment()); + untargeted.setPoliticalActionProposal(new PoliticalAction(Action.SNAP_ELECTIONS)); + handler.propose(untargeted); + List<String> encoded = handler.serializeProposals(); + assertEquals( + List.of( + "Leader:law:economy:regulated", + "Alice:tax:GUILD_ID:merchants:7.5", + "Alice:tax:CITIZENS:null:4.0", + "Bob:fee:REGISTRATION_FEE:tf:boat:1.25", + "Bob:fee:TRANSFER_FEE:*:2.0", + "Leader:action:CHANGE_LEADER:Alice", + "Leader:action:SNAP_ELECTIONS:"), + encoded); + List<String> stored = + new ArrayList<>( + Arrays.asList( + null, + "bad", + ":tax:CITIZENS:null:99", + "Leader:law:missing:removed", + "Leader:law:economy:removed", + "Leader:law:economy", + "Leader:fee:missing", + "Leader:fee:UNKNOWN:*:1", + "Leader:fee:TRANSFER_FEE:*:bad", + "Leader:action:REMOVED", + "Leader:tax:UNKNOWN:null:1", + "Leader:tax:CITIZENS:null:bad", + "Leader:tax:CITIZENS", + "Leader:unknown:data")); + stored.addAll(encoded); + Map<Action, PoliticalAction> previous = new LinkedHashMap<>(PoliticalActionLoader.map); + try { + PoliticalActionLoader.map.clear(); + PoliticalAction configured = new PoliticalAction(Action.CHANGE_LEADER); + PoliticalActionLoader.map.put(Action.CHANGE_LEADER, configured); + handler.restoreProposals(home, stored); + assertEquals(encoded, handler.serializeProposals()); + assertSame(law, handler.getProposals().get(0).getLaw()); + assertNull(handler.getProposals().get(2).getTaxChange().getId()); + assertEquals("tf:boat", handler.getProposals().get(3).getFeeChange().getVehicleTypeId()); + assertSame(configured, handler.getProposals().get(5).getPoliticalAction()); + assertEquals("Alice", handler.getProposals().get(5).getTarget()); + assertFalse(handler.getProposals().get(6).hasTarget()); + } finally { + PoliticalActionLoader.map.clear(); + PoliticalActionLoader.map.putAll(previous); + } + } + + @ParameterizedTest + @EnumSource(FeeKind.class) + void feeKindsMapToTheirRulesAndCalculateOnlyPositiveCharges(FeeKind kind) { + assertSame(kind, FeeKind.fromRule(kind.getRule())); + assertSame(kind, FeeKind.fromBracket(kind.getBracket())); + assertEquals(kind.isPercent() ? 1.25 : 125.0, kind.amount(2.5, 50)); + assertEquals(0.0, kind.amount(0, 50)); + assertEquals(0.0, kind.amount(2.5, 0)); + assertEquals(kind.isPercent() ? "2.5%" : "2.5x upkeep", kind.formatRate(2.5)); + assertFalse(kind.getDisplayName().isBlank()); + assertNull(FeeKind.fromRule(Rules.CAN_HAVE_VASSALS)); + assertNull(FeeKind.fromBracket(Brackets.GUILD_TAX)); + } + + private Proposal tax(Faction faction, String proposer, TaxTarget target, String id, double rate) { + Proposal proposal = new Proposal(proposer, faction.getGovernment()); + proposal.setTaxProposal(new TaxLawChange(target, id, rate)); + return proposal; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/loaders/BattleTemplateYamlLoaderTest.java b/src/test/java/net/tfminecraft/simplefactions/loaders/BattleTemplateYamlLoaderTest.java index 4542ace3..85d11a06 100644 --- a/src/test/java/net/tfminecraft/simplefactions/loaders/BattleTemplateYamlLoaderTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/loaders/BattleTemplateYamlLoaderTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; @@ -72,16 +73,22 @@ void load_parsesSettingsOnlyYaml() throws IOException { } @Test - void load_invalidType_skipsTemplate() throws IOException { + void load_invalidType_rejectsTheWholeInitialFile() throws IOException { Path yamlFile = tempDir.resolve("battle-templates.yml"); Files.writeString(yamlFile, """ + valid_sibling: + type: field broken: type: not_a_type friendly_fire: true """); - new BattleTemplateLoader().load(yamlFile.toFile()); + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> new BattleTemplateLoader().load(yamlFile.toFile())); + assertTrue(failure.getMessage().contains(yamlFile.toString())); + assertNotNull(failure.getCause()); + assertTrue(failure.getCause().getMessage().contains("broken")); assertTrue(BattleTemplateLoader.getAll().isEmpty()); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/loaders/CatalogReloadCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/loaders/CatalogReloadCoverageTest.java new file mode 100644 index 00000000..eb62aed8 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/loaders/CatalogReloadCoverageTest.java @@ -0,0 +1,182 @@ +package net.tfminecraft.simplefactions.loaders; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.nio.file.*; +import java.util.*; +import java.util.function.*; +import java.util.stream.Stream; +import net.tfminecraft.simplefactions.government.movement.*; +import net.tfminecraft.simplefactions.guild.upgrade.Upgrade; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class CatalogReloadCoverageTest { + @TempDir Path directory; + private FactionDomainFixture domain; + private Map<String, Upgrade> company; + private Map<Action, PoliticalAction> actions; + + @BeforeEach + void setup() { + domain = new FactionDomainFixture(); + company = new LinkedHashMap<>(CompanyUpgradeLoader.get()); + actions = new LinkedHashMap<>(PoliticalActionLoader.map); + CompanyUpgradeLoader.get().clear(); + PoliticalActionLoader.map.clear(); + TierLoader.oList.clear(); + RegimentLoader.oList.clear(); + GuildLoader.map.clear(); + BranchLoader.map.clear(); + UpgradeLoader.map.clear(); + LawLoader.map.clear(); + } + + @AfterEach + void close() { + CompanyUpgradeLoader.get().clear(); + CompanyUpgradeLoader.get().putAll(company); + PoliticalActionLoader.map.clear(); + PoliticalActionLoader.map.putAll(actions); + domain.close(); + } + + private record Catalog( + Consumer<File> load, + Supplier<List<String>> ids, + Function<String, Object> find, + String properties) {} + + private Catalog catalog(String kind) { + return switch (kind) { + case "guild" -> + new Catalog( + new GuildLoader()::load, + () -> GuildLoader.getList().stream().map(g -> g.getId()).toList(), + GuildLoader::getByString, + " name: %s\n"); + case "branch" -> + new Catalog( + new BranchLoader()::load, + () -> BranchLoader.getList().stream().map(g -> g.getId()).toList(), + BranchLoader::getByString, + " name: %s\n group: 3\n"); + case "upgrade" -> + new Catalog( + new UpgradeLoader()::load, + () -> UpgradeLoader.getList().stream().map(g -> g.getId()).toList(), + UpgradeLoader::getByString, + " name: %s\n upkeep: 4\n"); + case "company" -> + new Catalog( + new CompanyUpgradeLoader()::load, + () -> CompanyUpgradeLoader.getList().stream().map(g -> g.getId()).toList(), + CompanyUpgradeLoader::getByString, + " name: %s\n upkeep: 4\n"); + case "law" -> + new Catalog( + new LawLoader()::load, + () -> LawLoader.getList().stream().map(g -> g.getId()).toList(), + LawLoader::getByString, + " name: %s\n laws:\n default: {}\n"); + case "tier" -> + new Catalog( + new TierLoader()::load, + () -> TierLoader.get().stream().map(g -> g.getId()).toList(), + TierLoader::getByString, + " name: %s\n tier: 1\n"); + case "regiment" -> + new Catalog( + new RegimentLoader()::loadRegiments, + () -> RegimentLoader.getRegiments().stream().map(g -> g.getId()).toList(), + RegimentLoader::getByString, + " name: %s\n default-slots: 2\n item:\n material: PAPER\n"); + default -> throw new IllegalArgumentException(kind); + }; + } + + static Stream<String> catalogs() { + return Stream.of("guild", "branch", "upgrade", "company", "law", "tier", "regiment"); + } + + private String row(Catalog catalog, String id, String name) { + return id + ":\n" + catalog.properties().formatted(name); + } + + @ParameterizedTest + @MethodSource("catalogs") + void reloadReplacesRemovedDefinitionsAndDoesNotDuplicateSurvivingIds(String kind) + throws Exception { + Catalog catalog = catalog(kind); + Path file = + Files.writeString( + directory.resolve(kind + ".yml"), + row(catalog, "alpha", "Alpha") + row(catalog, "beta", "Beta")); + catalog.load().accept(file.toFile()); + assertEquals(Set.of("alpha", "beta"), new HashSet<>(catalog.ids().get())); + Object original = catalog.find().apply("BETA"); + assertNotNull(original); + Files.writeString(file, row(catalog, "beta", "Updated") + row(catalog, "gamma", "Gamma")); + catalog.load().accept(file.toFile()); + assertEquals(2, catalog.ids().get().size()); + assertEquals(Set.of("beta", "gamma"), new HashSet<>(catalog.ids().get())); + assertNull(catalog.find().apply("ALPHA")); + assertNotSame(original, catalog.find().apply("beta")); + } + + @ParameterizedTest + @MethodSource("catalogs") + void malformedOrUnreadableReloadPreservesThePreviousCatalog(String kind) throws Exception { + Catalog catalog = catalog(kind); + Path file = Files.writeString(directory.resolve(kind + ".yml"), row(catalog, "alpha", "Alpha")); + catalog.load().accept(file.toFile()); + Object original = catalog.find().apply("alpha"); + Files.writeString(file, "broken: [\n"); + assertThrows(IllegalStateException.class, () -> catalog.load().accept(file.toFile())); + assertEquals(List.of("alpha"), catalog.ids().get()); + assertSame(original, catalog.find().apply("alpha")); + Files.writeString(file, row(catalog, "replacement", "Replacement") + "invalid: 42\n"); + assertThrows(IllegalStateException.class, () -> catalog.load().accept(file.toFile())); + assertEquals(List.of("alpha"), catalog.ids().get()); + assertSame(original, catalog.find().apply("alpha")); + Files.delete(file); + assertThrows(IllegalStateException.class, () -> catalog.load().accept(file.toFile())); + assertSame(original, catalog.find().apply("alpha")); + } + + @Test + void politicalActionReloadReplacesOldActionsButRetainsTheNoneAction() throws Exception { + Path file = Files.writeString(directory.resolve("actions.yml"), "CHANGE_LEADER: {}\n"); + PoliticalActionLoader loader = new PoliticalActionLoader(); + loader.load(file.toFile()); + assertNotNull(PoliticalActionLoader.getByAction(Action.CHANGE_LEADER)); + Files.writeString(file, "SURRENDER: {}\n"); + loader.load(file.toFile()); + assertEquals(Set.of(Action.NONE, Action.SURRENDER), PoliticalActionLoader.get().keySet()); + assertEquals(2, PoliticalActionLoader.getList().size()); + assertNull(PoliticalActionLoader.getByAction(Action.CHANGE_LEADER)); + Object original = PoliticalActionLoader.getByAction(Action.SURRENDER); + Files.writeString(file, "SURRENDER: {}\ninvalid-action: {}\n"); + assertThrows(IllegalStateException.class, () -> loader.load(file.toFile())); + assertSame(original, PoliticalActionLoader.getByAction(Action.SURRENDER)); + Files.writeString(file, "broken: [\n"); + assertThrows(IllegalStateException.class, () -> loader.load(file.toFile())); + assertSame(original, PoliticalActionLoader.getByAction(Action.SURRENDER)); + } + + @Test + void guildFallbackAndTierOrderingWorkForUnflaggedDefinitions() throws Exception { + Path guilds = Files.writeString(directory.resolve("guilds.yml"), "ordinary: {}\n"); + new GuildLoader().load(guilds.toFile()); + assertSame(GuildLoader.getByString("ordinary"), GuildLoader.getBaseType()); + assertSame(GuildLoader.getByString("ordinary"), GuildLoader.getDefaultType()); + Path tiers = + Files.writeString(directory.resolve("tiers.yml"), "high:\n tier: 3\nlow:\n tier: 1\n"); + new TierLoader().load(tiers.toFile()); + assertEquals("low", TierLoader.getLowest().getId()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/loaders/CatalogStartupFailureCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/loaders/CatalogStartupFailureCoverageTest.java new file mode 100644 index 00000000..7563eb22 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/loaders/CatalogStartupFailureCoverageTest.java @@ -0,0 +1,236 @@ +package net.tfminecraft.simplefactions.loaders; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import net.tfminecraft.simplefactions.government.movement.Action; +import net.tfminecraft.simplefactions.government.movement.PoliticalAction; +import net.tfminecraft.simplefactions.guild.upgrade.Upgrade; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.PersistenceFilesFixture; +import net.tfminecraft.simplefactions.war.battle.template.BattleTemplate; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class CatalogStartupFailureCoverageTest { + private FactionDomainFixture domain; + private PersistenceFilesFixture files; + private Map<String, Upgrade> previousCompany; + private Map<Action, PoliticalAction> previousActions; + private Map<String, BattleTemplate> previousTemplates; + + @BeforeEach + void setup() throws Exception { + files = new PersistenceFilesFixture(); + domain = new FactionDomainFixture(); + previousCompany = new LinkedHashMap<>(CompanyUpgradeLoader.get()); + previousActions = new LinkedHashMap<>(PoliticalActionLoader.get()); + previousTemplates = BattleTemplateLoader.getAll(); + CompanyUpgradeLoader.get().clear(); + PoliticalActionLoader.get().clear(); + BattleTemplateLoader.resetForTests(); + RankLoader.ranks.clear(); + RegimentLoader.oList.clear(); + TierLoader.oList.clear(); + BranchLoader.map.clear(); + LawLoader.map.clear(); + GuildLoader.map.clear(); + UpgradeLoader.map.clear(); + RelationLoader.types.clear(); + RelationLoader.attitudes.clear(); + } + + @AfterEach + void restore() throws Exception { + try { + CompanyUpgradeLoader.get().clear(); + CompanyUpgradeLoader.get().putAll(previousCompany); + PoliticalActionLoader.get().clear(); + PoliticalActionLoader.get().putAll(previousActions); + BattleTemplateLoader.resetForTests(); + previousTemplates.values().forEach(BattleTemplateLoader::putForTests); + domain.close(); + } finally { + files.close(); + } + } + + private void load(String kind, File file) { + switch (kind) { + case "rank" -> new RankLoader().loadRanks(file); + case "regiment" -> new RegimentLoader().loadRegiments(file); + case "tier" -> new TierLoader().load(file); + case "branch" -> new BranchLoader().load(file); + case "law" -> new LawLoader().load(file); + case "guild" -> new GuildLoader().load(file); + case "upgrade" -> new UpgradeLoader().load(file); + case "company" -> new CompanyUpgradeLoader().load(file); + case "political" -> new PoliticalActionLoader().load(file); + case "relations" -> new RelationLoader().loadRelationTypes(file); + case "attitudes" -> new RelationLoader().loadAttitudes(file); + case "templates" -> new BattleTemplateLoader().load(file); + default -> throw new IllegalArgumentException(kind); + } + } + + private List<?> entries(String kind) { + return switch (kind) { + case "rank" -> List.copyOf(RankLoader.ranks); + case "regiment" -> List.copyOf(RegimentLoader.getRegiments()); + case "tier" -> List.copyOf(TierLoader.get()); + case "branch" -> BranchLoader.getList(); + case "law" -> LawLoader.getList(); + case "guild" -> GuildLoader.getList(); + case "upgrade" -> UpgradeLoader.getList(); + case "company" -> CompanyUpgradeLoader.getList(); + case "political" -> PoliticalActionLoader.getList(); + case "relations" -> List.copyOf(RelationLoader.types); + case "attitudes" -> List.copyOf(RelationLoader.attitudes); + case "templates" -> List.copyOf(BattleTemplateLoader.getAll().values()); + default -> throw new IllegalArgumentException(kind); + }; + } + + private String validYaml(String kind) { + return switch (kind) { + case "political" -> "CHANGE_LEADER: {}\n"; + case "relations" -> "types:\n alpha: {}\nattitudes: {}\n"; + case "attitudes" -> "types: {}\nattitudes:\n alpha: {}\n"; + case "templates" -> "alpha:\n type: field\n"; + default -> + "alpha:\n" + + " name: Alpha\n" + + " tier: 1\n" + + " laws:\n" + + " default: {}\n" + + " item:\n" + + " material: PAPER\n"; + }; + } + + @ParameterizedTest + @ValueSource( + strings = { + "rank", + "regiment", + "tier", + "branch", + "law", + "guild", + "upgrade", + "company", + "political", + "relations", + "attitudes", + "templates" + }) + void malformedInitialCatalogFailsExplicitlyWithoutPublishingPartialDefinitions(String kind) + throws Exception { + Path file = files.write(kind + ".yml", "broken: [unterminated\n"); + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> load(kind, file.toFile())); + assertTrue(failure.getMessage().contains(file.getFileName().toString()), failure.getMessage()); + assertTrue(entries(kind).isEmpty(), "A rejected initial catalog has no published definitions"); + assertEquals("broken: [unterminated\n", Files.readString(file)); + Files.delete(file); + Files.createDirectory(file); + assertThrows(IllegalStateException.class, () -> load(kind, file.toFile())); + assertTrue(entries(kind).isEmpty()); + assertTrue(Files.isDirectory(file), "An unreadable catalog must not be replaced with defaults"); + } + + @ParameterizedTest + @ValueSource( + strings = { + "rank", + "regiment", + "tier", + "branch", + "law", + "guild", + "upgrade", + "company", + "political", + "relations", + "attitudes", + "templates" + }) + void failedReloadStillReportsFailureAndPreservesEveryLastKnownGoodObject(String kind) + throws Exception { + Path file = files.write(kind + ".yml", validYaml(kind)); + load(kind, file.toFile()); + List<?> previous = entries(kind); + assertFalse(previous.isEmpty()); + Files.writeString(file, "broken: [unterminated\n"); + assertThrows(IllegalStateException.class, () -> load(kind, file.toFile())); + List<?> retained = entries(kind); + assertEquals(previous.size(), retained.size()); + for (Object entry : previous) assertTrue(retained.stream().anyMatch(value -> value == entry)); + Files.delete(file); + assertThrows(IllegalStateException.class, () -> load(kind, file.toFile())); + assertEquals(previous, entries(kind)); + Files.writeString(file, validYaml(kind)); + assertDoesNotThrow(() -> load(kind, file.toFile())); + assertEquals(previous.size(), entries(kind).size()); + for (Object entry : previous) + assertFalse(entries(kind).stream().anyMatch(value -> value == entry)); + } + + @ParameterizedTest + @ValueSource( + strings = {"regiment", "tier", "branch", "law", "guild", "upgrade", "company", "templates"}) + void aSuccessfullyParsedEmptyCatalogIsNotMistakenForALoadFailure(String kind) throws Exception { + Path file = files.write(kind + ".yml", "{}\n"); + assertDoesNotThrow(() -> load(kind, file.toFile())); + assertTrue(entries(kind).isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = {"{", "null", "[]", "{\"good\":{\"name\":\"Good\"},\"bad\":42}"}) + void malformedInitialTitleFilesFailExplicitlyAndRemainUnchanged(String json) throws Exception { + domain.tier("county", 1, 0); + Path file = files.write("Input/county.json", json); + IllegalStateException failure = + assertThrows(IllegalStateException.class, new TitleLoader()::loadAll); + assertTrue(failure.getMessage().contains("county.json"), failure.getMessage()); + assertTrue(TitleLoader.getTitles().isEmpty()); + assertEquals(json, Files.readString(file)); + } + + @Test + void optionalAbsentAndEmptyTitleFilesRemainValidWhileUnreadablePresentFilesFail() + throws Exception { + domain.tier("county", 1, 0); + TitleLoader loader = new TitleLoader(); + assertDoesNotThrow(loader::loadAll); + assertTrue(TitleLoader.getTitles().isEmpty()); + Path file = files.write("Input/county.json", "{}"); + assertDoesNotThrow(loader::loadAll); + assertTrue(TitleLoader.getTitles().isEmpty()); + Files.delete(file); + Files.createDirectory(file); + IllegalStateException failure = assertThrows(IllegalStateException.class, loader::loadAll); + assertTrue(failure.getMessage().contains("county.json"), failure.getMessage()); + assertTrue(Files.isDirectory(file)); + } + + @ParameterizedTest + @ValueSource(strings = {"config", "war"}) + void malformedPrimaryConfigurationFailsInsteadOfStartingWithDefaultState(String kind) + throws Exception { + Path file = files.write(kind + ".yml", "broken: [unterminated\n"); + ConfigLoader loader = new ConfigLoader(); + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> { + if (kind.equals("config")) loader.loadConfig(file.toFile()); + else loader.loadWar(file.toFile()); + }); + assertTrue(failure.getMessage().contains(file.getFileName().toString()), failure.getMessage()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/loaders/ConfigurationValidationCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/loaders/ConfigurationValidationCoverageTest.java new file mode 100644 index 00000000..7bb8a043 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/loaders/ConfigurationValidationCoverageTest.java @@ -0,0 +1,515 @@ +package net.tfminecraft.simplefactions.loaders; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.*; +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.enums.*; +import net.tfminecraft.simplefactions.government.stability.StabilityTuning; +import net.tfminecraft.simplefactions.guild.hub.HubTransport; +import net.tfminecraft.simplefactions.guild.hub.OpenTrackSettings; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.war.battle.enums.DefenderRespawnMode; +import net.tfminecraft.simplefactions.war.enums.WarGoalType; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ConfigurationValidationCoverageTest { + @TempDir Path directory; + private FactionDomainFixture domain; + private final Map<Field, Object> saved = new HashMap<>(); + private final Map<Field, Object> otherSettings = new LinkedHashMap<>(); + + @BeforeEach + void setup() throws Exception { + domain = new FactionDomainFixture(); + remember(StabilityTuning.class, "active"); + remember(HubTransport.class, "rates"); + for (String name : List.of("enabled", "share", "keptPer1000", "rangeBlocks")) + remember(OpenTrackSettings.class, name); + for (Field field : Cache.class.getFields()) { + if (!Modifier.isStatic(field.getModifiers()) || Modifier.isFinal(field.getModifiers())) + continue; + Object value = field.get(null); + if (value instanceof Map<?, ?> map) value = new HashMap<>(map); + else if (value instanceof List<?> list) value = new ArrayList<>(list); + saved.put(field, value); + } + } + + @AfterEach + void close() throws Exception { + try { + for (var entry : saved.entrySet()) entry.getKey().set(null, entry.getValue()); + for (var entry : otherSettings.entrySet()) entry.getKey().set(null, entry.getValue()); + } finally { + domain.close(); + } + } + + private void remember(Class<?> owner, String name) throws Exception { + Field field = owner.getDeclaredField(name); + field.setAccessible(true); + otherSettings.put(field, field.get(null)); + } + + private Path write(String name, String yaml) throws Exception { + return Files.writeString(directory.resolve(name), yaml); + } + + @ParameterizedTest + @ValueSource( + strings = { + "defender_choice_deadline_hour=16", + "vote_close_hour=19", + "raid_window_start_hour=21", + "raid_window_end_hour=21", + "window_end_hour=20", + "window_end_hour=25" + }) + void invalidScheduleOrderingIsRejectedWithTheSettingName(String input) throws Exception { + String[] parts = input.split("="); + YamlConfiguration config = new YamlConfiguration(); + config.set("war.battle_schedule." + parts[0], Integer.parseInt(parts[1])); + Path path = write("war.yml", config.saveToString()); + IllegalStateException error = + assertThrows(IllegalStateException.class, () -> new ConfigLoader().loadWar(path.toFile())); + assertTrue(error.getMessage().contains(parts[0])); + } + + @ParameterizedTest + @ValueSource( + strings = { + "battle_voting.min_players", + "battle_voting.dev_min_players", + "campaign_raid.muster_seconds", + "campaign_raid.duration_seconds", + "campaign_raid.repair_lock_hours", + "campaign_raid.intruder_damage_interval_ticks", + "campaign_raid.intruder_damage_amount", + "devmode.phantom_count" + }) + void invalidWarMinimumsAreRejected(String key) throws Exception { + YamlConfiguration config = new YamlConfiguration(); + config.set("war." + key, -1); + Path path = write("war.yml", config.saveToString()); + IllegalStateException error = + assertThrows(IllegalStateException.class, () -> new ConfigLoader().loadWar(path.toFile())); + assertTrue(error.getMessage().contains(key)); + } + + @ParameterizedTest + @ValueSource( + strings = { + "province_poll_interval_ticks", + "capture_min_players", + "province_leave_countdown_seconds", + "siege.contest_duration_seconds" + }) + void invalidBattleMinimumsAreRejected(String key) throws Exception { + YamlConfiguration config = new YamlConfiguration(); + config.set("battle." + key, 0); + Path path = write("config.yml", config.saveToString()); + IllegalStateException error = + assertThrows( + IllegalStateException.class, () -> new ConfigLoader().loadConfig(path.toFile())); + assertTrue(error.getMessage().contains(key)); + } + + @Test + void reminderListsFilterNonNumbersSortOffsetsAndRejectNonPositiveNumbers() throws Exception { + Path config = + write( + "config.yml", + "battle:\n" + + " signup_reminder_seconds_before: [10, junk, 30, 20]\n" + + " raid:\n" + + " defender_respawn_mode_default: invalid\n"); + new ConfigLoader().loadConfig(config.toFile()); + assertEquals(List.of(30, 20, 10), Cache.battleSignupReminderSecondsBefore); + assertEquals(DefenderRespawnMode.INFINITE, Cache.battleRaidDefenderRespawnModeDefault); + Files.writeString(config, "battle:\n signup_reminder_seconds_before: [0]\n"); + assertThrows(IllegalStateException.class, () -> new ConfigLoader().loadConfig(config.toFile())); + Path war = + write("war.yml", "war:\n campaign_raid:\n muster_reminder_seconds_before: [-1]\n"); + assertThrows(IllegalStateException.class, () -> new ConfigLoader().loadWar(war.toFile())); + } + + @Test + void legacyWarFallbackReadsConfigAndClampsBattleLimits() throws Exception { + write( + "config.yml", + "war:\n" + + " declare_opinion_threshold: -77\n" + + " autoresolve:\n" + + " luck: -2\n" + + " goals:\n" + + " SUBJUGATE:\n" + + " max_battles_per_leg: 999\n"); + new ConfigLoader().loadWar(directory.resolve("war.yml").toFile()); + assertEquals(-77, Cache.warDeclareOpinionThreshold); + assertEquals(0, Cache.warAutoresolveLuck); + assertEquals(Cache.MAX_BATTLES_PER_LEG, Cache.warGoalMaxBattles.get(WarGoalType.SUBJUGATE)); + Path war = write("war.yml", "war:\n autoresolve:\n luck: 2\n"); + new ConfigLoader().loadWar(war.toFile()); + assertEquals(1, Cache.warAutoresolveLuck); + } + + @Test + void legacyTransportAndValidEffectListsAreParsed() throws Exception { + Path path = + write( + "config.yml", + "supply-hubs:\n" + + " corridor-share: 0.3\n" + + "max-trade-upkeep: -4\n" + + "terrain-modifiers:\n" + + " - 'PLAINS 0.7'\n" + + " - 'wrong-token-count'\n" + + " - 'missing 0.8'\n" + + "icons:\n" + + " - 'example(tfmc:example)'\n" + + "base-effects:\n" + + " DOMESTIC_GUILDS:\n" + + " modifiers: ['TRADE_POWER(2)']\n" + + " INVALID: {}\n"); + new ConfigLoader().loadConfig(path.toFile()); + assertEquals(0.3, Cache.supplyHubCorridorShare); + assertEquals( + net.tfminecraft.simplefactions.guild.income.TradeUpkeep.DEFAULT_MAX, Cache.maxTradeUpkeep); + assertEquals(0.7, Cache.tradeCarry.get(Terrain.PLAINS)); + assertEquals("tfmc:example", Cache.icons.get("example")); + assertTrue(Cache.baseEffects.containsKey(Scope.DOMESTIC_GUILDS)); + } + + @Test + void malformedIconAndScalarEffectsDoNotAbortTheConfigurationLoad() throws Exception { + Path path = + write("config.yml", "icons:\n - 'broken'\n - 'good(tfmc:good)'\nbase-effects: bad\n"); + assertDoesNotThrow(() -> new ConfigLoader().loadConfig(path.toFile())); + assertEquals("tfmc:good", Cache.icons.get("good")); + assertFalse(Cache.icons.containsKey("broken")); + } + + @Test + void invalidEconomySettingsUseTheirDocumentedDefaults() throws Exception { + Path config = + write( + "config.yml", + "elevation-base: -1\n" + + "elevation-size-multiplier: .nan\n" + + "elevation-exponent: .inf\n" + + "eviction-multiplier: -2\n"); + new ConfigLoader().loadConfig(config.toFile()); + assertEquals(25.0, Cache.elevationBase); + assertEquals(1.0, Cache.elevationSizeMultiplier); + assertEquals(1.1, Cache.elevationExponent); + assertEquals(2.0, Cache.evictionMultiplier); + } + + @Test + void malformedYamlKeepsTheLastLoadedConfiguration() throws Exception { + Path config = write("config.yml", "max-members: 9\n"); + new ConfigLoader().loadConfig(config.toFile()); + Files.writeString(config, "broken: [\n"); + assertThrows(IllegalStateException.class, () -> new ConfigLoader().loadConfig(config.toFile())); + assertEquals(9, Cache.maxMembers); + Path war = write("war.yml", "war:\n declare_opinion_threshold: -77\n"); + new ConfigLoader().loadWar(war.toFile()); + Files.writeString(war, "broken: [\n"); + assertThrows(IllegalStateException.class, () -> new ConfigLoader().loadWar(war.toFile())); + assertEquals(-77, Cache.warDeclareOpinionThreshold); + } + + @Test + void terrainNamesAreIndependentOfTheHostLocale() throws Exception { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + Cache.tradeCarry.remove(Terrain.PLAINS); + Path path = write("config.yml", "terrain-modifiers: ['plains 0.25']\n"); + new ConfigLoader().loadConfig(path.toFile()); + assertEquals(0.25, Cache.tradeCarry.get(Terrain.PLAINS)); + } finally { + Locale.setDefault(previous); + } + } + + @ParameterizedTest + @ValueSource( + strings = { + "province_poll_interval_ticks", + "capture_min_players", + "province_leave_countdown_seconds", + "siege.contest_duration_seconds", + "signup_reminder_seconds_before" + }) + void invalidBattleReloadRetainsEveryPublishedSetting(String key) throws Exception { + ConfigLoader loader = new ConfigLoader(); + Path path = write("config.yml", mainSettings(true).saveToString()); + loader.loadConfig(path.toFile()); + PublishedSettings prior = PublishedSettings.capture(); + YamlConfiguration rejected = mainSettings(false); + rejected.set("battle." + key, key.endsWith("seconds_before") ? List.of(40, 0) : 0); + Files.writeString(path, rejected.saveToString()); + + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> loader.loadConfig(path.toFile())); + assertTrue(failure.getMessage().contains(key)); + prior.assertUnchanged(); + + Files.writeString(path, mainSettings(false).saveToString()); + loader.loadConfig(path.toFile()); + assertEquals(24, Cache.maxMembers); + assertEquals("candidate-world", Cache.worldName); + assertEquals(66, StabilityTuning.get().legitimacyCommunity); + assertEquals(0.7, HubTransport.rates(HubTransport.Mode.RAIL).trade()); + assertTrue(OpenTrackSettings.enabled()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "battle_schedule.defender_choice_deadline_hour=-1", + "battle_schedule.defender_choice_deadline_hour=16", + "battle_schedule.vote_close_hour=19", + "battle_schedule.raid_window_start_hour=21", + "battle_schedule.raid_window_end_hour=21", + "battle_schedule.window_start_hour=25", + "battle_schedule.window_end_hour=20", + "battle_schedule.window_end_hour=25", + "battle_voting.min_players=0", + "battle_voting.dev_min_players=0", + "campaign_raid.muster_seconds=0", + "campaign_raid.duration_seconds=0", + "campaign_raid.repair_lock_hours=0", + "campaign_raid.intruder_damage_interval_ticks=0", + "campaign_raid.intruder_damage_amount=0", + "campaign_raid.muster_reminder_seconds_before=0", + "devmode.phantom_count=-1" + }) + void invalidWarReloadRetainsEveryPublishedSetting(String input) throws Exception { + ConfigLoader loader = new ConfigLoader(); + Path path = write("war.yml", warSettings(true).saveToString()); + loader.loadWar(path.toFile()); + PublishedSettings prior = PublishedSettings.capture(); + String[] setting = input.split("="); + int invalid = Integer.parseInt(setting[1]); + YamlConfiguration rejected = warSettings(false); + rejected.set( + "war." + setting[0], + setting[0].endsWith("seconds_before") ? List.of(45, invalid) : invalid); + Files.writeString(path, rejected.saveToString()); + + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> loader.loadWar(path.toFile())); + assertTrue( + failure.getMessage().contains(setting[0].substring(setting[0].lastIndexOf('.') + 1))); + prior.assertUnchanged(); + + Files.writeString(path, warSettings(false).saveToString()); + loader.loadWar(path.toFile()); + assertEquals(-22, Cache.warDeclareOpinionThreshold); + assertEquals(16, Cache.warVoteCloseHour); + assertEquals(Cache.MAX_BATTLES_PER_LEG, Cache.warGoalMaxBattles.get(WarGoalType.SUBJUGATE)); + assertEquals(List.of("say candidate"), Cache.battleLootCommands); + } + + @Test + void invalidLegacyWarReloadRetainsEveryPublishedSetting() throws Exception { + ConfigLoader loader = new ConfigLoader(); + Path war = write("war.yml", warSettings(true).saveToString()); + loader.loadWar(war.toFile()); + PublishedSettings prior = PublishedSettings.capture(); + YamlConfiguration rejected = warSettings(false); + rejected.set("war.campaign_raid.muster_seconds", 0); + Path legacy = write("config.yml", rejected.saveToString()); + Files.writeString(war, "{}\n"); + + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> loader.loadWar(war.toFile())); + assertTrue(failure.getMessage().contains("war.campaign_raid.muster_seconds")); + prior.assertUnchanged(); + + Files.writeString(legacy, warSettings(false).saveToString()); + loader.loadWar(war.toFile()); + assertEquals(-22, Cache.warDeclareOpinionThreshold); + assertEquals(60, Cache.campaignRaidMusterSeconds); + } + + @Test + void minimumValidNumbersAndDocumentedClampsStillLoad() throws Exception { + YamlConfiguration config = mainSettings(false); + config.set("battle.province_poll_interval_ticks", 1); + config.set("battle.capture_min_players", 1); + config.set("battle.province_leave_countdown_seconds", 1); + config.set("battle.siege.contest_duration_seconds", 1); + config.set("battle.signup_reminder_seconds_before", List.of(1)); + config.set("battle.raid.defender_respawn_mode_default", "unknown"); + config.set("battle.item_durability_multiplier", 2); + config.set("battle.empty_side_grace_seconds", -3); + new ConfigLoader().loadConfig(write("config.yml", config.saveToString()).toFile()); + assertEquals(1, Cache.battleProvincePollIntervalTicks); + assertEquals(1, Cache.battleCaptureMinPlayers); + assertEquals(1, Cache.battleProvinceLeaveCountdownSeconds); + assertEquals(1, Cache.battleSiegeContestDurationSeconds); + assertEquals(List.of(1), Cache.battleSignupReminderSecondsBefore); + assertEquals(DefenderRespawnMode.INFINITE, Cache.battleRaidDefenderRespawnModeDefault); + assertEquals(1.0, Cache.battleItemDurabilityMultiplier); + assertEquals(0, Cache.battleEmptySideGraceSeconds); + + YamlConfiguration war = warSettings(false); + war.set("war.battle_schedule.defender_choice_deadline_hour", 0); + war.set("war.battle_schedule.vote_close_hour", 1); + war.set("war.battle_schedule.raid_window_start_hour", 2); + war.set("war.battle_schedule.raid_window_end_hour", 2); + war.set("war.battle_schedule.window_start_hour", 3); + war.set("war.battle_schedule.window_end_hour", 24); + war.set("war.battle_voting.min_players", 1); + war.set("war.battle_voting.dev_min_players", 1); + war.set("war.devmode.phantom_count", 0); + for (String key : + List.of( + "muster_seconds", + "duration_seconds", + "repair_lock_hours", + "intruder_damage_interval_ticks", + "intruder_damage_amount")) war.set("war.campaign_raid." + key, 1); + war.set("war.campaign_raid.muster_reminder_seconds_before", List.of(1)); + war.set("war.goals.SUBJUGATE.max_battles_per_leg", 999); + war.set("war.declare_code_timeout_seconds", -5); + new ConfigLoader().loadWar(write("war.yml", war.saveToString()).toFile()); + assertEquals(0, Cache.warDefenderChoiceDeadlineHour); + assertEquals(24, Cache.warBattleWindowEndHour); + assertEquals(1, Cache.warBattleVotingMinPlayers); + assertEquals(1, Cache.warBattleVotingDevMinPlayers); + assertEquals(0, Cache.warDevmodePhantomCount); + assertEquals(1, Cache.campaignRaidMusterSeconds); + assertEquals(1, Cache.campaignRaidDurationSeconds); + assertEquals(1, Cache.campaignRaidRepairLockHours); + assertEquals(1, Cache.campaignRaidIntruderDamageIntervalTicks); + assertEquals(1, Cache.campaignRaidIntruderDamageAmount); + assertEquals(List.of(1), Cache.campaignRaidMusterReminderSecondsBefore); + assertEquals(Cache.MAX_BATTLES_PER_LEG, Cache.warGoalMaxBattles.get(WarGoalType.SUBJUGATE)); + assertEquals(1, Cache.warDeclareCodeTimeoutSeconds); + } + + private static YamlConfiguration mainSettings(boolean prior) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("map-reference", prior ? "loaded-map" : "candidate-map"); + yaml.set("world-name", prior ? "loaded-world" : "candidate-world"); + yaml.set("max-members", prior ? 12 : 24); + yaml.set("capital-move-cost", prior ? 50 : 150); + yaml.set("stability.legitimacy-community", prior ? 77 : 66); + yaml.set("installation-trade.corridor-share", prior ? 0.2 : 0.8); + yaml.set("installation-trade.transport.rail.trade", prior ? 0.3 : 0.7); + yaml.set("installation-trade.transport.sea.production", prior ? 0.25 : 0.65); + yaml.set("installation-trade.open-track.enabled", !prior); + yaml.set("installation-trade.open-track.share", prior ? 0.4 : 0.9); + yaml.set("installation-trade.open-track.kept-per-1000-blocks", prior ? 0.6 : 0.8); + yaml.set("installation-trade.open-track.range-blocks", prior ? 1200 : 2400); + yaml.set("battle.province_poll_interval_ticks", prior ? 30 : 40); + yaml.set("battle.capture_min_players", prior ? 2 : 3); + yaml.set("battle.province_leave_countdown_seconds", prior ? 10 : 20); + yaml.set("battle.siege.contest_duration_seconds", prior ? 120 : 240); + yaml.set("battle.signup_reminder_seconds_before", prior ? List.of(60, 30) : List.of(40, 20)); + yaml.set("branch-upgrade-cost", prior ? 300 : 600); + yaml.set("terrain-modifiers", List.of(prior ? "PLAINS 0.25" : "PLAINS 0.75")); + yaml.set("icons", List.of(prior ? "prior(v.STONE)" : "candidate(v.DIRT)")); + yaml.set( + "base-effects.DOMESTIC_GUILDS.modifiers", + List.of(prior ? "TRADE_POWER(2)" : "TRADE_POWER(3)")); + return yaml; + } + + private static YamlConfiguration warSettings(boolean prior) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("war.declare_opinion_threshold", prior ? -77 : -22); + yaml.set("war.initiative_factor", prior ? 2.5 : 4.5); + yaml.set("war.goals.SUBJUGATE.max_battles_per_leg", prior ? 3 : 5); + yaml.set( + "war.goals.OPEN_MARKET.defender_must_not_have", + List.of(prior ? "prior-law" : "candidate-law")); + yaml.set("war.battle_schedule.defender_choice_deadline_hour", prior ? 2 : 12); + yaml.set("war.battle_schedule.vote_close_hour", prior ? 4 : 16); + yaml.set("war.battle_schedule.raid_window_start_hour", prior ? 6 : 19); + yaml.set("war.battle_schedule.raid_window_end_hour", prior ? 8 : 20); + yaml.set("war.battle_schedule.window_start_hour", prior ? 10 : 21); + yaml.set("war.battle_schedule.window_end_hour", prior ? 12 : 24); + yaml.set("war.battle_voting.min_players", prior ? 7 : 4); + yaml.set("war.battle_voting.dev_min_players", prior ? 8 : 2); + yaml.set("war.battle_military.lives_per_regiment", prior ? 7 : 8); + yaml.set("war.battle_loot.commands", List.of(prior ? "say prior" : "say candidate")); + yaml.set("war.devmode.phantom_count", prior ? 9 : 10); + yaml.set("war.campaign_raid.muster_seconds", prior ? 70 : 60); + yaml.set("war.campaign_raid.duration_seconds", prior ? 800 : 600); + yaml.set("war.campaign_raid.repair_lock_hours", prior ? 60 : 48); + yaml.set("war.campaign_raid.intruder_damage_interval_ticks", prior ? 15 : 10); + yaml.set("war.campaign_raid.intruder_damage_amount", prior ? 7 : 4); + yaml.set( + "war.campaign_raid.muster_reminder_seconds_before", + prior ? List.of(50, 20) : List.of(45, 30, 15, 10)); + return yaml; + } + + /** Publicly published settings must be unchanged, including lists retained by running tasks. */ + private record PublishedSettings( + Map<Field, Object> references, + Map<Field, Object> contents, + StabilityTuning stability, + Map<HubTransport.Mode, HubTransport.Rates> transport, + boolean openTrackEnabled, + double openTrackShare, + double openTrackKept, + double openTrackRange) { + static PublishedSettings capture() throws IllegalAccessException { + Map<Field, Object> references = new LinkedHashMap<>(); + Map<Field, Object> contents = new LinkedHashMap<>(); + for (Field field : Cache.class.getFields()) { + if (!Modifier.isStatic(field.getModifiers()) || Modifier.isFinal(field.getModifiers())) + continue; + Object value = field.get(null); + references.put(field, value); + if (value instanceof Map<?, ?> map) value = new HashMap<>(map); + else if (value instanceof List<?> list) value = new ArrayList<>(list); + contents.put(field, value); + } + Map<HubTransport.Mode, HubTransport.Rates> transport = new EnumMap<>(HubTransport.Mode.class); + for (HubTransport.Mode mode : HubTransport.Mode.values()) + transport.put(mode, HubTransport.rates(mode)); + return new PublishedSettings( + references, + contents, + StabilityTuning.get(), + transport, + OpenTrackSettings.enabled(), + OpenTrackSettings.share(), + OpenTrackSettings.keptPer1000(), + OpenTrackSettings.rangeBlocks()); + } + + void assertUnchanged() { + List<org.junit.jupiter.api.function.Executable> checks = new ArrayList<>(); + for (var entry : references.entrySet()) { + Field field = entry.getKey(); + checks.add(() -> assertEquals(contents.get(field), field.get(null), field.getName())); + if (entry.getValue() instanceof Map<?, ?> || entry.getValue() instanceof List<?>) + checks.add( + () -> assertSame(entry.getValue(), field.get(null), field.getName() + " reference")); + } + checks.add(() -> assertSame(stability, StabilityTuning.get())); + for (var entry : transport.entrySet()) + checks.add(() -> assertSame(entry.getValue(), HubTransport.rates(entry.getKey()))); + checks.add(() -> assertEquals(openTrackEnabled, OpenTrackSettings.enabled())); + checks.add(() -> assertEquals(openTrackShare, OpenTrackSettings.share())); + checks.add(() -> assertEquals(openTrackKept, OpenTrackSettings.keptPer1000())); + checks.add(() -> assertEquals(openTrackRange, OpenTrackSettings.rangeBlocks())); + assertAll(checks); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/loaders/InstallationConfigLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/loaders/InstallationConfigLifecycleCoverageTest.java new file mode 100644 index 00000000..d4ddf8cf --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/loaders/InstallationConfigLifecycleCoverageTest.java @@ -0,0 +1,314 @@ +package net.tfminecraft.simplefactions.loaders; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.logging.Logger; +import java.util.stream.Stream; +import net.tfminecraft.simplefactions.installation.InstallationKind; +import net.tfminecraft.simplefactions.installation.InstallationKindConfig; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +class InstallationConfigLifecycleCoverageTest { + @TempDir Path temporary; + private GuiTestFixture fixture; + private final Map<Field, Object> globals = new LinkedHashMap<>(); + private final Map<InstallationKind, InstallationKindConfig> previousKinds = + new EnumMap<>(InstallationKind.class); + private Locale previousLocale; + private Logger logger; + + @BeforeEach + void setup() throws Exception { + fixture = new GuiTestFixture(); + previousLocale = Locale.getDefault(); + Locale.setDefault(Locale.ROOT); + previousKinds.putAll(InstallationConfigLoader.getAll()); + kinds().clear(); + remember(InstallationConfigLoader.class, "consentProximityBlocks"); + remember(InstallationConfigLoader.class, "transferRequestTimeoutSeconds"); + for (String name : + List.of( + "personalSlotLimit", + "defaultPerPerson", + "maintenanceHourlyDamagePercent", + "maintenanceMinHealthPercent", + "maintenanceIntervalTicks", + "categoryIds", + "typesByCategory", + "categoryByVehicleTypeId", + "categoryDisplayNames", + "feeExcludedCategories")) { + remember(VehiclesConfigLoader.class, name); + } + logger = mock(Logger.class); + when(Bukkit.getLogger()).thenReturn(logger); + YamlConfiguration vehicles = new YamlConfiguration(); + for (String category : + List.of("static_emplacements", "aircraft", "ships", "land_vehicles", "train")) { + vehicles.createSection("categories." + category); + } + Path path = temporary.resolve("vehicles.yml"); + vehicles.save(path.toFile()); + VehiclesConfigLoader.load(path.toFile()); + } + + @AfterEach + void close() throws Exception { + kinds().clear(); + kinds().putAll(previousKinds); + for (var entry : globals.entrySet()) entry.getKey().set(null, entry.getValue()); + Locale.setDefault(previousLocale); + fixture.close(); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void failedReadPreservesTheWholePreviouslyLoadedConfiguration(boolean malformed) + throws Exception { + InstallationConfigLoader.load(write(valid()).toFile()); + Map<InstallationKind, InstallationKindConfig> before = + new EnumMap<>(InstallationConfigLoader.getAll()); + Path bad = temporary.resolve("bad.yml"); + if (malformed) Files.writeString(bad, "fort: [unterminated"); + + IllegalStateException error = + assertThrows( + IllegalStateException.class, () -> InstallationConfigLoader.load(bad.toFile())); + + assertEquals("Failed to load installations.yml", error.getMessage()); + assertEquals(before, InstallationConfigLoader.getAll()); + assertEquals(7, InstallationConfigLoader.getConsentProximityBlocks()); + assertEquals(33, InstallationConfigLoader.getTransferRequestTimeoutSeconds()); + verify(logger).severe("[SimpleFactions] Failed to load installations.yml"); + } + + @Test + void lateValidationFailureDoesNotPartiallyReplaceKindsOrGlobalSettings() throws Exception { + InstallationConfigLoader.load(write(valid()).toFile()); + Map<InstallationKind, InstallationKindConfig> before = + new EnumMap<>(InstallationConfigLoader.getAll()); + YamlConfiguration replacement = valid(); + replacement.set("consent-proximity-blocks", 91); + replacement.set("transfer-request-timeout-seconds", 99); + replacement.set("fort.daily-upkeep", 500); + replacement.set("airport.radius", null); + Path invalid = write(replacement); + + assertThrows( + IllegalStateException.class, () -> InstallationConfigLoader.load(invalid.toFile())); + + assertEquals(before, InstallationConfigLoader.getAll()); + assertEquals(7, InstallationConfigLoader.getConsentProximityBlocks()); + assertEquals(33, InstallationConfigLoader.getTransferRequestTimeoutSeconds()); + } + + @Test + void categoryIdentifiersRemainCaseInsensitiveUnderTurkishLocale() throws Exception { + YamlConfiguration config = valid(); + config.set("airport.slots", null); + config.set("airport.slots.AIRCRAFT", 3); + Path path = write(config); + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + + InstallationConfigLoader.load(path.toFile()); + + assertEquals( + 3, InstallationConfigLoader.getCategorySlotCapacity(InstallationKind.AIRPORT, "AIRCRAFT")); + assertEquals( + Map.of("aircraft", 3), InstallationConfigLoader.getCategorySlots(InstallationKind.AIRPORT)); + } + + @ParameterizedTest + @ValueSource(doubles = {Double.NaN, Double.POSITIVE_INFINITY}) + void upkeepMustBeFiniteBeforeItCanReachGuildBalances(double amount) throws Exception { + InstallationConfigLoader.load(write(valid()).toFile()); + Map<InstallationKind, InstallationKindConfig> before = + new EnumMap<>(InstallationConfigLoader.getAll()); + for (String key : List.of("fort.daily-upkeep", "fort.levels.2.daily-upkeep")) { + YamlConfiguration invalid = valid(); + invalid.set(key, amount); + Path path = write(invalid); + + assertThrows(IllegalStateException.class, () -> InstallationConfigLoader.load(path.toFile())); + + assertEquals(before, InstallationConfigLoader.getAll()); + assertEquals(4, InstallationConfigLoader.getDailyUpkeep(InstallationKind.FORT)); + assertEquals(9, InstallationConfigLoader.getDailyUpkeep(InstallationKind.FORT, 2)); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidConfigurations") + void invalidFieldsAreRejectedWithoutPublishingPartialSettings( + String key, Object value, String diagnostic) throws Exception { + InstallationConfigLoader.load(write(valid()).toFile()); + Map<InstallationKind, InstallationKindConfig> before = + new EnumMap<>(InstallationConfigLoader.getAll()); + YamlConfiguration invalid = valid(); + invalid.set(key, value); + Path path = write(invalid); + + IllegalStateException error = + assertThrows( + IllegalStateException.class, () -> InstallationConfigLoader.load(path.toFile())); + + assertTrue(error.getMessage().contains(diagnostic), error.getMessage()); + assertEquals(before, InstallationConfigLoader.getAll()); + assertEquals(7, InstallationConfigLoader.getConsentProximityBlocks()); + assertEquals(33, InstallationConfigLoader.getTransferRequestTimeoutSeconds()); + verify(logger).severe("[SimpleFactions] " + error.getMessage()); + } + + private static Stream<Arguments> invalidConfigurations() { + return Stream.of( + Arguments.of("consent-proximity-blocks", null, "consent-proximity-blocks is required"), + Arguments.of( + "transfer-request-timeout-seconds", + null, + "transfer-request-timeout-seconds is required"), + Arguments.of("consent-proximity-blocks", -1, "consent-proximity-blocks must be >= 0"), + Arguments.of( + "transfer-request-timeout-seconds", 0, "transfer-request-timeout-seconds must be > 0"), + Arguments.of("port", null, "missing required section: port"), + Arguments.of("fort.daily-upkeep", null, "fort.daily-upkeep is required"), + Arguments.of("fort.construction-time", null, "fort.construction-time is required"), + Arguments.of("fort.radius", null, "fort.radius is required"), + Arguments.of("fort.daily-upkeep", -1, "fort.daily-upkeep"), + Arguments.of("fort.construction-time", 0, "fort.construction-time must be > 0"), + Arguments.of("fort.radius", 0, "fort.radius must be > 0"), + Arguments.of("fort.slots", null, "fort.slots is required"), + Arguments.of("fort.slots.unknown", 1, "references unknown vehicle category"), + Arguments.of("fort.slots.static_emplacements", -1, "must be >= 0"), + Arguments.of("fort.levels", "invalid", "fort.levels must be a section"), + Arguments.of("fort.levels.two.daily-upkeep", 1, "must be a level number >= 2"), + Arguments.of("fort.levels.1.daily-upkeep", 1, "levels must start at 2 with no gaps"), + Arguments.of("fort.levels.02.daily-upkeep", 1, "levels must start at 2 with no gaps"), + Arguments.of("fort.levels.4.daily-upkeep", 1, "levels must start at 2 with no gaps"), + Arguments.of("fort.levels.2", "invalid", "levels must start at 2 with no gaps"), + Arguments.of("fort.levels.2.daily-upkeep", null, "fort.levels.2.daily-upkeep is required"), + Arguments.of( + "fort.levels.2.construction-time", null, "fort.levels.2.construction-time is required"), + Arguments.of("fort.levels.2.daily-upkeep", -1, "fort.levels.2.daily-upkeep"), + Arguments.of( + "fort.levels.2.construction-time", 0, "fort.levels.2.construction-time must be > 0"), + Arguments.of("fort.levels.2.slots", null, "fort.levels.2.slots is required")); + } + + @Test + void aCompleteReplacementPublishesReadOnlyLevelsAndCompatibleStationDefaults() throws Exception { + InstallationConfigLoader.load(write(valid()).toFile()); + YamlConfiguration replacement = valid(); + replacement.set("consent-proximity-blocks", 0); + replacement.set("transfer-request-timeout-seconds", 1); + replacement.set("fort.daily-upkeep", 0); + replacement.set("fort.slots.static_emplacements", 0); + replacement.set("fort.levels.3.daily-upkeep", 15); + replacement.set("fort.levels.3.construction-time", 60); + replacement.set("fort.levels.3.slots.static_emplacements", 5); + replacement.set("train_station", null); + + InstallationConfigLoader.load(write(replacement).toFile()); + + assertEquals(0, InstallationConfigLoader.getConsentProximityBlocks()); + assertEquals(1, InstallationConfigLoader.getTransferRequestTimeoutSeconds()); + assertEquals(4, InstallationConfigLoader.getAll().size()); + assertThrows( + UnsupportedOperationException.class, () -> InstallationConfigLoader.getAll().clear()); + InstallationKindConfig fort = InstallationConfigLoader.getAll().get(InstallationKind.FORT); + assertEquals(3, fort.getMaximumLevel()); + assertEquals(0, fort.getDailyUpkeep()); + assertEquals(15, fort.getDailyUpkeep(100)); + assertEquals(10, fort.getConstructionTimeSeconds()); + assertEquals(60, fort.getConstructionTimeSeconds(100)); + assertEquals(20, fort.getRadius()); + assertEquals(Map.of("static_emplacements", 0), fort.getCategorySlots()); + assertEquals(0, fort.getLevel(-1).dailyUpkeep()); + assertEquals(5, fort.getCategorySlots(3).get("static_emplacements")); + assertThrows(UnsupportedOperationException.class, () -> fort.getLevels().clear()); + assertThrows(UnsupportedOperationException.class, () -> fort.getCategorySlots().clear()); + assertEquals(0, InstallationConfigLoader.getCategorySlotCapacity(InstallationKind.FORT, null)); + assertEquals(0, InstallationConfigLoader.getCategorySlotCapacity(InstallationKind.FORT, "")); + assertEquals( + 0, InstallationConfigLoader.getCategorySlotCapacity(InstallationKind.FORT, "aircraft")); + assertEquals(3, InstallationConfigLoader.getMaximumLevel(InstallationKind.TRAIN_STATION)); + assertEquals(80, InstallationConfigLoader.getRadius(InstallationKind.TRAIN_STATION)); + assertEquals(100, InstallationConfigLoader.getDailyUpkeep(InstallationKind.TRAIN_STATION, 3)); + assertEquals( + 432000, + InstallationConfigLoader.getConstructionTimeSeconds(InstallationKind.TRAIN_STATION, 3)); + assertEquals( + 4, + InstallationConfigLoader.getCategorySlotCapacity( + InstallationKind.TRAIN_STATION, 3, "STATIC_EMPLACEMENTS")); + } + + @Test + void anUnconfiguredColdStartReportsMissingConfigurationEvenWithoutBukkit() throws Exception { + assertTrue(InstallationConfigLoader.getAll().isEmpty()); + IllegalStateException missing = + assertThrows( + IllegalStateException.class, + () -> InstallationConfigLoader.getRadius(InstallationKind.FORT)); + assertEquals("Installation config not loaded for kind fort", missing.getMessage()); + when(Bukkit.getServer()).thenReturn(null); + Path file = write(new YamlConfiguration()); + assertThrows(IllegalStateException.class, () -> InstallationConfigLoader.load(file.toFile())); + assertTrue(InstallationConfigLoader.getAll().isEmpty()); + verifyNoInteractions(logger); + } + + private YamlConfiguration valid() { + YamlConfiguration config = new YamlConfiguration(); + config.set("consent-proximity-blocks", 7); + config.set("transfer-request-timeout-seconds", 33); + for (InstallationKind kind : InstallationKind.values()) { + String key = kind.getCommandName(); + config.set(key + ".daily-upkeep", 4); + config.set(key + ".construction-time", 10); + config.set(key + ".radius", 20); + config.set(key + ".slots.static_emplacements", 2); + config.set(key + ".levels.2.daily-upkeep", 9); + config.set(key + ".levels.2.construction-time", 30); + config.set(key + ".levels.2.slots.static_emplacements", 3); + } + return config; + } + + private Path write(YamlConfiguration config) throws Exception { + Path path = temporary.resolve("installations.yml"); + config.save(path.toFile()); + return path; + } + + private void remember(Class<?> type, String name) throws Exception { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + globals.put(field, field.get(null)); + } + + @SuppressWarnings("unchecked") + private Map<InstallationKind, InstallationKindConfig> kinds() throws Exception { + Field field = InstallationConfigLoader.class.getDeclaredField("byKind"); + field.setAccessible(true); + return (Map<InstallationKind, InstallationKindConfig>) field.get(null); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/loaders/RegionLoaderBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/loaders/RegionLoaderBoundaryCoverageTest.java new file mode 100644 index 00000000..f7366c4d --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/loaders/RegionLoaderBoundaryCoverageTest.java @@ -0,0 +1,123 @@ +package net.tfminecraft.simplefactions.loaders; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.nio.file.DirectoryNotEmptyException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class RegionLoaderBoundaryCoverageTest { + @TempDir Path folder; + private JsonObject before; + + @BeforeEach + void snapshot() { + before = new JsonObject(); + for (var region : RegionLoader.getRegions()) { + JsonObject data = new JsonObject(); + data.addProperty("name", region.getName()); + JsonArray provinces = new JsonArray(); + region.getProvinces().forEach(provinces::add); + data.add("provinces", provinces); + before.add(region.getId(), data); + } + RegionLoader.loadFrom( + JsonParser.parseString("{\"old\":{\"name\":\"Old\",\"provinces\":[3]}}").getAsJsonObject()); + } + + @AfterEach + void restore() { + RegionLoader.loadFrom(before); + } + + @ParameterizedTest + @ValueSource(strings = {"null", "[]", "\"wrong shape\"", "{\"partial\":"}) + void aFailedReloadKeepsAllPreviouslyPublishedIndices(String invalid) throws Exception { + var live = RegionLoader.getById("old"); + Path file = Files.writeString(folder.resolve("regions.json"), invalid); + RegionLoader.loadAll(file.toFile()); + assertSame(live, RegionLoader.getById("old")); + assertSame(live, RegionLoader.getByProvince(3)); + assertEquals(List.of(live), RegionLoader.getRegions()); + } + + @Test + void aSuccessfulReloadReplacesAllIndicesAndSkipsMalformedOptionalRows() throws Exception { + Path file = + Files.writeString( + folder.resolve("regions.json"), + """ +{" ":{}, "ignored":[], "NORTH":{"name":" Northern Reach ","provinces":[7,7,"bad",null,{},8]}, + "south":{"name":" ","provinces":[9]}, "unnamed":{"name":[],"provinces":[]}} +"""); + RegionLoader.loadAll(file.toFile()); + assertNull(RegionLoader.getById("old")); + assertNull(RegionLoader.getByProvince(3)); + assertNull(RegionLoader.getById(null)); + assertNull(RegionLoader.getById(" ")); + var north = RegionLoader.getById("north"); + assertEquals("Northern Reach", north.getName()); + assertEquals(Set.of(7, 8), north.getProvinces()); + assertTrue(north.containsProvince(8)); + assertFalse(north.containsProvince(9)); + assertSame(north, RegionLoader.getByProvince(7)); + assertEquals("south", RegionLoader.getByProvince(9).getName()); + assertEquals("unnamed", RegionLoader.getById("unnamed").getName()); + assertEquals(3, RegionLoader.getRegions().size()); + assertThrows(UnsupportedOperationException.class, () -> RegionLoader.getRegions().clear()); + assertThrows(UnsupportedOperationException.class, () -> north.getProvinces().clear()); + } + + @Test + void defaultInputPathPublishesTheFileContent() throws Exception { + Path file = Path.of("plugins/SimpleFactions/Input/regions.json"); + byte[] previous = + Files.exists(file, LinkOption.NOFOLLOW_LINKS) ? Files.readAllBytes(file) : null; + List<Path> createdDirectories = new ArrayList<>(); + for (Path ancestor = file.getParent(); + ancestor != null && !Files.exists(ancestor, LinkOption.NOFOLLOW_LINKS); + ancestor = ancestor.getParent()) { + createdDirectories.add(ancestor); + } + try { + Files.createDirectories(file.getParent()); + Files.writeString(file, "{\"default_path\":{\"provinces\":[31]}}"); + RegionLoader.loadAll(); + assertEquals("default_path", RegionLoader.getByProvince(31).getId()); + assertNull(RegionLoader.getById("old")); + } finally { + if (previous == null) Files.deleteIfExists(file); + else Files.write(file, previous); + for (Path created : createdDirectories) { + if (!Files.isDirectory(created, LinkOption.NOFOLLOW_LINKS)) continue; + try { + Files.deleteIfExists(created); + } catch (DirectoryNotEmptyException retained) { + // Preserve contents added by another fixture; ancestors are visited deepest first. + } + } + } + } + + @Test + void removingTheOptionalFileOrExplicitlyClearingRemovesAllRegions() throws Exception { + RegionLoader.loadAll(folder.resolve("missing.json").toFile()); + assertTrue(RegionLoader.getRegions().isEmpty()); + assertNull(RegionLoader.getByProvince(3)); + RegionLoader.loadFrom(null); + assertTrue(RegionLoader.getRegions().isEmpty()); + RegionLoader.loadAll(null); + assertTrue(RegionLoader.getRegions().isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/loaders/TitlePersistenceCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/loaders/TitlePersistenceCoverageTest.java new file mode 100644 index 00000000..e0933bd1 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/loaders/TitlePersistenceCoverageTest.java @@ -0,0 +1,146 @@ +package net.tfminecraft.simplefactions.loaders; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.gson.JsonParser; +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.PersistenceFilesFixture; +import net.tfminecraft.simplefactions.tiers.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.*; + +class TitlePersistenceCoverageTest { + private FactionDomainFixture domain; + private PersistenceFilesFixture files; + private Tier county, duchy; + + @BeforeEach + void setup() throws Exception { + files = new PersistenceFilesFixture(); + domain = new FactionDomainFixture(); + TierLoader.oList.clear(); + county = tier("county", 1); + duchy = tier("duchy", 2); + } + + private Tier tier(String id, int level) { + YamlConfiguration config = new YamlConfiguration(); + config.set("tier", level); + Tier tier = new Tier(id, config); + TierLoader.oList.add(tier); + return tier; + } + + @AfterEach + void close() throws Exception { + if (domain != null) domain.close(); + if (files != null) files.close(); + } + + @Test + void creatingAndReloadingTitlesPreservesOtherEntriesAndCompositeMembership() throws Exception { + files.write( + "Input/county.json", + "{\"old\":{\"name\":\"Old\",\"rgb\":\"1,2,3\",\"provinces\":[9],\"extra\":42}}"); + Title first = + TitleLoader.createNewTitle(county, "first", "First", "3,4,5", List.of(1, 2), null, false); + Title second = + TitleLoader.createNewTitle( + duchy, "second", "Second", "4,5,6", null, List.of("first", "old"), true); + assertNotNull(first); + assertNotNull(second); + assertEquals( + 42, + JsonParser.parseString(Files.readString(files.root.resolve("Input/county.json"))) + .getAsJsonObject() + .getAsJsonObject("old") + .get("extra") + .getAsInt()); + second.setName("Renamed"); + assertTrue(TitleLoader.saveTitle(second)); + new TitleLoader().reload(); + assertEquals(3, TitleLoader.getTitles().size()); + assertEquals(List.of(1, 2), TitleLoader.getById("FIRST").getProvinces()); + Title reloaded = TitleLoader.getById("second"); + assertEquals("Renamed", reloaded.getName()); + assertTrue(reloaded.isComposite()); + assertTrue(reloaded.isTitleComplete()); + assertEquals(List.of("first", "old"), reloaded.getTitles()); + assertSame(reloaded, TitleLoader.getByTitle(TitleLoader.getById("first"))); + } + + @Test + void failureToSaveNewTitleDoesNotPublishItInMemory() throws Exception { + files.write("Input", "ordinary file blocks the directory"); + Title created = + TitleLoader.createNewTitle( + county, "failed", "Failed", "1,2,3", List.of(1), List.of(), false); + assertNull(created); + assertNull(TitleLoader.getById("failed")); + assertEquals( + "ordinary file blocks the directory", Files.readString(files.root.resolve("Input"))); + } + + @Test + void malformedExistingTierFileIsPreservedWhenCreatingATitle() throws Exception { + Path path = files.write("Input/county.json", "{ not json"); + Title created = + assertDoesNotThrow( + () -> + TitleLoader.createNewTitle( + county, "failed", "Failed", "1,2,3", List.of(1), null, false)); + assertNull(created); + assertTrue(TitleLoader.getTitles().isEmpty()); + assertEquals("{ not json", Files.readString(path)); + } + + @Test + void malformedReloadPreservesAllPreviouslyLoadedTitles() throws Exception { + Path path = + files.write("Input/county.json", "{\"first\":{\"name\":\"First\",\"provinces\":[1]}}"); + new TitleLoader().loadAll(); + Title original = TitleLoader.getById("first"); + assertNotNull(original); + Files.writeString( + path, "{\"replacement\":{\"name\":\"Replacement\",\"provinces\":[2]},\"invalid\":42}"); + assertThrows(IllegalStateException.class, () -> new TitleLoader().reload()); + assertEquals(List.of(original), TitleLoader.getTitles()); + assertSame(original, TitleLoader.getById("first")); + assertNull(TitleLoader.getById("replacement")); + } + + @Test + void blockedTemporaryFileLeavesOriginalTierDataUntouched() throws Exception { + Path file = + files.write("Input/county.json", "{\"first\":{\"name\":\"First\",\"provinces\":[1]}}"); + new TitleLoader().loadAll(); + Title first = TitleLoader.getById("first"); + first.setName("Changed"); + Files.createDirectory(files.root.resolve("Input/county.json.tmp")); + files.write("Input/county.json.tmp/keep", "keep"); + assertFalse(TitleLoader.saveTitle(first)); + assertTrue(Files.readString(file).contains("First")); + assertEquals("keep", Files.readString(files.root.resolve("Input/county.json.tmp/keep"))); + } + + @Test + void tierFilenamesAreIndependentOfTheServerLocale() throws Exception { + Tier viscount = tier("VISCOUNT", 3); + Locale original = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + Files.createDirectories(files.root.resolve("Input")); + Title created = + TitleLoader.createNewTitle( + viscount, "north", "North", "1,2,3", List.of(), List.of(), false); + assertNotNull(created); + assertTrue(Files.exists(files.root.resolve("Input/viscount.json"))); + new TitleLoader().loadAll(); + assertNotNull(TitleLoader.getById("north")); + } finally { + Locale.setDefault(original); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/loaders/VehiclesConfigLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/loaders/VehiclesConfigLifecycleCoverageTest.java new file mode 100644 index 00000000..7a7d7eb3 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/loaders/VehiclesConfigLifecycleCoverageTest.java @@ -0,0 +1,272 @@ +package net.tfminecraft.simplefactions.loaders; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; +import java.util.stream.Stream; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +class VehiclesConfigLifecycleCoverageTest { + @TempDir Path temporary; + private GuiTestFixture fixture; + private final Map<Field, Object> globals = new LinkedHashMap<>(); + private Logger logger; + + @BeforeEach + void setup() throws Exception { + fixture = new GuiTestFixture(); + for (String name : + List.of( + "personalSlotLimit", + "defaultPerPerson", + "maintenanceHourlyDamagePercent", + "maintenanceMinHealthPercent", + "maintenanceIntervalTicks", + "categoryIds", + "typesByCategory", + "categoryByVehicleTypeId", + "categoryDisplayNames", + "feeExcludedCategories")) { + Field field = VehiclesConfigLoader.class.getDeclaredField(name); + field.setAccessible(true); + globals.put(field, field.get(null)); + } + logger = mock(Logger.class); + when(Bukkit.getLogger()).thenReturn(logger); + VehiclesConfigLoader.load(write(valid()).toFile()); + } + + @AfterEach + void close() throws Exception { + for (var entry : globals.entrySet()) entry.getKey().set(null, entry.getValue()); + fixture.close(); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void unavailableOrMalformedFilesPreserveEveryLiveSetting(boolean malformed) throws Exception { + Path file = temporary.resolve("bad.yml"); + if (malformed) Files.writeString(file, "categories: [unfinished"); + + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> VehiclesConfigLoader.load(file.toFile())); + + assertEquals("Failed to load vehicles.yml", failure.getMessage()); + assertOriginalConfiguration(); + verify(logger).severe("[SimpleFactions] Failed to load vehicles.yml"); + } + + @Test + void lateTypeValidationFailurePreservesGlobalLimitsMaintenanceAndFeeExclusions() + throws Exception { + YamlConfiguration replacement = valid(); + replacement.set("personal-slot-limit", 9); + replacement.set("default-per-person", 8); + replacement.set("maintenance-hourly-damage-percent", 45); + replacement.set("maintenance-min-health-percent", 4); + replacement.set("maintenance-interval-ticks", 1234); + replacement.set("fee-excluded-categories", List.of("ships")); + replacement.set("categories.ships.ironclad.size", 0); + Path file = write(replacement); + + assertThrows(IllegalStateException.class, () -> VehiclesConfigLoader.load(file.toFile())); + + assertOriginalConfiguration(); + } + + @ParameterizedTest + @ValueSource(doubles = {Double.NaN, Double.POSITIVE_INFINITY}) + void nonFiniteUpkeepIsRejectedForBothDefaultsAndIndividualTypes(double amount) throws Exception { + for (String key : List.of("default-upkeep", "categories.ships.ironclad.upkeep")) { + YamlConfiguration invalid = valid(); + if (key.equals("default-upkeep")) invalid.set("categories.ships.ironclad.upkeep", null); + invalid.set(key, amount); + Path file = write(invalid); + + assertThrows(IllegalStateException.class, () -> VehiclesConfigLoader.load(file.toFile())); + + assertOriginalConfiguration(); + } + } + + @Test + void categoriesDifferingOnlyByCaseCannotSilentlyErasePreviouslyParsedTypes() throws Exception { + YamlConfiguration invalid = valid(); + invalid.set("categories.SHIPS.raft.upkeep", 1); + invalid.set("categories.SHIPS.raft.size", 1); + Path file = write(invalid); + + assertThrows(IllegalStateException.class, () -> VehiclesConfigLoader.load(file.toFile())); + + assertOriginalConfiguration(); + assertFalse(VehiclesConfigLoader.isKnownType("raft")); + } + + @ParameterizedTest(name = "{0}={1}") + @MethodSource("invalidFields") + void invalidSettingsAreRejectedWithoutChangingTheActiveSnapshot( + String key, Object value, String expected) throws Exception { + YamlConfiguration invalid = valid(); + invalid.set(key, value); + Path file = write(invalid); + + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> VehiclesConfigLoader.load(file.toFile())); + + assertTrue(failure.getMessage().contains(expected), failure.getMessage()); + assertOriginalConfiguration(); + } + + private static Stream<Arguments> invalidFields() { + return Stream.of( + Arguments.of("personal-slot-limit", -1, "personal-slot-limit"), + Arguments.of("default-per-person", 0, "default-per-person"), + Arguments.of("default-upkeep", -1, "default-upkeep"), + Arguments.of("maintenance-hourly-damage-percent", -1, "maintenance-hourly-damage-percent"), + Arguments.of("maintenance-hourly-damage-percent", 101, "maintenance-hourly-damage-percent"), + Arguments.of("maintenance-min-health-percent", -1, "maintenance-min-health-percent"), + Arguments.of("maintenance-min-health-percent", 101, "maintenance-min-health-percent"), + Arguments.of("maintenance-interval-ticks", 0, "maintenance-interval-ticks"), + Arguments.of("upkeep.ironclad", 20, "legacy upkeep block"), + Arguments.of("categories", null, "missing required categories section"), + Arguments.of("categories.ships.ironclad.size", null, "size is required"), + Arguments.of("categories.ships.ironclad.upkeep", null, "upkeep is required"), + Arguments.of("categories.ships.ironclad.upkeep", -1, "upkeep"), + Arguments.of("categories.ships.ironclad.size", 0, "size must be > 0"), + Arguments.of("categories.ships.ironclad.per-person", 0, "per-person must be >= 1"), + Arguments.of( + "categories.aircraft.IRONCLAD", + Map.of("size", 1, "upkeep", 5), + "duplicate vehicle type id")); + } + + @Test + void replacementUsesConfiguredDefaultsAndExposesImmutableCaseInsensitiveCatalogs() + throws Exception { + YamlConfiguration replacement = new YamlConfiguration(); + replacement.set("default-upkeep", 7.5); + replacement.set("categories.SHIPS.show-on-upcoming-battle-icon", true); + replacement.set("categories.SHIPS.RAFT.size", 1); + replacement.set("categories.SHIPS.RAFT.show-on-upcoming-battle-icon", false); + replacement.set("categories.SHIPS.BARGE.size", 2); + replacement.set("categories.SHIPS.BARGE.upkeep", 0); + replacement.set("categories.aircraft.display-name", " "); + Path file = write(replacement); + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + + VehiclesConfigLoader.load(file.toFile()); + + assertEquals(1, VehiclesConfigLoader.getPersonalSlotLimit()); + assertEquals(1, VehiclesConfigLoader.getDefaultPerPerson()); + assertEquals(20, VehiclesConfigLoader.getMaintenanceHourlyDamagePercent()); + assertEquals(3, VehiclesConfigLoader.getMaintenanceMinHealthPercent()); + assertEquals(72000, VehiclesConfigLoader.getMaintenanceIntervalTicks()); + assertEquals(7.5, VehiclesConfigLoader.getUpkeep("RaFt")); + assertEquals(1, VehiclesConfigLoader.getPerPersonLimit("RAFT")); + assertFalse(VehiclesConfigLoader.ignoresPersonalSlotLimit("RAFT")); + assertFalse(VehiclesConfigLoader.showsOnUpcomingBattleIcon("RAFT")); + assertTrue(VehiclesConfigLoader.showsOnUpcomingBattleIcon("BARGE")); + assertEquals("ships", VehiclesConfigLoader.getCategoryId("BARGE").orElseThrow()); + assertNull(VehiclesConfigLoader.getCategoryDisplayName("aircraft")); + assertFalse(VehiclesConfigLoader.isFeeExcludedCategory("AIRCRAFT")); + assertThrows( + UnsupportedOperationException.class, () -> VehiclesConfigLoader.getCategoryIds().clear()); + assertThrows( + UnsupportedOperationException.class, + () -> VehiclesConfigLoader.getTypesInCategory("SHIPS").clear()); + } finally { + Locale.setDefault(previous); + } + } + + @ParameterizedTest + @ValueSource(strings = {"", "unknown"}) + void unavailableCatalogEntriesUseDocumentedDefaults(String unknown) { + for (String id : java.util.Arrays.asList(null, unknown)) { + assertFalse(VehiclesConfigLoader.isKnownType(id)); + assertFalse(VehiclesConfigLoader.ignoresPersonalSlotLimit(id)); + assertFalse(VehiclesConfigLoader.showsOnUpcomingBattleIcon(id)); + assertEquals(0, VehiclesConfigLoader.getUpkeep(id)); + assertEquals(0, VehiclesConfigLoader.getSize(id)); + assertEquals(3, VehiclesConfigLoader.getPerPersonLimit(id)); + assertTrue(VehiclesConfigLoader.getCategoryId(id).isEmpty()); + assertTrue(VehiclesConfigLoader.getTypesInCategory(id).isEmpty()); + assertNull(VehiclesConfigLoader.getCategoryDisplayName(id)); + assertFalse(VehiclesConfigLoader.isFeeExcludedCategory(id)); + } + } + + @Test + void validationStillReportsAnErrorBeforeBukkitExists() throws Exception { + when(Bukkit.getServer()).thenReturn(null); + Path file = write(new YamlConfiguration()); + assertThrows(IllegalStateException.class, () -> VehiclesConfigLoader.load(file.toFile())); + assertOriginalConfiguration(); + verifyNoInteractions(logger); + } + + private void assertOriginalConfiguration() { + assertEquals(2, VehiclesConfigLoader.getPersonalSlotLimit()); + assertEquals(3, VehiclesConfigLoader.getDefaultPerPerson()); + assertEquals(15, VehiclesConfigLoader.getMaintenanceHourlyDamagePercent()); + assertEquals(7, VehiclesConfigLoader.getMaintenanceMinHealthPercent()); + assertEquals(90, VehiclesConfigLoader.getMaintenanceIntervalTicks()); + assertEquals(0.15, VehiclesConfigLoader.getMaintenanceHourlyDamageFraction()); + assertEquals(0.07, VehiclesConfigLoader.getMaintenanceMinHealthFraction()); + assertTrue(VehiclesConfigLoader.isFeeExcludedCategory("AIRCRAFT")); + assertFalse(VehiclesConfigLoader.isFeeExcludedCategory("ships")); + assertEquals(Set.of("ships", "aircraft"), VehiclesConfigLoader.getCategoryIds()); + assertEquals("Ships", VehiclesConfigLoader.getCategoryDisplayName("SHIPS")); + assertEquals(20, VehiclesConfigLoader.getUpkeep("IRONCLAD")); + assertEquals(2, VehiclesConfigLoader.getSize("ironclad")); + assertEquals(4, VehiclesConfigLoader.getPerPersonLimit("ironclad")); + assertTrue(VehiclesConfigLoader.ignoresPersonalSlotLimit("ironclad")); + assertTrue(VehiclesConfigLoader.showsOnUpcomingBattleIcon("ironclad")); + assertEquals(Set.of("ironclad"), VehiclesConfigLoader.getTypesInCategory("ships").keySet()); + } + + private YamlConfiguration valid() { + YamlConfiguration config = new YamlConfiguration(); + config.set("personal-slot-limit", 2); + config.set("default-per-person", 3); + config.set("maintenance-hourly-damage-percent", 15); + config.set("maintenance-min-health-percent", 7); + config.set("maintenance-interval-ticks", 90); + config.set("fee-excluded-categories", List.of("aircraft")); + config.set("categories.ships.display-name", "Ships"); + config.set("categories.ships.show-on-upcoming-battle-icon", true); + config.set("categories.ships.ironclad.upkeep", 20); + config.set("categories.ships.ironclad.size", 2); + config.set("categories.ships.ironclad.per-person", 4); + config.set("categories.ships.ironclad.ignore-limit", true); + config.createSection("categories.aircraft"); + return config; + } + + private Path write(YamlConfiguration config) throws Exception { + Path file = temporary.resolve("vehicles.yml"); + config.save(file.toFile()); + return file; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/CapitalMoveLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/CapitalMoveLifecycleCoverageTest.java new file mode 100644 index 00000000..6643223e --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/CapitalMoveLifecycleCoverageTest.java @@ -0,0 +1,167 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerQuitEvent; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class CapitalMoveLifecycleCoverageTest { + private FactionDomainFixture fixture; + private Faction faction; + private Player player; + private Map<Object, Object> pending, original; + private double oldCost; + + @BeforeEach + @SuppressWarnings("unchecked") + void setup() throws Exception { + fixture = new FactionDomainFixture(); + fixture.provincesEnabled(true); + var field = CapitalMovePrompt.class.getDeclaredField("pending"); + field.setAccessible(true); + pending = (Map<Object, Object>) field.get(null); + original = new LinkedHashMap<>(pending); + pending.clear(); + oldCost = Cache.capitalMoveCost; + Cache.capitalMoveCost = 20; + for (int id : List.of(1, 2, 3)) fixture.provinceData.put(id, new Province(id, "PLAINS", 50)); + fixture.provinceData.get(1).addNeighbour(2); + fixture.provinceData.get(2).addNeighbour(1); + fixture.provinceData.get(2).addNeighbour(3); + fixture.provinceData.get(3).addNeighbour(2); + var data = fixture.data("realm", "Alice"); + data.provinces.addAll(List.of(1, 2, 3)); + data.capital = 1; + faction = fixture.saved(data); + faction.getOrCreateMainGuild(); + faction.getBank().setWealth(100.0); + faction.getSettlementHandler().found("Old_City", 1, 0, 0); + player = fixture.player("Alice"); + fixture.inventory.confirming = new HashMap<>(); + } + + @AfterEach + void close() { + try { + pending.clear(); + pending.putAll(original); + Cache.capitalMoveCost = oldCost; + } finally { + fixture.close(); + } + } + + @Test + void aFreeRenameOpensAQuotedConfirmationAndAppliesOnce() { + CapitalMovePrompt.begin(player, faction, 1, "New_City", true); + verify(fixture.inventory).confirmCapitalMoveView(player, faction, true, 0, 0); + assertSame(faction, fixture.inventory.confirming.get(player)); + assertTrue(faction.getSettlementHandler().getByProvince(1).getName().contains("Old City")); + CapitalMovePrompt.handleConfirm(player, true); + CapitalMovePrompt.handleConfirm(player, true); + assertTrue(faction.getSettlementHandler().getByProvince(1).getName().contains("New City")); + assertEquals(1, faction.getCapitalMoves()); + assertEquals(100.0, faction.getBank().getWealth()); + assertFalse(fixture.inventory.confirming.containsKey(player)); + verify(player, times(1)).playSound(player, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); + } + + @Test + void paidRenameChargesExactlyTheDisplayedQuote() { + faction.setCapitalMoves(1); + CapitalMovePrompt.begin(player, faction, 1, "New_City", true); + verify(fixture.inventory).confirmCapitalMoveView(player, faction, true, 20, 0); + CapitalMovePrompt.handleConfirm(player, true); + assertEquals(80.0, faction.getBank().getWealth()); + assertEquals(2, faction.getCapitalMoves()); + } + + @Test + void cancelClosesTheMenuAndKeepsCapitalBankAndName() { + CapitalMovePrompt.begin(player, faction, 1, "New_City", true); + CapitalMovePrompt.handleConfirm(player, false); + assertUnchanged(); + verify(player).closeInventory(); + verify(player).playSound(player, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); + } + + @Test + void insufficientBankBalanceDoesNotOpenConfirmation() { + faction.setCapitalMoves(1); + faction.getBank().setWealth(5.0); + CapitalMovePrompt.begin(player, faction, 1, "New_City", true); + assertTrue(pending.isEmpty()); + assertTrue(fixture.inventory.confirming.isEmpty()); + verify(fixture.inventory, never()) + .confirmCapitalMoveView(any(), any(), anyBoolean(), anyDouble(), anyInt()); + verify(player).playSound(player, Sound.ENTITY_VILLAGER_NO, 1f, 1f); + } + + @Test + void changedLeaderCannotApplyAnOpenCapitalConfirmation() { + CapitalMovePrompt.begin(player, faction, 1, "New_City", true); + faction.setLeader("Bob"); + CapitalMovePrompt.handleConfirm(player, true); + assertUnchanged(); + verify(player).playSound(player, Sound.ENTITY_VILLAGER_NO, 1f, 1f); + } + + @Test + void failedDestinationValidationDoesNotSpendOrMove() { + CapitalMovePrompt.begin(player, faction, 0, "Invalid", false); + CapitalMovePrompt.handleConfirm(player, true); + assertUnchanged(); + } + + @Test + void capitalMovePublishesTheNewCityAndRevalidatesClaims() { + CapitalMovePrompt.begin(player, faction, 2, "New_City", false); + CapitalMovePrompt.handleConfirm(player, true); + assertEquals(2, faction.getCapital()); + assertNotNull(faction.getSettlementHandler().getByProvince(2)); + assertNull(faction.getSettlementHandler().getByProvince(1)); + assertEquals(1, faction.getCapitalMoves()); + assertEquals(100.0, faction.getBank().getWealth()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void firstCapitalIsFreeAndOnlyAppliesValidDestinations(boolean valid) { + faction.setCapital(-1, true, false); + CapitalMovePrompt.applyFactionCapitalMove(player, faction, valid ? 2 : 0, "First_City"); + assertEquals(valid ? 2 : -1, faction.getCapital()); + assertEquals(0, faction.getCapitalMoves()); + assertEquals(100.0, faction.getBank().getWealth()); + verify(player) + .playSound(player, valid ? Sound.ENTITY_PLAYER_LEVELUP : Sound.ENTITY_VILLAGER_NO, 1f, 1f); + } + + @Test + void quittingClearsBothPendingRecordsAndLateConfirmationDoesNothing() { + CapitalMovePrompt.begin(player, faction, 1, "New_City", true); + CapitalMovePrompt listener = new CapitalMovePrompt(); + listener.onPlayerQuit(new PlayerQuitEvent(player, "quit")); + listener.onPlayerQuit(new PlayerQuitEvent(player, "quit")); + CapitalMovePrompt.handleConfirm(player, true); + assertTrue(pending.isEmpty()); + assertTrue(fixture.inventory.confirming.isEmpty()); + assertUnchanged(); + } + + private void assertUnchanged() { + assertEquals(1, faction.getCapital()); + assertEquals(100.0, faction.getBank().getWealth()); + assertTrue(faction.getSettlementHandler().getByProvince(1).getName().contains("Old City")); + assertEquals(0, faction.getCapitalMoves()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java new file mode 100644 index 00000000..f7924858 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java @@ -0,0 +1,1778 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.stream.Stream; +import net.tfminecraft.denareconomy.DenarEconomy; +import net.tfminecraft.denareconomy.data.Account; +import net.tfminecraft.denareconomy.data.PlayerData; +import net.tfminecraft.denareconomy.managers.MoneyManager; +import net.tfminecraft.denareconomy.managers.PlayerManager; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.army.ExpandResult; +import net.tfminecraft.simplefactions.army.Military; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.diplomacy.Relation; +import net.tfminecraft.simplefactions.diplomacy.RelationType; +import net.tfminecraft.simplefactions.enums.Rules; +import net.tfminecraft.simplefactions.enums.Stance; +import net.tfminecraft.simplefactions.espionage.CharacterNames; +import net.tfminecraft.simplefactions.espionage.EspionageCommands; +import net.tfminecraft.simplefactions.events.FactionCreateEvent; +import net.tfminecraft.simplefactions.events.FactionDeleteEvent; +import net.tfminecraft.simplefactions.government.Government; +import net.tfminecraft.simplefactions.government.election.Election; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.GuildBankGrant; +import net.tfminecraft.simplefactions.guild.loans.Loan; +import net.tfminecraft.simplefactions.guild.network.TradeGraphCommand; +import net.tfminecraft.simplefactions.installation.Installation; +import net.tfminecraft.simplefactions.installation.InstallationConstruction; +import net.tfminecraft.simplefactions.installation.InstallationKind; +import net.tfminecraft.simplefactions.installation.handler.ConstructResult; +import net.tfminecraft.simplefactions.installation.handler.InstallationHandler; +import net.tfminecraft.simplefactions.laws.Law; +import net.tfminecraft.simplefactions.laws.LawGroup; +import net.tfminecraft.simplefactions.loaders.LawLoader; +import net.tfminecraft.simplefactions.loaders.RelationLoader; +import net.tfminecraft.simplefactions.loaders.TitleLoader; +import net.tfminecraft.simplefactions.managers.inventory.InstallationView; +import net.tfminecraft.simplefactions.map.MapSystem; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.BankPlacementValidator; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.Modifier; +import net.tfminecraft.simplefactions.objects.handler.GuildHandler; +import net.tfminecraft.simplefactions.objects.handler.LawHandler; +import net.tfminecraft.simplefactions.rest.RestServer; +import net.tfminecraft.simplefactions.settlement.handler.CapitalResult; +import net.tfminecraft.simplefactions.settlement.handler.SettlementHandler; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import net.tfminecraft.simplefactions.tiers.Title; +import net.tfminecraft.simplefactions.tiers.admin.TitleAdminCommand; +import net.tfminecraft.simplefactions.utils.DisplayNameGate; +import net.tfminecraft.simplefactions.utils.DisplayNameGate.NameOperation; +import net.tfminecraft.simplefactions.utils.Permissions; +import net.tfminecraft.simplefactions.utils.RandomRGB; +import net.tfminecraft.simplefactions.vehicles.VehicleFactionCommands; +import net.tfminecraft.simplefactions.vehicles.berth.VehicleFindMessages; +import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenancePayService.PaymentSource; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +@SuppressWarnings("deprecation") +class CommandManagerCoverageTest { + private GuiTestFixture gui; + private MockedStatic<FactionManager> factions; + private CommandManager commands; + private InventoryManager registered; + private Player player; + private Faction faction; + private InstallationHandler installations; + private boolean previousProvinces; + private List<Faction> previousFactions; + private List<Bank> previousBanks; + private Government government; + private Guild guild; + private GuildHandler guilds; + private MapSystem map; + + @BeforeEach + void setUp() { + previousProvinces = Cache.provincesEnabled; + previousFactions = FactionManager.factions; + previousBanks = BankManager.banks; + FactionManager.factions = new ArrayList<>(); + BankManager.banks = new ArrayList<>(); + Cache.provincesEnabled = true; + gui = new GuiTestFixture(); + player = gui.player("Leader"); + faction = mock(Faction.class); + installations = mock(InstallationHandler.class); + when(faction.getId()).thenReturn("realm"); + when(faction.getLeader()).thenReturn("Leader"); + when(faction.getName()).thenReturn("Realm"); + when(faction.getMembers()).thenReturn(new ArrayList<>(List.of("Leader", "Alice"))); + government = mock(Government.class); + when(faction.getGovernment()).thenReturn(government); + guild = mock(Guild.class, RETURNS_DEEP_STUBS); + when(guild.getId()).thenReturn("merchants"); + when(guild.getName()).thenReturn("Merchants"); + when(guild.getLeader()).thenReturn("Leader"); + when(guild.getFaction()).thenReturn(faction); + when(guild.getMembers()).thenReturn(new ArrayList<>(List.of("Leader", "Alice"))); + when(guild.isLeader(player)).thenReturn(true); + when(guild.getBank()).thenReturn(null); + when(faction.getOrCreateMainGuild()).thenReturn(guild); + guilds = mock(GuildHandler.class); + when(faction.getGuildHandler()).thenReturn(guilds); + map = mock(MapSystem.class); + when(faction.getInstallationHandler()).thenReturn(installations); + when(installations.getById("fort")).thenReturn(mock(Installation.class)); + when(installations.deconstruct("fort")).thenReturn(ConstructResult.ok("Removed fort")); + when(installations.upgrade("fort")).thenReturn(ConstructResult.ok("Upgrade queued")); + registered = new InventoryManager(); + registered.installationView = mock(InstallationView.class); + factions = mockStatic(FactionManager.class); + factions.when(() -> FactionManager.getByLeader("Leader")).thenReturn(faction); + // SimpleFactions.onEnable publishes the InventoryManager registered as its event listener here. + factions.when(FactionManager::getInv).thenReturn(registered); + factions.when(FactionManager::getMap).thenReturn(map); + factions.when(() -> FactionManager.getByString("realm")).thenReturn(faction); + factions.when(() -> FactionManager.getGuildByString("merchants")).thenReturn(guild); + commands = new CommandManager(); + } + + @AfterEach + void close() { + try { + if (factions != null) factions.close(); + } finally { + try { + if (gui != null) gui.close(); + } finally { + Cache.provincesEnabled = previousProvinces; + FactionManager.factions = previousFactions; + BankManager.banks = previousBanks; + } + } + } + + @ParameterizedTest + @ValueSource(strings = {"deconstruct", "upgrade"}) + void installationCommandConfirmationIsHandledByTheRegisteredListener(String action) { + assertTrue(run("faction", action, "fort")); + assertEquals("§7Confirm Action", player.getOpenInventory().getTitle()); + registered.clickButton(gui.click(player, 11)); + if (action.equals("deconstruct")) verify(installations).deconstruct("fort"); + else verify(installations).upgrade("fort"); + verify(registered.installationView).installationsView(null, player, faction, true); + } + + @ParameterizedTest + @CsvSource({ + "addprestigemodifier,bad", + "addprestigemodifier,NaN", + "addprestigemodifier,Infinity", + "addprestigemodifier,-Infinity", + "setpower,bad", + "setpower,NaN", + "setpower,Infinity", + "setpower,-Infinity" + }) + void administrativeNumericInputCannotCrashOrStoreNonFiniteValues(String action, String amount) { + when(player.hasPermission(Permissions.Permission_Admin)).thenReturn(true); + factions.when(() -> FactionManager.getByString("realm")).thenReturn(faction); + Government government = mock(Government.class); + when(faction.getGovernment()).thenReturn(government); + if (action.equals("setpower")) run("faction", action, "realm", amount); + else run("faction", action, "realm", "festival", amount); + verify(faction, never()).addPersistentPrestigeModifier(any()); + verify(government, never()).setPower(anyDouble()); + verify(player).sendMessage(contains("number")); + } + + @ParameterizedTest + @ValueSource(strings = {"faction", "guild"}) + void missingSubcommandHasUsageAndConsoleCannotRunPlayerCommands(String name) { + assertTrue(run(name)); + verify(player).sendMessage(contains("command format")); + CommandSender console = mock(CommandSender.class); + assertFalse(run(console, name, "create", "Realm")); + verifyNoInteractions(console); + } + + @Test + void unknownCommandExplainsTheFormat() { + assertFalse(run("unrelated", "unknown")); + verify(player).sendMessage(contains("command format")); + } + + static Stream<String> administrativeCommands() { + return Stream.of( + "addprestigemodifier realm type 1", + "forcedelete realm", + "forceupgrade realm fort", + "forceconstruct realm fort Citadel", + "forceregiment realm give guards 1", + "forceleader realm Alice", + "forcejoin realm Bob", + "forcewithdraw realm 1", + "refresh", + "delbank realm", + "startelection realm", + "endelection realm", + "getglobalwealth", + "queueallnations", + "fullregen i_love_tfmc", + "reloadtitles", + "reloadconfigs", + "destroytitle crown", + "granttitle realm crown", + "transfersubject realm other", + "setrelation realm other alliance", + "settreaty realm other trade", + "setpower realm 1", + "setlaw realm levy low", + "setstance merchants support", + "usurp realm other", + "provincecap"); + } + + @ParameterizedTest + @MethodSource("administrativeCommands") + void staffCommandsRefusePlayersWithoutPermission(String command) { + assertTrue(run("faction", command.split(" "))); + verify(player).sendMessage(contains("do not have access")); + verify(faction, never()).setLeader(anyString()); + verify(government, never()).setPower(anyDouble()); + verifyNoInteractions(map); + } + + @ParameterizedTest + @ValueSource(strings = {"dummyLeader", "dummify"}) + void guildStaffCommandsRequirePermissionAndAnExistingMembership(String action) { + assertTrue(run("guild", action)); + verify(player).sendMessage(contains("do not have access")); + admin(); + assertTrue(run("guild", action)); + verify(player).sendMessage("§cYou are not in a guild"); + guildLeader(); + assertTrue(run("guild", action)); + if (action.equals("dummify")) verify(guild).dummify(player); + else verify(guild).dummyLeader(player); + } + + @ParameterizedTest + @ValueSource( + strings = { + "forceupgrade realm fort", + "forceconstruct realm fort Citadel", + "queueallnations", + "fullregen i_love_tfmc", + "reloadtitles", + "destroytitle crown", + "granttitle realm crown", + "usurp realm other", + "provincecap", + "claim", + "unclaim", + "construct fort Citadel", + "deconstruct fort", + "upgrade fort", + "setcapital" + }) + void provinceCommandsRefuseDisabledProvinceIntegration(String command) { + admin(); + Cache.provincesEnabled = false; + assertTrue(run("faction", command.split(" "))); + verify(player).sendMessage(contains("disabled")); + verifyNoInteractions(map); + } + + @ParameterizedTest + @ValueSource( + strings = { + "forceupgrade missing fort", + "forceconstruct missing fort Citadel", + "forceregiment missing give guards", + "forceleader missing Alice", + "forcejoin missing Alice", + "forcewithdraw missing 1", + "forcedelete missing", + "delbank missing", + "startelection missing", + "endelection missing", + "addprestigemodifier missing type 1" + }) + void administrativeActionsReportMissingFaction(String command) { + admin(); + assertTrue(run("faction", command.split(" "))); + verify(player) + .sendMessage( + argThat( + (String text) -> + text.contains("does not exist") || text.contains("Faction not found"))); + } + + @ParameterizedTest + @ValueSource( + strings = { + "forceupgrade", + "forceupgrade realm", + "forceconstruct", + "forceconstruct realm", + "forceregiment", + "forceregiment realm give", + "forceregiment realm dance guards" + }) + void incompleteStaffConstructionAndRegimentCommandsDescribeUsage(String command) { + admin(); + assertTrue(run("faction", command.split(" "))); + verify(player).sendMessage(contains("Usage:")); + verify(installations, never()).upgradeInstant(anyString()); + } + + @ParameterizedTest + @ValueSource(strings = {"construct", "forceconstruct"}) + void constructionChecksKindNameLocationAndPreservesANameWithSpaces(String action) { + admin(); + boolean forced = action.startsWith("force"); + assertTrue( + run( + "faction", + forced ? new String[] {action, "realm", "unknown"} : new String[] {action, "unknown"})); + verify(player).sendMessage(contains("Unknown installation type")); + assertTrue( + run( + "faction", + forced ? new String[] {action, "realm", "fort"} : new String[] {action, "fort"})); + verify(player).sendMessage(contains("Name required")); + String[] valid = + forced + ? new String[] {action, "realm", "fort", "North", "Gate"} + : new String[] {action, "fort", "North", "Gate"}; + try (MockedStatic<RestServer> rest = mockStatic(RestServer.class)) { + rest.when(() -> RestServer.getProvince(player)).thenReturn(-2); + assertTrue(run("faction", valid)); + verify(player).sendMessage(contains("could not resolve province")); + rest.when(() -> RestServer.getProvince(player)).thenReturn(0); + assertTrue(run("faction", valid)); + verify(player).sendMessage("§cThis location has no province!"); + rest.when(() -> RestServer.getProvince(player)).thenReturn(7); + ConstructResult result = ConstructResult.ok("Construction complete"); + when(installations.constructInstant( + eq(InstallationKind.FORT), eq("North Gate"), eq(7), anyInt(), anyInt())) + .thenReturn(result); + when(installations.construct( + eq(InstallationKind.FORT), eq("North Gate"), eq(7), anyInt(), anyInt())) + .thenReturn(result); + assertTrue(run("faction", valid)); + if (forced) + verify(installations) + .constructInstant( + InstallationKind.FORT, + "North Gate", + 7, + player.getLocation().getBlockX(), + player.getLocation().getBlockZ()); + else + verify(installations) + .construct( + InstallationKind.FORT, + "North Gate", + 7, + player.getLocation().getBlockX(), + player.getLocation().getBlockZ()); + verify(player).sendMessage("Construction complete"); + verify(player).playSound(player, Sound.BLOCK_ANVIL_USE, 1f, 1f); + } + } + + @Test + void forcedUpgradeReturnsTheDomainFailureWithoutClaimingSuccess() { + admin(); + when(installations.upgradeInstant("fort")).thenReturn(ConstructResult.fail("Maximum level")); + assertTrue(run("faction", "forceupgrade", "realm", "fort")); + verify(installations).upgradeInstant("fort"); + verify(player).sendMessage("Maximum level"); + } + + @ParameterizedTest + @CsvSource({"give,1,1,Added", "give,3,3,Added", "take,2,-2,Removed"}) + void regimentAdjustmentsUseSignedAmountsAndHumanReadableNames( + String action, int amount, int delta, String verb) { + admin(); + Military military = mock(Military.class); + when(faction.getMilitary()).thenReturn(military); + when(military.adminAdjustSlots("guards", delta)).thenReturn(new ExpandResult(true, "")); + Regiment regiment = mock(Regiment.class); + when(regiment.getName()).thenReturn("Royal Guards"); + when(military.getRegiment("guards")).thenReturn(regiment); + assertTrue( + run("faction", "forceregiment", "realm", action, "guards", Integer.toString(amount))); + verify(military).adminAdjustSlots("guards", delta); + verify(player) + .sendMessage(contains(verb + " " + amount + " " + (amount == 1 ? "slot" : "slots"))); + verify(player).sendMessage(contains("Royal Guards")); + } + + @ParameterizedTest + @ValueSource(strings = {"bad", "0", "-1", "1.5", "2147483648"}) + void regimentAmountsMustBePositiveIntegers(String amount) { + admin(); + assertTrue(run("faction", "forceregiment", "realm", "give", "guards", amount)); + verify(player).sendMessage(contains("positive whole number")); + verify(faction, never()).getMilitary(); + } + + @Test + void regimentDefaultIsOneAndDomainDenialsArePreserved() { + admin(); + Military military = mock(Military.class); + when(faction.getMilitary()).thenReturn(military); + when(military.adminAdjustSlots("guards", 1)) + .thenReturn(new ExpandResult(false, "Cannot grow levies")); + assertTrue(run("faction", "forceregiment", "realm", "give", "guards")); + verify(player).sendMessage("§cCannot grow levies"); + when(military.adminAdjustSlots("guards", 1)).thenReturn(new ExpandResult(true, "")); + assertTrue(run("faction", "forceregiment", "realm", "give", "guards")); + verify(player).sendMessage("§aAdded 1 slot to guards"); + } + + @Test + void finitePrestigeAndPowerValuesUpdateTheRequestedDomainObjects() { + admin(); + assertTrue(run("faction", "addprestigemodifier", "realm", "festival", "2.5")); + ArgumentCaptor<Modifier> modifier = ArgumentCaptor.forClass(Modifier.class); + verify(faction).addPersistentPrestigeModifier(modifier.capture()); + assertEquals("festival", modifier.getValue().getType()); + assertEquals(2.5, modifier.getValue().getAmount()); + assertTrue(modifier.getValue().isPersistent()); + verify(faction).updatePrestige(); + assertTrue(run("faction", "setpower", "realm", "4.25")); + verify(government).setPower(4.25); + } + + @ParameterizedTest + @ValueSource(strings = {"accept", "decline"}) + void pendingRequestsAreRequiredBeforeTheyCanBeResolved(String action) { + try (MockedStatic<RequestManager> requests = mockStatic(RequestManager.class)) { + assertTrue(run("faction", action)); + verify(player).sendMessage(contains("no requests")); + requests.when(() -> RequestManager.hasRequest(player)).thenReturn(true); + assertTrue(run("faction", action)); + if (action.equals("accept")) requests.verify(() -> RequestManager.accept(player)); + else requests.verify(() -> RequestManager.decline(player)); + } + } + + @Test + void missingFactionJoinReportsTheMissingIdWithoutConsumingAnInvite() { + assertTrue(run("faction", "join", "missing")); + verify(player).sendMessage(contains("No faction")); + verify(faction, never()).consumeInvite(anyString()); + } + + @ParameterizedTest + @ValueSource(strings = {"setrulertitle", "setrulingsystem", "setculture", "setreligion"}) + void leaderIdentityCommandsConvertUnderscoresAndRefuseOutsiders(String action) { + assertTrue(run("faction", action, "High_King")); + switch (action) { + case "setrulertitle" -> verify(faction).setRulerTitle("High King"); + case "setrulingsystem" -> verify(faction).setGovernment("High King"); + case "setculture" -> verify(faction).setCulture("High King"); + case "setreligion" -> verify(faction).setReligion("High King"); + } + factions.when(() -> FactionManager.getByLeader("Leader")).thenReturn(null); + assertTrue(run("faction", action, "Other")); + verify(player).sendMessage(contains("must be the leader")); + } + + private void admin() { + when(player.hasPermission(Permissions.Permission_Admin)).thenReturn(true); + } + + @ParameterizedTest + @ValueSource(strings = {"NaN", "Infinity", "-Infinity", "0.005"}) + void forcedWithdrawalRejectsInvalidMonetaryAmounts(String amount) { + admin(); + Bank bank = mock(Bank.class); + when(faction.getBank()).thenReturn(bank); + when(bank.getWealth()).thenReturn(100.0); + try (MockedStatic<DenarEconomy> economy = mockStatic(DenarEconomy.class)) { + var money = mock(MoneyManager.class); + economy.when(DenarEconomy::getMoneyManager).thenReturn(money); + when(money.amountToItems(anyDouble())).thenReturn(List.of()); + run("faction", "forcewithdraw", "realm", amount); + verify(bank, never()).withdraw(anyDouble()); + verify(money, never()).amountToItems(anyDouble()); + verify(player) + .sendMessage( + argThat( + (String message) -> message.contains("amount") || message.contains("Amount"))); + } + } + + @Test + void forcedWithdrawalReportsAMissingBank() { + admin(); + assertTrue(run("faction", "forcewithdraw", "realm", "10")); + verify(player).sendMessage(contains("bank")); + } + + @Test + void forcedWithdrawalDropsPhysicalCoinsThatCannotFitInTheInventoryExactlyOnce() { + admin(); + Bank bank = mock(Bank.class); + when(faction.getBank()).thenReturn(bank); + when(bank.getWealth()).thenReturn(100.0); + var coin = new ItemStack(Material.GOLD_NUGGET, 10); + when(player.getWorld()).thenReturn(gui.world); + for (int slot = 0; slot < player.getInventory().getSize(); slot++) { + player.getInventory().setItem(slot, new ItemStack(Material.STONE, 64)); + } + try (MockedStatic<DenarEconomy> economy = mockStatic(DenarEconomy.class)) { + var money = mock(MoneyManager.class); + economy.when(DenarEconomy::getMoneyManager).thenReturn(money); + when(money.amountToItems(10.0)).thenReturn(List.of(coin)); + assertTrue(run("faction", "forcewithdraw", "realm", "10")); + verify(bank).withdraw(10.0); + verify(player.getInventory()).addItem(coin); + ArgumentCaptor<ItemStack> dropped = ArgumentCaptor.forClass(ItemStack.class); + verify(gui.world).dropItemNaturally(eq(player.getLocation()), dropped.capture()); + assertEquals(Material.GOLD_NUGGET, dropped.getValue().getType()); + assertEquals(10, dropped.getValue().getAmount()); + for (ItemStack held : player.getInventory().getContents()) { + assertEquals(Material.STONE, held.getType()); + assertEquals(64, held.getAmount()); + } + } + } + + @ParameterizedTest + @CsvSource({"faction,deposit", "faction,withdraw", "guild,deposit", "guild,withdraw"}) + void bankTransfersValidateMembershipLocationAmountAndFunds(String name, String action) { + boolean deposit = action.equals("deposit"); + factions.when(() -> FactionManager.getByLeader("Leader")).thenReturn(null); + assertTrue(run(name, action, "10")); + verify(player).sendMessage(contains("need to be")); + member(); + factions.when(() -> FactionManager.getByLeader("Leader")).thenReturn(faction); + guildLeader(); + assertFalse(run(name, action, "10")); + verify(player).sendMessage(contains("not established a bank")); + Bank bank = mock(Bank.class); + when(faction.getBank()).thenReturn(bank); + when(guild.getBank()).thenReturn(bank); + Chunk here = mock(Chunk.class); + Location location = mock(Location.class); + when(location.getChunk()).thenReturn(here); + when(player.getLocation()).thenReturn(location); + assertFalse(run(name, action, "10")); + verify(player).sendMessage(contains("must be at your")); + when(bank.getChunk()).thenReturn(here); + assertFalse(run(name, action, "bad")); + verify(player).sendMessage(contains("whole cents")); + try (MockedStatic<DenarEconomy> economy = mockStatic(DenarEconomy.class)) { + var players = mock(PlayerManager.class); + var data = mock(PlayerData.class); + var pouch = mock(Account.class); + economy.when(DenarEconomy::getPlayerManager).thenReturn(players); + when(players.get(player)).thenReturn(data); + when(data.getPouch()).thenReturn(pouch); + assertFalse(run(name, action, "10")); + verify(player).sendMessage(contains("Not enough funds")); + when(bank.getWealth()).thenReturn(100.0); + when(pouch.getBal()).thenReturn(100.0); + assertTrue(run(name, action, "10.25")); + verify(pouch).change(deposit ? -10.25 : 10.25); + if (deposit) { + verify(bank).deposit(10.25); + verify(guild.getLedger().getHistory()).addDeposit("Leader", 10.25); + } else verify(bank).withdraw(10.25); + verify(player).sendMessage(contains("Bank Report")); + verify(player).playSound(player, Sound.BLOCK_NOTE_BLOCK_CHIME, 1f, 1f); + } + } + + @ParameterizedTest + @ValueSource(strings = {"faction", "guild"}) + void bankPlacementHonoursDomainPlacementRulesAndMovesOrCreatesTheBank(String name) { + member(); + guildLeader(); + Chunk chunk = mock(Chunk.class); + Location location = mock(Location.class); + when(location.getChunk()).thenReturn(chunk); + when(player.getLocation()).thenReturn(location); + try (MockedStatic<BankPlacementValidator> placement = + mockStatic(BankPlacementValidator.class)) { + placement + .when(() -> BankPlacementValidator.failureReasonForFaction(faction, location)) + .thenReturn("Outside your capital"); + placement + .when(() -> BankPlacementValidator.failureReasonForGuild(guild, location)) + .thenReturn("Outside your capital"); + assertTrue(run(name, "setbank")); + verify(player).sendMessage("Outside your capital"); + placement.reset(); + try (MockedConstruction<Bank> banks = mockConstruction(Bank.class)) { + assertTrue(run(name, "setbank")); + assertEquals(1, banks.constructed().size()); + if (name.equals("faction")) verify(faction).setBank(banks.constructed().getFirst()); + else verify(guild).setBank(banks.constructed().getFirst()); + } + Bank existing = mock(Bank.class); + when(faction.getBank()).thenReturn(existing); + when(guild.getBank()).thenReturn(existing); + assertTrue(run(name, "setbank")); + verify(existing).setChunk(chunk); + verify(player).sendMessage("§aBank moved"); + } + } + + @Test + void validForcedWithdrawalPaysPhysicalCoinsAndNotifiesOnlineMembers() { + admin(); + Bank bank = mock(Bank.class); + when(faction.getBank()).thenReturn(bank); + when(bank.getWealth()).thenReturn(100.0); + Player alice = gui.player("Alice"); + when(alice.isOnline()).thenReturn(true); + when(Bukkit.getPlayer("Alice")).thenReturn(alice); + try (MockedStatic<DenarEconomy> economy = mockStatic(DenarEconomy.class)) { + var money = mock(MoneyManager.class); + economy.when(DenarEconomy::getMoneyManager).thenReturn(money); + var coin = new ItemStack(Material.GOLD_NUGGET, 10); + when(money.amountToItems(10.0)).thenReturn(List.of(coin)); + assertTrue(run("faction", "forcewithdraw", "realm", "10")); + verify(bank).withdraw(10.0); + verify(player.getInventory()).addItem(coin); + verify(alice).sendMessage(contains("withdrawn from the faction by admins")); + assertTrue(run("faction", "forcewithdraw", "realm", "101")); + verify(player).sendMessage(contains("enough wealth")); + assertFalse(run("faction", "forcewithdraw", "realm", "-1")); + verify(bank, times(1)).withdraw(anyDouble()); + } + } + + @Test + void administrativeMaintenanceRepairsMembershipAndDeletesBanksWithoutOtherChanges() { + admin(); + List<String> members = new ArrayList<>(List.of("Alice")); + when(faction.getMembers()).thenReturn(members); + FactionManager.factions.add(faction); + assertTrue(run("faction", "refresh")); + assertEquals(List.of("Alice", "Leader"), members); + assertTrue(run("faction", "refresh")); + assertEquals(2, members.size()); + Bank bank = mock(Bank.class); + when(faction.getBank()).thenReturn(bank); + BankManager.banks.add(bank); + assertTrue(run("faction", "delbank", "realm")); + assertTrue(BankManager.banks.isEmpty()); + verify(faction).setBank(null); + assertTrue(run("faction", "provincecap")); + verify(faction).provinceCap(); + } + + @Test + void mapRegenerationAndReloadCommandsUseTheirDedicatedServices() { + admin(); + assertTrue(run("faction", "queueallnations")); + verify(map).queueAllNations(); + assertTrue(run("faction", "fullregen", "wrong")); + verify(map, never()).fullRegen(); + verify(player).sendMessage(contains("incorrect passcode")); + assertTrue(run("faction", "fullregen", "i_love_tfmc")); + verify(map).fullRegen(); + try (MockedStatic<SimpleFactions> plugin = mockStatic(SimpleFactions.class)) { + assertTrue(run("faction", "reloadtitles")); + plugin.verify(SimpleFactions::reloadTitles); + assertTrue(run("faction", "reloadconfigs")); + plugin.verify(SimpleFactions::reloadConfigs); + } + } + + @Test + void globalWealthReportCombinesAndLabelsTheSeparateBalances() { + admin(); + factions.when(FactionManager::getGlobalWealth).thenReturn(100.0); + factions.when(FactionManager::getPouchWealth).thenReturn(20.0); + factions.when(FactionManager::getBankWealth).thenReturn(5.0); + factions.when(FactionManager::getGlobalNodeWealth).thenReturn(25.0); + assertTrue(run("faction", "getglobalwealth")); + verify(player).sendMessage("§eGlobal Wealth: §6125.0d"); + verify(player).sendMessage("§aNode Percentage: §f20% §aof global wealth"); + } + + @ParameterizedTest + @ValueSource(strings = {"startelection", "endelection"}) + void electionCommandsRespectStateAndNotifyOnlyFactionMembers(String action) { + admin(); + Election election = mock(Election.class); + when(government.getElection()).thenReturn(election); + Player alice = gui.player("Alice"), outsider = gui.player("Visitor"); + online(alice, outsider); + if (action.equals("startelection")) { + assertTrue(run("faction", action, "realm")); + verify(player).sendMessage(contains("does not have elections")); + when(government.hasElections()).thenReturn(true); + when(election.isActive()).thenReturn(true); + assertTrue(run("faction", action, "realm")); + verify(player).sendMessage(contains("already active")); + when(election.isActive()).thenReturn(false); + assertTrue(run("faction", action, "realm")); + verify(election).start(); + verify(alice).sendMessage(contains("Election Started")); + } else { + assertTrue(run("faction", action, "realm")); + verify(player).sendMessage(contains("No active election")); + when(election.isActive()).thenReturn(true); + assertTrue(run("faction", action, "realm")); + verify(election).end(); + verify(alice).sendMessage(contains("Election Ended")); + } + verify(outsider, never()).sendMessage(anyString()); + } + + private void member() { + factions.when(() -> FactionManager.getByMember("Leader")).thenReturn(faction); + } + + @ParameterizedTest + @ValueSource(strings = {"faction", "guild"}) + void renamingRequiresLeadershipAndAcceptsOnlyConfirmedDisplayNames(String name) { + factions.when(() -> FactionManager.getByLeader("Leader")).thenReturn(null); + assertTrue(run(name, "rename", "New_Realm")); + verify(player).sendMessage(contains("must be the leader")); + factions.when(() -> FactionManager.getByLeader("Leader")).thenReturn(faction); + guildLeader(); + NameOperation operation = + name.equals("faction") ? NameOperation.FACTION_RENAME : NameOperation.GUILD_RENAME; + try (MockedStatic<DisplayNameGate> names = mockStatic(DisplayNameGate.class)) { + names + .when(() -> DisplayNameGate.check(player, operation, "New_Realm")) + .thenReturn(DisplayNameGate.Result.NEEDS_CONFIRM); + assertTrue(run(name, "rename", "New_Realm")); + verify(faction, never()).setName(anyString()); + verify(guild, never()).setName(anyString()); + names + .when(() -> DisplayNameGate.check(player, operation, "New_Realm")) + .thenReturn(DisplayNameGate.Result.OK); + assertTrue(run(name, "rename", "New_Realm")); + if (name.equals("faction")) { + verify(faction) + .setName(argThat((String value) -> "New Realm".equals(ChatColor.stripColor(value)))); + verify(map).enqueue(eq("nation"), nullable(String.class)); + } else + verify(guild) + .setName(argThat((String value) -> "New Realm".equals(ChatColor.stripColor(value)))); + } + } + + @ParameterizedTest + @ValueSource(strings = {"faction", "guild"}) + void bannerCommandsCopyOneBannerWithoutChangingTheHeldStack(String name) { + guildLeader(); + var stone = new ItemStack(Material.STONE); + when(player.getInventory().getItemInMainHand()).thenReturn(stone); + assertTrue(run(name, "setbanner")); + verify(player).sendMessage(contains("holding a banner")); + var held = new ItemStack(Material.WHITE_BANNER, 12); + when(player.getInventory().getItemInMainHand()).thenReturn(held); + assertTrue(run(name, "setbanner")); + ArgumentCaptor<ItemStack> banner = ArgumentCaptor.forClass(ItemStack.class); + if (name.equals("faction")) verify(faction).setBanner(banner.capture()); + else verify(guild).setBanner(banner.capture()); + assertNotSame(held, banner.getValue()); + assertEquals(1, banner.getValue().getAmount()); + assertEquals(12, held.getAmount()); + } + + @ParameterizedTest + @CsvSource({"setbanner,unused", "setcolour,'1,2,3'"}) + void baseGuildAppearanceIsChangedThroughTheFactionCommands(String action, String value) { + guildLeader(); + when(guild.isBase()).thenReturn(true); + assertTrue( + run( + "guild", + action.equals("setbanner") ? new String[] {action} : new String[] {action, value})); + verify(player).sendMessage(contains("base guild")); + verify(guild, never()).setRGB(anyString()); + verify(guild, never()).setBanner(any()); + } + + @ParameterizedTest + @CsvSource({ + "faction,1,Invalid format", + "guild,1,Invalid format", + "faction,2,must be numbers", + "guild,2,must be numbers", + "faction,3,between 0 and 255", + "guild,3,between 0 and 255" + }) + void invalidColoursHaveSpecificValidationMessages(String name, int code, String message) { + guildLeader(); + factions.when(() -> FactionManager.validateRGB("invalid")).thenReturn(code); + assertTrue(run(name, "setcolour", "invalid")); + verify(player).sendMessage(contains(message)); + verify(guild, never()).setRGB(anyString()); + verify(faction, never()).setRGB(anyString()); + } + + @Test + void validColoursQueueBothFactionMapStatesAndRespectGuildColourUniqueness() { + guildLeader(); + when(faction.getRGB()).thenReturn("1,1,1", "2,2,2"); + assertTrue(run("faction", "setcolour", "2,2,2")); + verify(faction).setRGB("2,2,2"); + verify(map).enqueue("nation", "1,1,1"); + verify(map).enqueue("nation", "2,2,2"); + try (MockedStatic<RandomRGB> colours = mockStatic(RandomRGB.class)) { + assertTrue(run("guild", "setcolour", "2,2,2")); + verify(player).sendMessage(contains("already used")); + colours.when(() -> RandomRGB.isFree("2,2,2")).thenReturn(true); + assertTrue(run("guild", "setcolour", "2,2,2")); + verify(guild).setRGB("2,2,2"); + } + } + + @ParameterizedTest + @ValueSource(strings = {"faction", "guild"}) + void leavingRequiresMembershipAndNonLeadership(String name) { + factions.when(() -> FactionManager.getByLeader("Leader")).thenReturn(null); + assertTrue(run(name, "leave")); + verify(player).sendMessage(contains("not in a")); + member(); + guildLeader(); + factions.when(() -> FactionManager.getByLeader("Leader")).thenReturn(faction); + assertTrue(run(name, "leave")); + verify(player).sendMessage(contains("cannot abandon")); + factions.when(() -> FactionManager.getByLeader("Leader")).thenReturn(null); + factions.when(() -> FactionManager.getGuildByLeader("Leader")).thenReturn(null); + if (name.equals("faction")) { + when(faction.isInGuild("Leader")).thenReturn(true); + assertTrue(run(name, "leave")); + verify(player).sendMessage(contains("use /guild leave")); + when(faction.isInGuild("Leader")).thenReturn(false); + } + assertTrue(run(name, "leave")); + if (name.equals("faction")) { + verify(faction).forceRemoveMember("Leader"); + verify(faction).updatePrestige(); + } else verify(guild).kick("Leader"); + verify(player).sendMessage(contains("Left ")); + } + + @ParameterizedTest + @ValueSource(strings = {"faction", "guild"}) + void joiningConsumesAnInvitationBeforeAddingAndNotifyingMembers(String name) { + String id = name.equals("faction") ? "realm" : "merchants"; + factions.when(() -> FactionManager.canJoinGuild(player)).thenReturn(true); + assertTrue(run(name, "join", id)); + verify(player).sendMessage(contains("need to be invited")); + verify(faction, never()).addMember(anyString()); + verify(guild, never()).addMember(anyString()); + when(faction.consumeInvite("Leader")).thenReturn(true); + when(guild.consumeInvite("Leader")).thenReturn(true); + Guild previous = mock(Guild.class); + factions.when(() -> FactionManager.getGuildByMember("Leader")).thenReturn(previous); + Player alice = gui.player("Alice"), outsider = gui.player("Visitor"); + online(alice, outsider); + assertTrue(run(name, "join", id)); + if (name.equals("faction")) verify(faction).addMember("Leader"); + else { + verify(previous).kick("Leader"); + verify(guild).addMember("Leader"); + } + verify(faction).updatePrestige(); + verify(alice).sendMessage(contains("joined the faction")); + verify(outsider, never()).sendMessage(anyString()); + } + + @Test + void joiningRefusesExistingMembershipOrGuildLeadership() { + member(); + assertTrue(run("faction", "join", "realm")); + verify(player).sendMessage(contains("Already in a faction")); + guildLeader(); + assertTrue(run("guild", "join", "merchants")); + verify(player).sendMessage(contains("leader of a guild")); + factions.when(() -> FactionManager.getGuildByLeader("Leader")).thenReturn(null); + assertTrue(run("guild", "join", "merchants")); + verify(player).sendMessage(contains("already in a guild")); + verify(faction, never()).consumeInvite(anyString()); + verify(guild, never()).consumeInvite(anyString()); + } + + @Test + void factionKickProtectsLeadersGuildMembersAndNonMembers() { + assertTrue(run("faction", "kick", "Alice")); + verify(player).sendMessage(contains("need to have a faction")); + member(); + when(faction.getLeader()).thenReturn("Ruler"); + assertTrue(run("faction", "kick", "Alice")); + verify(player).sendMessage(contains("Only the leader")); + when(faction.getLeader()).thenReturn("Leader"); + assertTrue(run("faction", "kick", "Leader")); + verify(player).sendMessage(contains("cannot dismiss the leader")); + assertTrue(run("faction", "kick", "Visitor")); + verify(player).sendMessage(contains("not a member")); + when(faction.isInGuild("Alice")).thenReturn(true); + assertTrue(run("faction", "kick", "Alice")); + verify(player).sendMessage(contains("member of a guild")); + verify(faction, never()).forceRemoveMember(anyString()); + when(faction.isInGuild("Alice")).thenReturn(false); + Player alice = gui.player("Alice"), visitor = gui.player("Visitor"); + online(alice, visitor); + assertTrue(run("faction", "kick", "Alice")); + verify(faction).forceRemoveMember("Alice"); + verify(alice).sendMessage(contains("kicked you")); + verify(visitor, never()).sendMessage(anyString()); + } + + @ParameterizedTest + @ValueSource(strings = {"setleader", "forceleader"}) + void factionLeaderChangesCheckEligibilityAndNotifyFactionMembers(String action) { + if (action.equals("forceleader")) admin(); + else { + assertTrue(run("faction", action, "Alice")); + verify(player).sendMessage(contains("need to have a faction")); + member(); + when(faction.getLeader()).thenReturn("Ruler"); + assertTrue(run("faction", action, "Alice")); + verify(player).sendMessage(contains("Only the leader")); + when(faction.getLeader()).thenReturn("Leader"); + when(government.hasLeaderElections()).thenReturn(true); + assertTrue(run("faction", action, "Alice")); + verify(player).sendMessage(contains("democracy")); + when(government.hasLeaderElections()).thenReturn(false); + } + String[] leader = + action.equals("forceleader") + ? new String[] {action, "realm", "Leader"} + : new String[] {action, "Leader"}; + assertTrue(run("faction", leader)); + verify(player).sendMessage(contains("already the leader")); + String[] missing = + action.equals("forceleader") + ? new String[] {action, "realm", "Visitor"} + : new String[] {action, "Visitor"}; + assertTrue(run("faction", missing)); + verify(player).sendMessage(contains("not in the faction")); + String[] eligible = + action.equals("forceleader") + ? new String[] {action, "realm", "Alice"} + : new String[] {action, "Alice"}; + assertTrue(run("faction", eligible)); + verify(player).sendMessage(contains("not eligible")); + when(faction.canBecomeLeader("Alice")).thenReturn(true); + Player alice = gui.player("Alice"), visitor = gui.player("Visitor"); + online(alice, visitor); + assertTrue(run("faction", eligible)); + verify(faction).setLeader("Alice"); + verify(alice).sendMessage(contains("Alice is the new faction leader")); + verify(visitor, never()).sendMessage(anyString()); + } + + @Test + void guildLeaderChangesCheckMembershipAndNotifyOnlyGuildMembers() { + assertTrue(run("guild", "setleader", "Alice")); + verify(player).sendMessage(contains("need to have a guild")); + guildLeader(); + when(guild.getLeader()).thenReturn("Other"); + assertTrue(run("guild", "setleader", "Alice")); + verify(player).sendMessage(contains("Only the leader")); + when(guild.getLeader()).thenReturn("Leader"); + assertTrue(run("guild", "setleader", "Visitor")); + verify(player).sendMessage(contains("not in the guild")); + when(guild.isMember("Leader")).thenReturn(true); + assertTrue(run("guild", "setleader", "Leader")); + verify(player).sendMessage(contains("already the leader")); + when(guild.isMember("Alice")).thenReturn(true); + Player alice = gui.player("Alice"), visitor = gui.player("Visitor"); + online(alice, visitor); + assertTrue(run("guild", "setleader", "Alice")); + verify(guild).setLeader("Alice"); + verify(alice).sendMessage(contains("new guild leader")); + verify(visitor, never()).sendMessage(anyString()); + } + + @Test + void administrativeJoinRefusesExistingMembershipAndNotifiesAfterAdding() { + admin(); + assertTrue(run("faction", "forcejoin", "realm", "Alice")); + verify(player).sendMessage(contains("already in the faction")); + Faction other = mock(Faction.class); + factions.when(() -> FactionManager.getByMember("Bob")).thenReturn(other); + assertTrue(run("faction", "forcejoin", "realm", "Bob")); + verify(player).sendMessage(contains("already in a faction")); + factions.when(() -> FactionManager.getByMember("Bob")).thenReturn(null); + Player alice = gui.player("Alice"), visitor = gui.player("Visitor"); + online(alice, visitor); + assertTrue(run("faction", "forcejoin", "realm", "Bob")); + verify(faction).addMember("Bob"); + verify(alice).sendMessage(contains("Bob joined")); + verify(visitor, never()).sendMessage(anyString()); + } + + private void guildLeader() { + factions.when(() -> FactionManager.getGuildByMember("Leader")).thenReturn(guild); + factions.when(() -> FactionManager.getGuildByLeader("Leader")).thenReturn(guild); + } + + @Test + void titleGrantsAndDestructionValidateExistenceAndOwnership() { + admin(); + Title title = mock(Title.class); + when(title.getId()).thenReturn("crown"); + when(title.getName()).thenReturn("The Crown"); + try (MockedStatic<TitleLoader> titles = mockStatic(TitleLoader.class); + MockedStatic<TitleManager> ownership = mockStatic(TitleManager.class)) { + assertFalse(run("faction", "granttitle", "missing", "crown")); + verify(player).sendMessage("§cNo faction by that id"); + assertFalse(run("faction", "granttitle", "realm", "crown")); + assertFalse(run("faction", "destroytitle", "crown")); + verify(player, times(2)).sendMessage("§cNo title by that id"); + titles.when(() -> TitleLoader.getById("crown")).thenReturn(title); + assertFalse(run("faction", "destroytitle", "crown")); + verify(player).sendMessage("§cNo faction owns that title"); + assertTrue(run("faction", "granttitle", "realm", "crown")); + verify(faction).addTitle(title); + ownership.when(() -> TitleManager.getOwner(title)).thenReturn(faction); + assertFalse(run("faction", "granttitle", "realm", "crown")); + verify(player).sendMessage(contains("already owns that title")); + assertTrue(run("faction", "destroytitle", "crown")); + verify(faction).removeTitle(title); + } + } + + @Test + void subjectTransferRejectsMissingFactionsLoopsAndInvalidVassalOwnership() { + admin(); + Faction other = mock(Faction.class); + when(other.getId()).thenReturn("other"); + when(other.getName()).thenReturn("Other"); + try (MockedStatic<RelationManager> relations = mockStatic(RelationManager.class)) { + assertFalse(run("faction", "transfersubject", "missing", "other")); + verify(player).sendMessage("§cNo faction by the id missing"); + assertFalse(run("faction", "transfersubject", "realm", "other")); + verify(player).sendMessage("§cNo faction by the id other"); + factions.when(() -> FactionManager.getByString("other")).thenReturn(other); + assertFalse(run("faction", "transfersubject", "realm", "other")); + verify(player).sendMessage(contains("not a subject")); + relations.when(() -> RelationManager.getOverlord(faction)).thenReturn("other"); + assertFalse(run("faction", "transfersubject", "realm", "other")); + verify(player).sendMessage(contains("already a subject")); + relations.when(() -> RelationManager.getOverlord(faction)).thenReturn("former"); + relations.when(() -> RelationManager.isOnOverlordPath(other, faction)).thenReturn(true); + assertFalse(run("faction", "transfersubject", "realm", "other")); + verify(player).sendMessage(contains("cause a loop")); + relations.when(() -> RelationManager.isOnOverlordPath(other, faction)).thenReturn(false); + assertFalse(run("faction", "transfersubject", "realm", "other")); + verify(player).sendMessage(contains("cannot have vassals")); + when(other.canHaveVassals()).thenReturn(true); + assertTrue(run("faction", "transfersubject", "realm", "other")); + relations.verify(() -> RelationManager.transferSubject(faction, other)); + } + } + + @ParameterizedTest + @ValueSource(strings = {"setrelation", "settreaty"}) + void diplomaticCommandsDistinguishRelationsTreatiesAndTrade(String action) { + admin(); + Faction other = mock(Faction.class); + RelationType type = mock(RelationType.class); + when(type.getName()).thenReturn("Agreement"); + try (MockedStatic<RelationLoader> types = mockStatic(RelationLoader.class); + MockedStatic<RelationManager> relations = mockStatic(RelationManager.class)) { + assertFalse(run("faction", action, "missing", "other", "agreement")); + verify(player).sendMessage("§cNo faction by the id missing"); + assertFalse(run("faction", action, "realm", "other", "agreement")); + verify(player).sendMessage("§cNo faction by the id other"); + factions.when(() -> FactionManager.getByString("other")).thenReturn(other); + assertFalse(run("faction", action, "realm", "other", "agreement")); + verify(player).sendMessage(contains("with the id agreement")); + types.when(() -> RelationLoader.getType("agreement")).thenReturn(type); + if (action.equals("setrelation")) { + when(type.isTradeAgreement()).thenReturn(true); + assertFalse(run("faction", action, "realm", "other", "agreement")); + verify(player).sendMessage(contains("Use /faction settreaty")); + when(type.isTradeAgreement()).thenReturn(false); + assertTrue(run("faction", action, "realm", "other", "agreement")); + relations.verify(() -> RelationManager.setRelation(player, type, other, faction, false)); + } else { + assertFalse(run("faction", action, "realm", "other", "agreement")); + when(type.isTreaty()).thenReturn(true); + assertTrue(run("faction", action, "realm", "other", "agreement")); + relations.verify(() -> RelationManager.setTreatyRelationForced(type, other, faction)); + when(type.isTreaty()).thenReturn(false); + when(type.isTradeAgreement()).thenReturn(true); + assertTrue(run("faction", action, "realm", "other", "agreement")); + relations.verify(() -> RelationManager.setTradeRelationForced(type, other, faction)); + } + } + } + + @Test + void lawChangeValidatesTheTemplateAndFactionGroupBeforeApplying() { + admin(); + var handler = mock(LawHandler.class); + when(faction.getLawHandler()).thenReturn(handler); + var template = mock(LawGroup.class); + when(template.getId()).thenReturn("levy"); + var chosen = mock(Law.class); + when(chosen.getId()).thenReturn("low"); + var owned = mock(LawGroup.class); + try (MockedStatic<LawLoader> laws = mockStatic(LawLoader.class)) { + assertFalse(run("faction", "setlaw", "missing", "levy", "low")); + verify(player).sendMessage(contains("No faction")); + assertFalse(run("faction", "setlaw", "realm", "levy", "low")); + verify(player).sendMessage(contains("No law group")); + laws.when(() -> LawLoader.getByString("levy")).thenReturn(template); + assertFalse(run("faction", "setlaw", "realm", "levy", "low")); + verify(player).sendMessage(contains("No law low")); + when(template.getLaw("low")).thenReturn(chosen); + assertFalse(run("faction", "setlaw", "realm", "levy", "low")); + verify(player).sendMessage(contains("has no group")); + when(handler.getGroup("levy")).thenReturn(owned); + assertTrue(run("faction", "setlaw", "realm", "levy", "low")); + verify(faction).applyLaw(chosen, owned); + } + } + + @Test + void staffStanceUpdatesResolveFactionMainGuildsAndPersistOnlyValidStances() { + admin(); + assertFalse(run("faction", "setstance", "missing", "support")); + verify(player).sendMessage(contains("No guild or faction")); + assertFalse(run("faction", "setstance", "merchants", "unknown")); + verify(player).sendMessage(contains("oppose, neutral, or support")); + try (MockedConstruction<Database> databases = mockConstruction(Database.class)) { + assertTrue(run("faction", "setstance", "realm", "SUPPORT")); + verify(guild).setStance(Stance.SUPPORT); + assertEquals(1, databases.constructed().size()); + verify(databases.constructed().getFirst()).saveFaction(faction); + } + } + + @Test + void usurpChecksBothFactionsAndReportsSuccessfulTransfers() { + admin(); + assertFalse(run("faction", "usurp", "missing", "realm")); + verify(player).sendMessage(contains("No faction by the id missing")); + assertFalse(run("faction", "usurp", "realm", "other")); + verify(player).sendMessage(contains("No faction by the id other")); + Faction other = mock(Faction.class); + factions.when(() -> FactionManager.getByString("other")).thenReturn(other); + assertTrue(run("faction", "usurp", "realm", "other")); + Title title = mock(Title.class); + when(title.getName()).thenReturn("Crown"); + factions.when(() -> FactionManager.usurp(player, faction, other)).thenReturn(title); + assertTrue(run("faction", "usurp", "realm", "other")); + verify(player).sendMessage(contains("usurped Crown")); + } + + @Test + void commandDispatchDelegatesConsoleCapableAndEspionageCommandsWithTheirArguments() { + CommandSender console = mock(CommandSender.class); + try (MockedStatic<TradeGraphCommand> trade = mockStatic(TradeGraphCommand.class); + MockedStatic<EspionageCommands> espionage = mockStatic(EspionageCommands.class); + MockedStatic<TitleAdminCommand> titles = mockStatic(TitleAdminCommand.class)) { + String[] graph = {"tradegraph", "status"}; + trade.when(() -> TradeGraphCommand.handle(console, graph)).thenReturn(true); + assertTrue(run(console, "faction", graph)); + trade.verify(() -> TradeGraphCommand.handle(console, graph)); + String[] reload = {"reloadespionage"}; + espionage.when(() -> EspionageCommands.reload(console, reload)).thenReturn(true); + assertTrue(run(console, "faction", reload)); + espionage.verify(() -> EspionageCommands.reload(console, reload)); + String[] edit = {TitleAdminCommand.SUBCOMMAND, "list"}; + titles.when(() -> TitleAdminCommand.handle(console, edit)).thenReturn(true); + assertTrue(run(console, "faction", edit)); + titles.verify(() -> TitleAdminCommand.handle(console, edit)); + String[] positions = {"positions"}; + espionage.when(() -> EspionageCommands.matches("positions")).thenReturn(true); + espionage.when(() -> EspionageCommands.handle(player, positions)).thenReturn(true); + assertTrue(run("faction", positions)); + espionage.verify(() -> EspionageCommands.handle(player, positions)); + } + } + + private void online(Player... players) { + when(Bukkit.getOnlinePlayers()).thenAnswer(call -> List.of(players)); + } + + @ParameterizedTest + @ValueSource(strings = {"faction", "guild"}) + void menuCommandsRouteToThePlayersOwnDomainAndListToTheOverview(String name) { + assertTrue(run(name, "menu")); + verify(player).sendMessage(contains("not in a")); + member(); + guildLeader(); + try (MockedConstruction<InventoryManager> menus = mockConstruction(InventoryManager.class)) { + assertTrue(run(name, "menu")); + assertTrue(run(name, "list")); + assertEquals(2, menus.constructed().size()); + if (name.equals("faction")) { + verify(menus.constructed().get(0)).factionView(player, faction); + verify(menus.constructed().get(1)).factionList(player); + } else { + verify(menus.constructed().get(0)).guildView(player, guild); + verify(menus.constructed().get(1)).guildList(player); + } + } + } + + @Test + void guildCreationRejectsConflictingMembershipOrAnUnconfirmedName() { + assertTrue(run("guild", "create", "New_Guild")); + verify(player).sendMessage(contains("need to be in a faction")); + member(); + when(faction.isInGuild("Leader")).thenReturn(true); + assertTrue(run("guild", "create", "New_Guild")); + verify(player).sendMessage(contains("already in a guild")); + when(faction.isInGuild("Leader")).thenReturn(false); + assertTrue(run("guild", "create", "New_Guild")); + verify(player).sendMessage(contains("leader of the faction")); + when(faction.getLeader()).thenReturn("Other"); + factions.when(() -> FactionManager.guildExists("New_Guild")).thenReturn(true); + assertTrue(run("guild", "create", "New_Guild")); + verify(player).sendMessage(contains("guild already exists")); + factions.when(() -> FactionManager.guildExists("New_Guild")).thenReturn(false); + try (MockedStatic<DisplayNameGate> names = mockStatic(DisplayNameGate.class); + MockedConstruction<Guild> created = mockConstruction(Guild.class)) { + names + .when(() -> DisplayNameGate.check(player, NameOperation.GUILD_CREATE, "New_Guild")) + .thenReturn(DisplayNameGate.Result.NEEDS_CONFIRM); + assertTrue(run("guild", "create", "New_Guild")); + assertTrue(created.constructed().isEmpty()); + names + .when(() -> DisplayNameGate.check(player, NameOperation.GUILD_CREATE, "New_Guild")) + .thenReturn(DisplayNameGate.Result.OK); + assertTrue(run("guild", "create", "New_Guild")); + assertEquals(1, created.constructed().size()); + verify(guilds).addGuild(created.constructed().getFirst()); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void factionCreationHonoursNameConfirmationAndTheCreateEvent(boolean cancelled) { + member(); + assertTrue(run("faction", "create", "New_Realm")); + verify(player).sendMessage(contains("already have a faction")); + factions.when(() -> FactionManager.getByMember("Leader")).thenReturn(null); + PluginManager plugins = mock(PluginManager.class); + when(Bukkit.getPluginManager()).thenReturn(plugins); + doAnswer( + call -> { + ((FactionCreateEvent) call.getArgument(0)).setCancelled(cancelled); + return null; + }) + .when(plugins) + .callEvent(any()); + try (MockedStatic<DisplayNameGate> names = mockStatic(DisplayNameGate.class); + MockedConstruction<Faction> created = mockConstruction(Faction.class)) { + names + .when(() -> DisplayNameGate.check(player, NameOperation.FACTION_CREATE, "New_Realm")) + .thenReturn(DisplayNameGate.Result.NEEDS_CONFIRM); + assertTrue(run("faction", "create", "New_Realm")); + assertTrue(created.constructed().isEmpty()); + names + .when(() -> DisplayNameGate.check(player, NameOperation.FACTION_CREATE, "New_Realm")) + .thenReturn(DisplayNameGate.Result.OK); + assertTrue(run("faction", "create", "New_Realm")); + assertEquals(1, created.constructed().size()); + Faction result = created.constructed().getFirst(); + factions.verify(() -> FactionManager.addFaction(result), cancelled ? never() : times(1)); + ArgumentCaptor<Event> event = ArgumentCaptor.forClass(Event.class); + verify(plugins).callEvent(event.capture()); + assertSame(result, ((FactionCreateEvent) event.getValue()).getFaction()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"delete", "forcedelete"}) + void factionDeletionHonoursCancellationAndRemovesTheBankOnlyAfterApproval(String action) { + admin(); + Bank bank = mock(Bank.class); + when(faction.getBank()).thenReturn(bank); + BankManager.banks.add(bank); + Faction other = mock(Faction.class); + HashMap<String, Relation> relations = new HashMap<>(); + relations.put("realm", mock(Relation.class)); + when(other.getRelations()).thenReturn(relations); + FactionManager.factions.add(other); + PluginManager plugins = mock(PluginManager.class); + when(Bukkit.getPluginManager()).thenReturn(plugins); + doAnswer( + call -> { + ((FactionDeleteEvent) call.getArgument(0)).setCancelled(true); + return null; + }) + .when(plugins) + .callEvent(any()); + assertTrue(run("faction", action, "realm")); + factions.verify(() -> FactionManager.deleteFaction(faction), never()); + assertEquals(List.of(bank), BankManager.banks); + assertTrue(relations.containsKey("realm")); + doNothing().when(plugins).callEvent(any()); + assertTrue(run("faction", action, "realm")); + factions.verify(() -> FactionManager.deleteFaction(faction)); + assertTrue(BankManager.banks.isEmpty()); + if (action.equals("delete")) assertFalse(relations.containsKey("realm")); + verify(player).sendMessage(contains("deleted!")); + } + + @Test + void factionDeletionRequiresMembershipLeadershipAndSettledFinances() { + assertTrue(run("faction", "delete", "missing")); + verify(player).sendMessage(contains("does not exist")); + when(faction.getMembers()).thenReturn(List.of()); + assertTrue(run("faction", "delete", "realm")); + verify(player).sendMessage(contains("not part of")); + when(faction.getMembers()).thenReturn(List.of("Leader")); + when(guild.isBankrupt()).thenReturn(true); + assertTrue(run("faction", "delete", "realm")); + verify(player).sendMessage(contains("bankruptcy")); + when(guild.isBankrupt()).thenReturn(false); + var loan = mock(Loan.class); + when(guild.getLoanHandler().getLoansTaken()).thenReturn(List.of(loan)); + assertTrue(run("faction", "delete", "realm")); + verify(player).sendMessage(contains("active loans")); + when(guild.getLoanHandler().getLoansTaken()).thenReturn(List.of()); + when(faction.getLeader()).thenReturn("Other"); + assertTrue(run("faction", "delete", "realm")); + verify(player).sendMessage(contains("Only the faction leader")); + when(faction.getLeader()).thenReturn("Leader"); + Bank bank = mock(Bank.class); + when(faction.getBank()).thenReturn(bank); + when(bank.getWealth()).thenReturn(1.0); + assertTrue(run("faction", "delete", "realm")); + verify(player).sendMessage(contains("bank balance is above 0")); + factions.verify(() -> FactionManager.deleteFaction(any()), never()); + } + + @Test + void claimsRequireAnExistingCapitalAndUnclaimsResolveAnAuthorizedFaction() { + member(); + assertTrue(run("faction", "claim")); + verify(player).sendMessage(contains("claim your first province")); + when(faction.getProvinces()).thenReturn(List.of(1)); + try (MockedStatic<RestServer> rest = mockStatic(RestServer.class)) { + rest.when(() -> RestServer.getProvince(player)).thenReturn(-2); + assertTrue(run("faction", "claim")); + assertTrue(run("faction", "unclaim")); + verify(player, times(2)).sendMessage(contains("could not resolve province")); + rest.when(() -> RestServer.getProvince(player)).thenReturn(7); + assertTrue(run("faction", "claim")); + verify(map).claim(player, faction, 7, false); + assertTrue(run("faction", "unclaim")); + verify(map).unclaim(player, faction, 7); + assertTrue(run("faction", "unclaim", "realm")); + verify(player).sendMessage(contains("do not have access")); + admin(); + assertTrue(run("faction", "unclaim", "missing")); + verify(player).sendMessage(contains("No faction by the id missing")); + assertTrue(run("faction", "unclaim", "realm")); + verify(map, times(2)).unclaim(player, faction, 7); + } + } + + @ParameterizedTest + @ValueSource(strings = {"faction", "guild"}) + void capitalsValidateLocationNameOwnershipAndDomainRules(String name) { + guildLeader(); + var settlements = mock(SettlementHandler.class); + when(faction.getSettlementHandler()).thenReturn(settlements); + try (MockedStatic<RestServer> rest = mockStatic(RestServer.class); + MockedStatic<DisplayNameGate> names = mockStatic(DisplayNameGate.class)) { + rest.when(() -> RestServer.getProvince(player)).thenReturn(-2); + assertTrue(run(name, "setcapital")); + verify(player).sendMessage(contains("could not resolve province")); + rest.when(() -> RestServer.getProvince(player)).thenReturn(0); + assertTrue(run(name, "setcapital")); + verify(player).sendMessage(contains("no province")); + rest.when(() -> RestServer.getProvince(player)).thenReturn(7); + when(settlements.requiresFoundingName(7)).thenReturn(true); + if (name.equals("faction")) { + assertTrue(run(name, "setcapital")); + verify(player).sendMessage(contains("Name required")); + } + names + .when(() -> DisplayNameGate.check(player, NameOperation.SETTLEMENT_FOUND, "Capital")) + .thenReturn(DisplayNameGate.Result.NEEDS_CONFIRM); + assertTrue(run(name, "setcapital", "Capital")); + verify(map, never()).claimForCapital(any(), any(), anyInt(), anyBoolean()); + names + .when(() -> DisplayNameGate.check(player, NameOperation.SETTLEMENT_FOUND, "Capital")) + .thenReturn(DisplayNameGate.Result.OK); + Faction foreign = mock(Faction.class); + when(foreign.getId()).thenReturn("foreign"); + factions.when(() -> FactionManager.getByProvince(7)).thenReturn(foreign); + assertTrue(run(name, "setcapital", "Capital")); + verify(player).sendMessage(contains("owned by another faction")); + factions.when(() -> FactionManager.getByProvince(7)).thenReturn(null); + when(faction.getProvinces()).thenReturn(List.of(1)); + assertTrue(run(name, "setcapital", "Capital")); + verify(player).sendMessage(contains("doesn't own this province")); + when(faction.getProvinces()).thenReturn(List.of()); + assertTrue(run(name, "setcapital", "Capital")); + verify(map).claimForCapital(player, faction, 7, true); + when(faction.ownsProvince(7)).thenReturn(true); + var denied = CapitalResult.fail("Settlement unavailable"); + when(settlements.validateFactionCapital(player, 7, "Capital")).thenReturn(denied); + when(settlements.resolveGuildCapital(player, guild, 7, "Capital")).thenReturn(denied); + assertTrue(run(name, "setcapital", "Capital")); + verify(player).sendMessage("Settlement unavailable"); + var success = CapitalResult.ok("Capital founded"); + when(settlements.validateFactionCapital(player, 7, "Capital")).thenReturn(success); + when(settlements.resolveGuildCapital(player, guild, 7, "Capital")).thenReturn(success); + try (MockedStatic<CapitalMovePrompt> prompt = mockStatic(CapitalMovePrompt.class)) { + assertTrue(run(name, "setcapital", "Capital")); + if (name.equals("faction")) + prompt.verify( + () -> CapitalMovePrompt.applyFactionCapitalMove(player, faction, 7, "Capital")); + else { + verify(guild).setCapital(7); + verify(player).playSound(player, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); + } + } + } + } + + @Test + void anExistingCapitalIsRenamedOrMovedThroughTheConfirmationPrompt() { + var settlements = mock(SettlementHandler.class); + when(faction.getSettlementHandler()).thenReturn(settlements); + when(faction.ownsProvince(7)).thenReturn(true); + when(faction.hasCapital()).thenReturn(true); + when(faction.getCapital()).thenReturn(7); + when(settlements.validateFactionCapital(eq(player), eq(7), nullable(String.class))) + .thenReturn(CapitalResult.ok("valid")); + try (MockedStatic<RestServer> rest = mockStatic(RestServer.class); + MockedStatic<DisplayNameGate> names = mockStatic(DisplayNameGate.class); + MockedStatic<CapitalMovePrompt> prompt = mockStatic(CapitalMovePrompt.class)) { + rest.when(() -> RestServer.getProvince(player)).thenReturn(7); + assertTrue(run("faction", "setcapital")); + verify(player).sendMessage(contains("already set here")); + when(settlements.rename(7, "Capital", true)).thenReturn(CapitalResult.fail("Duplicate name")); + assertTrue(run("faction", "setcapital", "Capital")); + verify(player).sendMessage("Duplicate name"); + when(settlements.rename(7, "Capital", true)).thenReturn(CapitalResult.ok("Renamed")); + assertTrue(run("faction", "setcapital", "Capital")); + prompt.verify(() -> CapitalMovePrompt.begin(player, faction, 7, "Capital", true)); + when(faction.getCapital()).thenReturn(1); + assertTrue(run("faction", "setcapital", "Capital")); + prompt.verify(() -> CapitalMovePrompt.begin(player, faction, 7, "Capital", false)); + } + } + + private boolean run(CommandSender sender, String commandName, String... args) { + Command command = mock(Command.class); + when(command.getName()).thenReturn(commandName); + return commands.onCommand(sender, command, commandName, args); + } + + @Test + void administratorGuildBankGrantsValidateTheGuildAmountAndLedger() { + CommandSender console = mock(CommandSender.class); + assertTrue(run(console, "faction", "addguildbank", "merchants", "10")); + verify(console).sendMessage(contains("do not have access")); + when(console.hasPermission(Permissions.Permission_Admin)).thenReturn(true); + assertTrue(run(console, "faction", "addguildbank")); + verify(console).sendMessage(contains("Usage:")); + assertTrue(run(console, "faction", "addguildbank", "missing", "10")); + verify(console).sendMessage(contains("guild does not exist")); + assertTrue(run(console, "faction", "addguildbank", "merchants", "NaN")); + verify(console, times(2)).sendMessage(contains("Usage:")); + assertTrue(run(console, "faction", "addguildbank", "merchants", "10")); + verify(console).sendMessage(contains("does not have a bank")); + Bank bank = mock(Bank.class); + when(guild.getBank()).thenReturn(bank); + when(bank.getWealth()).thenReturn(10.0); + try (MockedStatic<GuildBankGrant> grant = mockStatic(GuildBankGrant.class)) { + assertTrue(run(console, "faction", "addguildbank", "merchants", "10")); + verify(console, times(2)).sendMessage(contains("does not have a bank")); + } + assertTrue(run(console, "faction", "addguildbank", "merchants", "10")); + verify(bank).deposit(10.0); + verify(guild.getLedger().getHistory()).addDeposit("Compensation", 10.0); + verify(console).sendMessage(contains("New balance:")); + } + + @ParameterizedTest + @ValueSource( + strings = { + "vehicle transfer fort", + "vehicle transfer", + "transfervehicle fort", + "transfervehicle", + "vehicle take", + "vehicle give Alice", + "vehicle give", + "vehicle handover Alice", + "vehicle handover", + "vehicle maintenance pay", + "vehicle maintenance pay bank" + }) + void vehicleCommandsRouteEachActionAndOptionalTarget(String command) { + String[] args = command.split(" "); + try (MockedStatic<VehicleFactionCommands> vehicles = mockStatic(VehicleFactionCommands.class)) { + assertTrue(run("faction", args)); + if (command.contains("transfer")) + vehicles.verify( + () -> + VehicleFactionCommands.armTransfer( + player, command.endsWith("fort") ? "fort" : null)); + else if (command.endsWith("take")) + vehicles.verify(() -> VehicleFactionCommands.armTake(player)); + else if (command.contains("give")) + vehicles.verify( + () -> + VehicleFactionCommands.armGive(player, command.endsWith("Alice") ? "Alice" : null)); + else if (command.contains("handover")) + vehicles.verify( + () -> + VehicleFactionCommands.armHandover( + player, command.endsWith("Alice") ? "Alice" : null)); + else + vehicles.verify( + () -> + VehicleFactionCommands.armMaintenancePay( + player, command.endsWith("bank") ? PaymentSource.BANK : PaymentSource.POUCH)); + } + } + + @Test + void vehicleUsageAndFindingValidateTheLeaderAndInstallation() { + assertTrue(run("faction", "vehicle")); + verify(player) + .sendMessage( + net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenanceMessages + .vehicleUsage()); + assertTrue(run("faction", "vehicle", "maintenance")); + verify(player) + .sendMessage( + net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenanceMessages + .payUsage()); + factions.when(() -> FactionManager.getByLeader("Leader")).thenReturn(null); + assertTrue(run("faction", "findvehicles", "fort")); + verify(player).sendMessage(VehicleFindMessages.notLeader()); + factions.when(() -> FactionManager.getByLeader("Leader")).thenReturn(faction); + assertTrue(run("faction", "findvehicles")); + verify(player).sendMessage(VehicleFindMessages.usage()); + try (MockedStatic<VehicleFindMessages> finds = mockStatic(VehicleFindMessages.class)) { + finds.when(VehicleFindMessages::unknownInstallation).thenCallRealMethod(); + finds + .when(() -> VehicleFindMessages.resolveInstallation(installations, "missing")) + .thenCallRealMethod(); + finds + .when(() -> VehicleFindMessages.resolveInstallation(installations, "fort")) + .thenCallRealMethod(); + assertTrue(run("faction", "findvehicles", "missing")); + verify(player).sendMessage(VehicleFindMessages.unknownInstallation()); + Installation fort = installations.getById("fort"); + assertTrue(run("faction", "findvehicles", "fort")); + finds.verify(() -> VehicleFindMessages.sendInstallationVehicles(player, fort)); + } + } + + @Test + void guildCapitalIsUnavailableWhenProvincesAreDisabled() { + Cache.provincesEnabled = false; + assertTrue(run("guild", "setcapital")); + verify(player).sendMessage(Cache.PROVINCES_DISABLED_MESSAGE); + verify(guild, never()).setCapital(anyInt()); + } + + @Test + void installationMenusAndUnknownIdsPreserveThePendingConfirmation() { + assertTrue(run("faction", "construct")); + verify(player).sendMessage(contains("Usage:")); + assertTrue(run("faction", "deconstruct")); + verify(registered.installationView).installationsView(null, player, faction, true); + assertTrue(run("faction", "deconstruct", "missing")); + verify(player).sendMessage(contains("No installation with id")); + assertTrue(run("faction", "upgrade")); + verify(player).sendMessage(contains("upgrade <installation id>")); + assertTrue(run("faction", "upgrade", "missing")); + verify(player, times(2)).sendMessage(contains("No installation with id")); + var construction = mock(InstallationConstruction.class); + when(construction.getId()).thenReturn("pending"); + when(installations.getPendingConstruction()).thenReturn(construction); + assertTrue(run("faction", "deconstruct", "pending")); + assertSame(faction, registered.confirming.get(player)); + assertEquals("§7Confirm Action", player.getOpenInventory().getTitle()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "claim", + "unclaim", + "construct fort Capital", + "deconstruct fort", + "upgrade fort", + "setbank", + "setbanner", + "setcapital", + "setcolour 1,2,3" + }) + void factionLeadershipIsRequiredForTerritoryAndAppearanceActions(String command) { + factions.when(() -> FactionManager.getByLeader("Leader")).thenReturn(null); + assertTrue(run("faction", command.split(" "))); + verify(player).sendMessage(contains("leader")); + verifyNoInteractions(map); + } + + @ParameterizedTest + @ValueSource( + strings = { + "setbank", + "setbanner", + "setcapital", + "setcolour 1,2,3", + "invite Alice", + "kick Alice" + }) + void guildLeadershipIsRequiredForGuildManagement(String command) { + assertTrue(run("guild", command.split(" "))); + verify(player).sendMessage(contains("leader")); + verify(guild, never()).setBank(any()); + verify(guild, never()).setBanner(any()); + } + + @Test + void unclaimRejectsExtraArgumentsWithoutPassingANullFactionToTheMap() { + try (MockedStatic<RestServer> rest = mockStatic(RestServer.class)) { + rest.when(() -> RestServer.getProvince(player)).thenReturn(7); + assertFalse(run("faction", "unclaim", "realm", "extra")); + verifyNoInteractions(map); + verify(player).sendMessage(contains("command format")); + } + } + + @Test + void settingPowerForAMissingFactionReportsTheId() { + admin(); + assertFalse(run("faction", "setpower", "missing", "2.5")); + verify(player).sendMessage("§cNo faction by the id missing"); + } + + private boolean run(String commandName, String... args) { + Command command = mock(Command.class); + when(command.getName()).thenReturn(commandName); + return commands.onCommand(player, command, commandName, args); + } + + @Test + void factionInvitationsRequireLeadershipCapacityAndAnUnalignedInvitee() { + assertTrue(run("faction", "invite", "Lady", "Alice")); + verify(player).sendMessage(contains("need to have a faction")); + member(); + when(faction.getLeader()).thenReturn("Other"); + assertTrue(run("faction", "invite", "Lady", "Alice")); + verify(player).sendMessage(contains("Only the leader")); + when(faction.getLeader()).thenReturn("Leader"); + int oldMaximum = Cache.maxMembers; + try (MockedStatic<CharacterNames> characters = mockStatic(CharacterNames.class)) { + assertTrue(run("faction", "invite", "Lady", "Alice")); + verify(faction, never()).invite(anyString()); + Player alice = gui.player("Alice"); + characters.when(() -> CharacterNames.resolveOnline(player, "Lady Alice")).thenReturn(alice); + when(faction.isMemberIgnoreCase("Alice")).thenReturn(true); + assertTrue(run("faction", "invite", "Lady", "Alice")); + verify(player).sendMessage(contains("already a member")); + when(faction.isMemberIgnoreCase("Alice")).thenReturn(false); + Faction other = mock(Faction.class); + factions.when(() -> FactionManager.getByMember("Alice")).thenReturn(other); + assertTrue(run("faction", "invite", "Lady", "Alice")); + verify(player).sendMessage(contains("member of another faction")); + factions.when(() -> FactionManager.getByMember("Alice")).thenReturn(null); + Cache.maxMembers = 2; + assertTrue(run("faction", "invite", "Lady", "Alice")); + verify(player).sendMessage(contains("maximum amount")); + Cache.maxMembers = 3; + assertTrue(run("faction", "invite", "Lady", "Alice")); + verify(faction).invite("Alice"); + verify(alice).sendMessage(contains("invited you")); + verify(alice).sendMessage(contains("/faction join realm")); + } finally { + Cache.maxMembers = oldMaximum; + } + } + + @Test + void guildInvitationsRejectSelfDuplicatesAndProtectedMemberships() { + guildLeader(); + when(guild.isLeader(player)).thenReturn(false); + assertTrue(run("guild", "invite", "Lady", "Alice")); + verify(player).sendMessage(contains("Only the guild leader")); + when(guild.isLeader(player)).thenReturn(true); + when(guild.isBase()).thenReturn(true); + assertTrue(run("guild", "invite", "Lady", "Alice")); + verify(player).sendMessage(contains("use /faction invite")); + when(guild.isBase()).thenReturn(false); + try (MockedStatic<CharacterNames> characters = mockStatic(CharacterNames.class)) { + assertTrue(run("guild", "invite", "Lady", "Alice")); + verify(guild, never()).invite(anyString()); + characters.when(() -> CharacterNames.resolveOnline(player, "Lady Alice")).thenReturn(player); + assertTrue(run("guild", "invite", "Lady", "Alice")); + verify(player).sendMessage(contains("Cannot invite yourself")); + Player alice = gui.player("Alice"); + characters.when(() -> CharacterNames.resolveOnline(player, "Lady Alice")).thenReturn(alice); + when(guild.isInvited("Alice")).thenReturn(true); + assertTrue(run("guild", "invite", "Lady", "Alice")); + verify(player).sendMessage(contains("already invited")); + when(guild.isInvited("Alice")).thenReturn(false); + when(guild.isMember("Alice")).thenReturn(true); + assertTrue(run("guild", "invite", "Lady", "Alice")); + verify(player).sendMessage(contains("already a member")); + when(guild.isMember("Alice")).thenReturn(false); + Guild other = mock(Guild.class); + Faction foreign = mock(Faction.class); + when(other.getFaction()).thenReturn(foreign); + factions.when(() -> FactionManager.getGuildByMember("Alice")).thenReturn(other); + assertTrue(run("guild", "invite", "Lady", "Alice")); + verify(player).sendMessage(contains("in another faction")); + when(other.getFaction()).thenReturn(faction); + assertTrue(run("guild", "invite", "Lady", "Alice")); + verify(player).sendMessage(contains("in the same faction")); + when(other.isBase()).thenReturn(true); + when(other.isLeader("Alice")).thenReturn(true); + assertTrue(run("guild", "invite", "Lady", "Alice")); + verify(player).sendMessage(contains("leader of the faction, cannot invite")); + when(other.isLeader("Alice")).thenReturn(false); + assertTrue(run("guild", "invite", "Lady", "Alice")); + verify(guild).invite("Alice"); + verify(alice).sendMessage(contains("invited to the guild")); + } + } + + @Test + void closedBordersBlockOutsideGuildInvitesButAllowTheFactionLeader() { + guildLeader(); + when(faction.hasFactionRule(Rules.CLOSED_BORDERS)).thenReturn(true); + Player alice = gui.player("Alice"); + try (MockedStatic<CharacterNames> characters = mockStatic(CharacterNames.class)) { + characters.when(() -> CharacterNames.resolveOnline(player, "Alice")).thenReturn(alice); + assertTrue(run("guild", "invite", "Alice")); + verify(player).sendMessage(contains("closed borders")); + verify(guild, never()).invite("Alice"); + when(faction.isLeader("Leader")).thenReturn(true); + assertTrue(run("guild", "invite", "Alice")); + verify(guild).invite("Alice"); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/DiplomacyCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/DiplomacyCoverageTest.java new file mode 100644 index 00000000..3ddc77b3 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/DiplomacyCoverageTest.java @@ -0,0 +1,567 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.tfminecraft.simplefactions.diplomacy.Attitude; +import net.tfminecraft.simplefactions.diplomacy.Relation; +import net.tfminecraft.simplefactions.diplomacy.RelationType; +import net.tfminecraft.simplefactions.loaders.RelationLoader; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.request.RelationRequest; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.war.core.War; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class DiplomacyCoverageTest { + private FactionDomainFixture fixture; + private Field requestsField; + private Object originalRequests; + private Field tickField; + private int originalTick; + + @BeforeEach + void setup() throws Exception { + fixture = new FactionDomainFixture(); + fixture.lawGroup( + "government", Map.of("effects.faction.rules", List.of("CAN_HAVE_VASSALS true"))); + requestsField = RequestManager.class.getDeclaredField("requests"); + requestsField.setAccessible(true); + originalRequests = requestsField.get(null); + requestsField.set(null, new HashMap<>()); + tickField = RelationManager.class.getDeclaredField("tick"); + tickField.setAccessible(true); + originalTick = tickField.getInt(null); + tickField.setInt(null, 0); + } + + @AfterEach + void close() throws Exception { + try { + if (requestsField != null) requestsField.set(null, originalRequests); + if (tickField != null) tickField.setInt(null, originalTick); + } finally { + if (fixture != null) fixture.close(); + } + } + + @Test + void failedSubjectTransferPreservesTheOriginalOverlordAndBothRelationshipDirections() { + Faction old = fixture.saved("old", "Alice"); + Faction subject = fixture.saved("subject", "Bob"); + Faction recipient = fixture.saved("recipient", "Cara"); + RelationType limited = + fixture.relationType("limited", Map.of("vassal", true, "link", "overlord", "limit", 0)); + old.setRelation(subject, new Relation(limited, RelationLoader.getDefaultAttitude())); + subject.setRelation( + old, new Relation(RelationLoader.getType("overlord"), RelationLoader.getDefaultAttitude())); + assertTrue(recipient.canHaveVassals()); + assertTrue(RelationManager.atLimit(recipient, limited)); + RelationManager.transferSubject(subject, recipient); + assertEquals("old", RelationManager.getOverlord(subject)); + assertSame(limited, old.getRelation(subject.getId()).getType()); + assertFalse(recipient.getRelation(subject.getId()).getType().isVassalage()); + } + + private Attitude attitude(String id, int target, double cost) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set(id + ".name", id); + yaml.set(id + ".target", target); + yaml.set(id + ".cost", cost); + Attitude attitude = new Attitude(id, yaml.getConfigurationSection(id)); + RelationLoader.attitudes.add(attitude); + return attitude; + } + + private void opinion(Faction from, Faction to, int opinion) { + from.setRelation( + to, + new Relation( + from.getRelation(to.getId()).getType(), RelationLoader.getDefaultAttitude(), opinion)); + } + + private RelationType selected(String kind, Faction from, Faction to) { + return switch (kind) { + case "trade" -> from.getDiplomacyHandler().getTradeRelation(to.getId()); + case "treaty" -> from.getDiplomacyHandler().getTreatyRelation(to.getId()); + default -> from.getRelation(to.getId()).getType(); + }; + } + + private void set( + String kind, + Player player, + RelationType type, + Faction target, + Faction origin, + boolean check) { + switch (kind) { + case "trade" -> RelationManager.setTradeRelation(player, type, target, origin, check); + case "treaty" -> RelationManager.setTreatyRelation(player, type, target, origin, check); + default -> RelationManager.setRelation(player, type, target, origin, check); + } + } + + @Test + void successfulSubjectTransferKeepsTheRelationKindAndRejectsCyclesSelfAndSameHost() { + Faction old = fixture.saved("old", "Alice"); + Faction subject = fixture.saved("subject", "Bob"); + Faction recipient = fixture.saved("recipient", "Cara"); + Faction child = fixture.saved("child", "Drew"); + fixture.subject(old, subject); + fixture.subject(subject, child); + RelationType kind = old.getRelation(subject.getId()).getType(); + for (Faction invalid : List.of(old, subject, child)) { + RelationManager.transferSubject(subject, invalid); + assertEquals("old", RelationManager.getOverlord(subject)); + assertSame(kind, old.getRelation(subject.getId()).getType()); + assertEquals("subject", RelationManager.getOverlord(child)); + } + RelationManager.transferSubject(subject, recipient); + assertEquals("recipient", RelationManager.getOverlord(subject)); + assertSame(kind, recipient.getRelation(subject.getId()).getType()); + assertSame(RelationLoader.getDefaultType(), old.getRelation(subject.getId()).getType()); + assertSame(RelationLoader.getDefaultType(), subject.getRelation(old.getId()).getType()); + assertEquals("subject", RelationManager.getOverlord(child)); + } + + @Test + void subjectTransfersDoNothingForAbsentOrIndependentPartiesAndDisallowedRecipients() { + Faction old = fixture.saved("old", "Alice"); + Faction subject = fixture.saved("subject", "Bob"); + Faction recipient = fixture.saved("recipient", "Cara"); + RelationManager.transferSubject(null, recipient); + RelationManager.transferSubject(subject, null); + RelationManager.transferSubject(subject, recipient); + assertNull(RelationManager.getOverlord(subject)); + fixture.subject(old, subject); + var law = + fixture.law( + "government", + "independent", + Map.of("effects.faction.rules", List.of("CAN_HAVE_VASSALS false"))); + recipient.getLawHandler().getGroup("government").setCurrent(law); + assertFalse(recipient.canHaveVassals()); + RelationManager.transferSubject(subject, recipient); + assertEquals(old.getId(), RelationManager.getOverlord(subject)); + recipient + .getLawHandler() + .getGroup("government") + .setCurrent(recipient.getLawHandler().getLaw("government", "current")); + FactionManager.factions.remove(old); + RelationManager.transferSubject(subject, recipient); + assertEquals(old.getId(), RelationManager.getOverlord(subject)); + assertFalse(recipient.getRelation(subject.getId()).getType().isVassalage()); + assertEquals(old.getId(), RelationManager.getTopLiege(subject)); + assertFalse(RelationManager.isOnOverlordPath(subject, recipient)); + } + + @Test + void hourlyTicksMoveOpinionOnceAndBreakingVassalagePreservesOpinions() { + Faction host = fixture.saved("host", "Alice"); + Faction subject = fixture.saved("subject", "Bob"); + fixture.subject(host, subject); + Attitude hostile = attitude("hostile", -50, 0); + subject.setRelation(host, new Relation(RelationLoader.getType("overlord"), hostile, 12)); + opinion(host, subject, 6); + for (int i = 0; i < 3599; i++) RelationManager.tick(); + assertEquals(12, subject.getRelation(host.getId()).getOpinion()); + RelationManager.tick(); + assertEquals(11, subject.getRelation(host.getId()).getOpinion()); + assertEquals(5, host.getRelation(subject.getId()).getOpinion()); + assertTrue(RelationManager.endVassalage(subject, host, true)); + for (Relation relation : + List.of(subject.getRelation(host.getId()), host.getRelation(subject.getId()))) { + assertSame(RelationLoader.getDefaultType(), relation.getType()); + assertSame(hostile, relation.getAttitude()); + } + assertEquals(11, subject.getRelation(host.getId()).getOpinion()); + assertFalse(RelationManager.endVassalage(subject, host, false)); + RelationManager.reset(host, subject, false); + assertSame( + RelationLoader.getDefaultAttitude(), host.getRelation(subject.getId()).getAttitude()); + assertEquals(5, host.getRelation(subject.getId()).getOpinion()); + } + + @Test + void subjectAndAllyQueriesRespectNestedGraphsAndPruneDeletedSubjects() { + Faction root = fixture.saved("root", "Alice"); + Faction middle = fixture.saved("middle", "Bob"); + Faction leaf = fixture.saved("leaf", "Cara"); + Faction foreign = fixture.saved("foreign", "Drew"); + fixture.subject(root, middle); + fixture.subject(middle, leaf); + RelationType ally = fixture.relationType("ally", Map.of("mutual", true)); + root.setRelation(foreign, new Relation(ally, RelationLoader.getDefaultAttitude())); + assertEquals(List.of(foreign), RelationManager.getAllies(root)); + assertEquals(List.of(middle), RelationManager.getSubjects(root)); + assertTrue(RelationManager.getSubjects(null).isEmpty()); + assertEquals(root.getId(), RelationManager.getTopLiege(leaf)); + assertNull(RelationManager.getTopLiege(root)); + assertTrue(RelationManager.isOnOverlordPath(leaf, middle)); + assertTrue(RelationManager.isOnOverlordPath(leaf, root)); + assertFalse(RelationManager.isOnOverlordPath(leaf, foreign)); + assertTrue(RelationManager.sameRealm(root, root)); + assertTrue(RelationManager.sameRealm(root, leaf)); + assertTrue(RelationManager.sameRealm(leaf, root)); + assertFalse(RelationManager.sameRealm(root, foreign)); + assertFalse(RelationManager.sameRealm(null, root)); + assertTrue(RelationManager.vassalCheck(middle, root)); + assertFalse(RelationManager.vassalCheck(middle, foreign)); + assertTrue(RelationManager.vassalCheck(foreign, root)); + FactionManager.factions.remove(middle); + assertTrue(RelationManager.getSubjects(root).isEmpty()); + assertFalse(root.getRelations().containsKey(middle.getId())); + assertEquals(List.of(foreign), RelationManager.getAllies(root)); + } + + @ParameterizedTest + @ValueSource(strings = {"relation", "trade", "treaty"}) + void mutualRequestsWaitForConsentThenUpdateBothParties(String kind) { + Faction origin = fixture.saved("origin", "Alice"); + Faction target = fixture.saved("target", "Bob"); + Player alice = fixture.player("Alice"); + Player bob = fixture.player("Bob"); + RelationType pact = + fixture.relationType( + kind, + Map.of( + "name", + "Pact", + "mutual", + true, + "trade-agreement", + kind.equals("trade"), + "treaty", + kind.equals("treaty"))); + set(kind, alice, pact, target, origin, true); + RelationRequest request = + assertInstanceOf(RelationRequest.class, RequestManager.getRequest(bob)); + assertSame(pact, request.getType()); + assertSame(origin, request.getFaction()); + assertNotSame(pact, selected(kind, origin, target)); + verify(bob).sendMessage("§7Type §a/faction accept §7to accept"); + RequestManager.accept(bob); + assertFalse(RequestManager.hasRequest(bob)); + assertSame(pact, selected(kind, origin, target)); + assertSame(pact, selected(kind, target, origin)); + verify(alice).sendMessage(contains("accepted your request")); + } + + @ParameterizedTest + @ValueSource(strings = {"relation", "trade", "treaty"}) + void requestsRequireAnOnlineTargetAndCurrentLeadershipWhenAccepted(String kind) { + Faction origin = fixture.saved("origin", "Alice"); + Faction target = fixture.saved("target", "Bob"); + Player alice = fixture.player("Alice"); + RelationType pact = fixture.relationType(kind, Map.of("mutual", true)); + set(kind, alice, pact, target, origin, true); + verify(alice).sendMessage(contains("leader is not online")); + Player bob = fixture.player("Bob"); + when(bob.isOnline()).thenReturn(false); + clearInvocations(alice); + set(kind, alice, pact, target, origin, true); + verify(alice).sendMessage(contains("leader is not online")); + assertFalse(RequestManager.hasRequest(bob)); + when(bob.isOnline()).thenReturn(true); + set(kind, alice, pact, target, origin, true); + assertTrue(RequestManager.hasRequest(bob)); + target.addMember("Cara"); + target.setLeader("Cara"); + RequestManager.accept(bob); + assertFalse(RequestManager.hasRequest(bob)); + verify(bob).sendMessage("§cYou do not have a faction"); + assertNotSame(pact, selected(kind, origin, target)); + assertNotSame(pact, selected(kind, target, origin)); + } + + @ParameterizedTest + @ValueSource(strings = {"relation", "trade", "treaty"}) + void opinionThresholdsMustBeMetOnBothSidesBeforeTheRelationshipChanges(String kind) { + Faction origin = fixture.saved("origin", "Alice"); + Faction target = fixture.saved("target", "Bob"); + Player alice = fixture.player("Alice"); + RelationType pact = + fixture.relationType( + kind, Map.of("mutual", true, "threshold.amount", 20, "threshold.mutual", true)); + set(kind, alice, pact, target, origin, false); + assertNotSame(pact, selected(kind, origin, target)); + verify(alice).sendMessage(contains("You need an opinion")); + verify(alice).sendMessage(contains("They need an opinion")); + opinion(origin, target, 20); + set(kind, null, pact, target, origin, false); + assertNotSame(pact, selected(kind, origin, target)); + opinion(target, origin, 20); + set(kind, alice, pact, target, origin, false); + assertSame(pact, selected(kind, origin, target)); + assertSame(pact, selected(kind, target, origin)); + assertEquals(20, origin.getRelation(target.getId()).getOpinion()); + } + + @Test + void forcedOverlaysPreserveBaseRelationsAndClearingTreatiesDoesNotRemoveTrade() { + Faction origin = fixture.saved("origin", "Alice"); + Faction target = fixture.saved("target", "Bob"); + opinion(origin, target, 7); + RelationType treaty = + fixture.relationType("peace", Map.of("mutual", true, "treaty", true, "blocks-war", true)); + RelationType trade = + fixture.relationType( + "embargo", Map.of("mutual", true, "trade-agreement", true, "blocks-shops", true)); + RelationType clear = fixture.relationType("clear", Map.of("treaty", true, "clear", true)); + RelationManager.setTreatyRelationForced(null, target, origin); + RelationManager.setTradeRelationForced(null, target, origin); + assertNull(origin.getDiplomacyHandler().getTreatyRelation(target.getId())); + RelationManager.setTreatyRelationForced(treaty, target, origin); + RelationManager.setTradeRelationForced(trade, target, origin); + assertSame(treaty, target.getDiplomacyHandler().getTreatyRelation(origin.getId())); + assertSame(trade, target.getDiplomacyHandler().getTradeRelation(origin.getId())); + assertTrue(RelationManager.hasNonAggressionPact(origin, target)); + assertTrue(RelationManager.hasTradeEmbargo(origin, target)); + assertFalse(RelationManager.hasTradeEmbargo(null, target)); + assertFalse(RelationManager.hasNonAggressionPact(origin, null)); + assertEquals(7, origin.getRelation(target.getId()).getOpinion()); + RelationManager.setTreatyRelationForced(clear, target, origin); + assertFalse(RelationManager.hasNonAggressionPact(origin, target)); + assertTrue(RelationManager.hasTradeEmbargo(origin, target)); + RelationManager.setTreatyRelationForced(treaty, target, origin); + Player alice = fixture.player("Alice"); + RelationManager.setTreatyRelation(alice, clear, target, origin, false); + verify(alice).sendMessage(contains("Cleared treaty")); + assertNull(target.getDiplomacyHandler().getTreatyRelation(origin.getId())); + } + + @Test + void forcedRelationsStillRejectLawsExistingOverlordsLimitsAndAncestryLoops() { + Faction root = fixture.saved("root", "Alice"); + Faction middle = fixture.saved("middle", "Bob"); + Faction leaf = fixture.saved("leaf", "Cara"); + Faction foreign = fixture.saved("foreign", "Drew"); + Player player = fixture.player("Alice"); + RelationType vassal = RelationLoader.getType("vassal"); + fixture.subject(root, middle); + fixture.subject(middle, leaf); + assertFalse(RelationManager.setRelation(player, vassal, root, leaf, false, false)); + assertFalse(RelationManager.setRelation(player, vassal, middle, leaf, false, false)); + assertFalse(RelationManager.setRelation(player, vassal, leaf, foreign, false, false)); + verify(player).sendMessage("§cThis faction is your top overlord"); + verify(player, times(2)).sendMessage("§cThis faction is already a subject of someone else"); + var law = + fixture.law( + "government", + "independent", + Map.of("effects.faction.rules", List.of("CAN_HAVE_VASSALS false"))); + foreign.getLawHandler().getGroup("government").setCurrent(law); + assertFalse(RelationManager.setRelation(player, vassal, root, foreign, false, false)); + verify(player).sendMessage("§cYour faction cannot have vassals!"); + RelationType limited = fixture.relationType("limited", Map.of("limit", 0)); + assertFalse(RelationManager.setRelation(player, limited, foreign, root, false, false)); + verify(player).sendMessage("§cYou have reached the limit for this relation type"); + assertEquals(1, RelationManager.getRelationCount(root, vassal)); + assertFalse(RelationManager.atLimit(root, vassal)); + assertFalse(RelationManager.setRelationForced(null, root, foreign)); + assertEquals(root.getId(), RelationManager.getOverlord(middle)); + assertEquals(middle.getId(), RelationManager.getOverlord(leaf)); + } + + @Test + void settingAndReplacingVassalageUpdatesLinkedRelationsAndTheMapWithoutLosingOpinion() { + Faction origin = fixture.saved("origin", "Alice"); + Faction target = fixture.saved("target", "Bob"); + Player alice = fixture.player("Alice"); + Player bob = fixture.player("Bob"); + opinion(origin, target, 17); + opinion(target, origin, -3); + RelationType vassal = RelationLoader.getType("vassal"); + assertTrue(RelationManager.setRelation(alice, vassal, target, origin, false, false)); + assertEquals(origin.getId(), RelationManager.getOverlord(target)); + verify(fixture.map, times(2)).enqueue(eq("nation"), anyString()); + assertSame( + RelationLoader.getType("overlord"), + RelationManager.partnerRelationAfter(target, origin, vassal)); + assertFalse(RelationManager.reverseChange(target, origin, vassal)); + RelationType ally = fixture.relationType("ally", Map.of("mutual", true)); + assertTrue(RelationManager.reverseChange(target, origin, ally)); + assertTrue(RelationManager.setRelation(alice, ally, target, origin, false, false)); + verify(bob).sendMessage(contains("has been changed")); + assertSame(ally, target.getRelation(origin.getId()).getType()); + assertEquals(17, origin.getRelation(target.getId()).getOpinion()); + assertEquals(-3, target.getRelation(origin.getId()).getOpinion()); + RelationType unilateral = fixture.relationType("friendly", Map.of("name", "Friendly")); + RelationManager.reset(origin, target, false); + assertSame( + RelationLoader.getDefaultType(), + RelationManager.partnerRelationAfter(target, origin, unilateral)); + assertTrue(RelationManager.setRelation(alice, unilateral, target, origin, false, false)); + assertSame(RelationLoader.getDefaultType(), target.getRelation(origin.getId()).getType()); + } + + @Test + void acceptingAnAllianceRequestRechecksAWarThatStartedAfterItWasSent() { + Faction origin = fixture.saved("origin", "Alice"); + Faction target = fixture.saved("target", "Bob"); + Faction enemy = fixture.saved("enemy", "Cara"); + Player alice = fixture.player("Alice"); + Player bob = fixture.player("Bob"); + RelationType ally = fixture.relationType("ally", Map.of("mutual", true)); + assertFalse(RelationManager.setRelation(alice, ally, target, origin, true, false)); + WarManager.get().add(new War(21, origin, enemy)); + RequestManager.accept(bob); + assertFalse(RequestManager.hasRequest(bob)); + assertNotSame(ally, origin.getRelation(target.getId()).getType()); + verify(bob).sendMessage(contains("cannot make new allies while at war")); + verify(alice).sendMessage(contains("cannot make new allies while at war")); + verify(alice, never()).sendMessage(contains("accepted your request")); + } + + @Test + void attitudesRespectCapacityAndAllowKeepingOrCheaperChangesWhileOverCapacity() { + Faction origin = fixture.saved("origin", "Alice"); + Faction target = fixture.saved("target", "Bob"); + target.setPrestige(100.0); + Player player = fixture.player("Alice"); + Attitude costly = attitude("costly", 20, 2); + Attitude free = attitude("free", 10, 0); + assertFalse(RelationManager.setAttitude(null, costly, target, origin)); + assertFalse(RelationManager.setAttitude(player, costly, target, origin)); + verify(player).sendMessage("§cYou lack diplomatic capacity for this attitude!"); + origin.setRelation(target, new Relation(RelationLoader.getDefaultType(), costly)); + assertTrue(RelationManager.setAttitude(player, costly, target, origin)); + assertSame(costly, origin.getRelation(target.getId()).getAttitude()); + assertTrue(RelationManager.setAttitude(player, free, target, origin)); + assertSame(free, origin.getRelation(target.getId()).getAttitude()); + assertEquals(0, RelationManager.getDiplomaticCost(origin, target, (Attitude) null)); + assertEquals(20, RelationManager.getDiplomaticCost(origin, target, costly)); + } + + @Test + void capacityCalculationsIncludePartnerLinksBlocShareAndPrestigeDiminishingReturns() { + Faction origin = fixture.saved("origin", "Alice"); + Faction target = fixture.saved("target", "Bob"); + Faction third = fixture.saved("third", "Cara"); + origin.setPrestige(100.0); + target.setPrestige(400.0); + third.setPrestige(500.0); + RelationType scaled = + fixture.relationType("scaled", Map.of("cost", 2.0, "bloc-scaling", 1.0, "mutual", true)); + assertEquals(60, RelationManager.getDiplomaticCost(origin, target, scaled)); + assertTrue( + RelationManager.actorLacksCapacity( + origin, target, scaled, RelationLoader.getDefaultType())); + assertTrue(RelationManager.partnerLacksRelationCapacity(origin, target, scaled)); + assertTrue(RelationManager.partnerLacksOverlayCapacity(origin, target, scaled, null)); + assertFalse(RelationManager.actorLacksCapacity(origin, target, scaled, scaled)); + assertFalse(RelationManager.actorLacksCapacity(null, target, scaled, null)); + assertFalse(RelationManager.partnerLacksRelationCapacity(null, target, scaled)); + assertFalse(RelationManager.partnerLacksOverlayCapacity(origin, target, null, null)); + assertNull(RelationManager.partnerRelationAfter(target, origin, null)); + RelationType unilateral = fixture.relationType("unilateral", Map.of("cost", 2.0)); + assertFalse(RelationManager.partnerLacksOverlayCapacity(origin, target, unilateral, null)); + RelationType nonSettable = + fixture.relationType("fixed", Map.of("cost", 9.0, "settable", false)); + assertEquals(0, RelationManager.getDiplomaticCost(origin, target, nonSettable)); + RelationType vassal = fixture.relationType("paid_subject", Map.of("cost", 3.0, "vassal", true)); + assertEquals(20, RelationManager.getDiplomaticCost(origin, target, vassal)); + assertEquals(0, RelationManager.getDiplomaticCost(origin, target, (RelationType) null)); + target.setPrestige(-10.0); + assertEquals(0, RelationManager.getDiplomaticCost(origin, target, scaled)); + assertFalse(RelationManager.lacksCapacityForChange(-10, 5, 6)); + assertFalse(RelationManager.lacksCapacityForChange(5, 10, 5)); + assertTrue(RelationManager.lacksCapacityForChange(4, 10, 5)); + } + + @ParameterizedTest + @ValueSource(strings = {"relation", "trade", "treaty"}) + void rejectedStaleRequestsNeverAnnounceSuccessfulAcceptance(String kind) { + Faction origin = fixture.saved("origin", "Alice"); + Faction target = fixture.saved("target", "Bob"); + Player alice = fixture.player("Alice"); + Player bob = fixture.player("Bob"); + RelationType pact = + fixture.relationType( + kind, + Map.of( + "name", "Pact", "mutual", true, "threshold.amount", 20, "threshold.mutual", true)); + opinion(origin, target, 20); + opinion(target, origin, 20); + set(kind, alice, pact, target, origin, true); + assertTrue(RequestManager.hasRequest(bob)); + opinion(origin, target, 0); + clearInvocations(alice, bob); + RequestManager.accept(bob); + assertFalse(RequestManager.hasRequest(bob)); + assertNotSame(pact, selected(kind, origin, target)); + assertNotSame(pact, selected(kind, target, origin)); + verify(alice, never()).sendMessage(contains("accepted your request")); + } + + @Test + void nullableDiplomacyQueriesAndForcedUpdatesDoNotChangeExistingRelationships() { + Faction origin = fixture.saved("origin", "Alice"); + Faction target = fixture.saved("target", "Bob"); + fixture.subject(origin, target); + RelationType type = RelationLoader.getType("vassal"); + assertNull(RelationManager.wartimeBlock(null, target, origin)); + assertNull(RelationManager.wartimeBlock(type, null, origin)); + assertNull(RelationManager.wartimeBlock(type, target, null)); + assertFalse(RelationManager.isTributaryOf(null, target)); + assertFalse(RelationManager.isTributaryOf(origin, null)); + assertFalse(RelationManager.hasTradeEmbargo(origin, null)); + assertFalse(RelationManager.hasNonAggressionPact(null, target)); + RelationManager.setTradeRelationForced(type, null, origin); + RelationManager.setTreatyRelationForced(type, target, null); + assertEquals("origin", RelationManager.getOverlord(target)); + assertSame(type, origin.getRelation(target.getId()).getType()); + assertNull(origin.getDiplomacyHandler().getTradeRelation(target.getId())); + assertNull(origin.getDiplomacyHandler().getTreatyRelation(target.getId())); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void aFactionCannotBecomeItsOwnVassal(boolean forced) { + Faction faction = fixture.saved("home", "Alice"); + Player player = fixture.player("Alice"); + RelationType vassal = RelationLoader.getType("vassal"); + try { + assertFalse(RelationManager.setRelation(player, vassal, faction, faction, false, forced)); + assertFalse(faction.getRelations().containsKey(faction.getId())); + assertNull(RelationManager.getOverlord(faction)); + } finally { + // Restore this public map even on the broken implementation, which writes a self-overlord + // edge. + faction.getRelations().remove(faction.getId()); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void partialPublicRelationEntriesDoNotBecomeTributariesOrLoseExistingSubjectOwnership( + boolean missingType) { + Faction host = fixture.saved("host", "Alice"); + Faction subject = fixture.saved("subject", "Bob"); + Faction sibling = fixture.saved("sibling", "Cara"); + Faction recipient = fixture.saved("recipient", "Drew"); + fixture.subject(host, subject); + fixture.subject(host, sibling); + Relation incomplete = + missingType ? new Relation(null, RelationLoader.getDefaultAttitude()) : null; + host.getRelations().put(subject.getId(), incomplete); + assertFalse(RelationManager.isTributaryOf(host, subject)); + assertEquals(List.of(sibling), RelationManager.getSubjects(host)); + RelationManager.transferSubject(subject, recipient); + assertEquals(host.getId(), RelationManager.getOverlord(subject)); + assertSame(incomplete, host.getRelations().get(subject.getId())); + assertEquals(host.getId(), RelationManager.getOverlord(sibling)); + assertFalse(recipient.getRelations().containsKey(subject.getId())); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java new file mode 100644 index 00000000..30923a39 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/FactionManagerCoverageTest.java @@ -0,0 +1,1325 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Logger; +import java.util.logging.Level; +import net.tfminecraft.denareconomy.DenarEconomy; +import net.tfminecraft.denareconomy.enums.Accounts; +import net.tfminecraft.denareconomy.managers.MoneyManager; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.database.LoanData; +import net.tfminecraft.simplefactions.diplomacy.Attitude; +import net.tfminecraft.simplefactions.diplomacy.DiplomacyHandler; +import net.tfminecraft.simplefactions.diplomacy.Relation; +import net.tfminecraft.simplefactions.diplomacy.RelationType; +import net.tfminecraft.simplefactions.enums.Member; +import net.tfminecraft.simplefactions.government.movement.Action; +import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.government.movement.PoliticalAction; +import net.tfminecraft.simplefactions.government.movement.cause.Cause; +import net.tfminecraft.simplefactions.government.proposal.Proposal; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.loans.Loan; +import net.tfminecraft.simplefactions.guild.income.Ledger; +import net.tfminecraft.simplefactions.guild.income.LedgerHistory; +import net.tfminecraft.simplefactions.guild.network.TradeGraph; +import net.tfminecraft.simplefactions.inactivity.InactivityService; +import net.tfminecraft.simplefactions.mercenary.contract.ContractAccrualService; +import net.tfminecraft.simplefactions.mercenary.contract.ContractTerminationService; +import net.tfminecraft.simplefactions.loaders.GuildLoader; +import net.tfminecraft.simplefactions.loaders.RankLoader; +import net.tfminecraft.simplefactions.loaders.RelationLoader; +import net.tfminecraft.simplefactions.loaders.TitleLoader; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.Modifier; +import net.tfminecraft.simplefactions.objects.PrestigeRank; +import net.tfminecraft.simplefactions.player.PlayerEconomyManager; +import net.tfminecraft.simplefactions.objects.request.ElevateRequest; +import net.tfminecraft.simplefactions.objects.request.MovementJoinRequest; +import net.tfminecraft.simplefactions.objects.request.MovementLeaderTargetRequest; +import net.tfminecraft.simplefactions.objects.request.RelocateRequest; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.tiers.Title; +import net.tfminecraft.simplefactions.utils.DailyGuildTransfers; +import net.tfminecraft.simplefactions.utils.FactionCleanup; +import net.tfminecraft.simplefactions.utils.PostSettlementPayouts; +import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleUpkeepService; +import net.tfminecraft.simplefactions.war.resolution.WarReparationsService; +import net.tfminecraft.tlibs.TLibs; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Sound; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +class FactionManagerCoverageTest { + private FactionDomainFixture fixture; + private FactionManager manager; + private MockedConstruction<Database> databases; + private final Map<Field, Object> globals = new LinkedHashMap<>(); + + @BeforeEach + void setUp() throws Exception { + fixture = new FactionDomainFixture(); + replace(FactionManager.class, "timer", 0); + replace(FactionManager.class, "day", 0); + replace(FactionManager.class, "loaded", false); + replace(FactionManager.class, "dbRelations", new HashMap<>()); + replace(FactionManager.class, "dbTradeRelations", new HashMap<>()); + replace(FactionManager.class, "dbTreatyRelations", new HashMap<>()); + replace(FactionManager.class, "loans", new ArrayList<>()); + replace(RequestManager.class, "requests", new HashMap<>()); + replace(Cache.class, "votingBlock", "v.LECTERN"); + databases = mockConstruction(Database.class); + manager = new FactionManager(); + when(Bukkit.getOnlinePlayers()).thenAnswer(call -> fixture.online.values()); + } + + @AfterEach + void tearDown() throws Exception { + try { + databases.close(); + for (Map.Entry<Field, Object> entry : globals.entrySet()) entry.getKey().set(null, entry.getValue()); + } finally { + fixture.close(); + } + } + + private void replace(Class<?> owner, String name, Object value) throws Exception { + Field field = owner.getDeclaredField(name); + field.setAccessible(true); + globals.putIfAbsent(field, field.get(null)); + field.set(null, value); + } + + private Faction faction(String id, String leader) { + Faction faction = mock(Faction.class, RETURNS_DEEP_STUBS); + when(faction.getId()).thenReturn(id); + when(faction.getName()).thenReturn(id); + when(faction.getLeader()).thenReturn(leader); + when(faction.getRGB()).thenReturn(id + "-rgb"); + List<String> members = new ArrayList<>(List.of(leader)); + when(faction.getMembers()).thenReturn(members); + when(faction.isMemberIgnoreCase(anyString())) + .thenAnswer(call -> members.stream().anyMatch(name -> name.equalsIgnoreCase(call.getArgument(0)))); + doAnswer(call -> members.add(call.getArgument(0))).when(faction).addMember(anyString()); + when(faction.getProvinces()).thenReturn(new ArrayList<>()); + when(faction.getTitles()).thenReturn(new ArrayList<>()); + when(faction.getGuildHandler().getGuilds()).thenReturn(new ArrayList<>()); + when(faction.getGovernment().getFaction()).thenReturn(faction); + when(faction.getGovernment().getMovementById(anyString())).thenReturn(null); + when(faction.getGovernment().getMovementByLeader(anyString())).thenReturn(null); + AtomicReference<PrestigeRank> rank = new AtomicReference<>(RankLoader.getLowest()); + when(faction.getRank()).thenAnswer(call -> rank.get()); + doAnswer(call -> { rank.set(call.getArgument(0)); return null; }).when(faction).setRank(any()); + DiplomacyHandler diplomacy = new DiplomacyHandler(faction); + when(faction.getDiplomacyHandler()).thenReturn(diplomacy); + when(faction.getRelations()).thenReturn(diplomacy.getRelations()); + when(faction.getRelation(anyString())).thenAnswer(call -> diplomacy.getRelation(call.getArgument(0))); + doAnswer(call -> { diplomacy.setRelation(call.getArgument(0), call.getArgument(1)); return null; }) + .when(faction).setRelation(any(Faction.class), any(Relation.class)); + FactionManager.factions.add(faction); + return faction; + } + + private Guild guild(Faction faction, String id, String leader) { + Guild guild = mock(Guild.class, RETURNS_DEEP_STUBS); + when(guild.getId()).thenReturn(id); + when(guild.getName()).thenReturn(id); + when(guild.getFaction()).thenReturn(faction); + when(guild.getLeader()).thenReturn(leader); + when(guild.isLeader(anyString())).thenAnswer(call -> leader.equalsIgnoreCase(call.getArgument(0))); + when(guild.getMembers()).thenReturn(new ArrayList<>(List.of(leader))); + when(guild.getType()).thenReturn(GuildLoader.map.get("guild")); + faction.getGuildHandler().getGuilds().add(guild); + return guild; + } + + private Movement movement(Faction faction, String leader) { + Movement movement = mock(Movement.class); + when(movement.getId()).thenReturn("movement"); + when(movement.getFaction()).thenReturn(faction); + when(movement.getLeader()).thenReturn(leader); + when(movement.getCauses()).thenReturn(new ArrayList<>()); + when(faction.getGovernment().getMovementById("movement")).thenReturn(movement); + when(faction.getGovernment().getMovementByLeader(leader)).thenReturn(movement); + return movement; + } + + private Cause leadershipCause(Movement movement) { + when(movement.getFaction().getRelationToFaction("Alice")).thenReturn(Member.MEMBER); + PoliticalAction action = mock(PoliticalAction.class); + when(action.getAction()).thenReturn(Action.CHANGE_LEADER); + Proposal proposal = new Proposal("Alice", movement.getFaction().getGovernment()); + proposal.setPoliticalActionProposal(action); + Cause cause = new Cause(movement, proposal, "Alice"); + movement.getCauses().add(cause); + return cause; + } + + @Test + void deletedMovementJoinCauseCannotSelectTheCauseNowAtItsOldIndex() { + Player sender = fixture.player("Alice"); + Player leader = fixture.player("Bob"); + Faction home = faction("home", "Bob"); + Movement movement = movement(home, "Bob"); + Cause removed = leadershipCause(movement); + Cause survivor = leadershipCause(movement); + when(movement.getJoiningAs(sender)).thenReturn("Alice"); + when(movement.canJoin(eq("Alice"), any())).thenReturn(true); + FactionManager.requestMovementJoin(sender, movement, "supporter", removed); + movement.getCauses().remove(removed); + + assertDoesNotThrow(() -> FactionManager.acceptMovementJoinRequest(leader)); + + verify(movement, never()).join(any(), any()); + verify(leader).sendMessage("§cSpecified cause no longer exists"); + assertEquals(List.of(survivor), movement.getCauses()); + } + + @Test + void survivingMovementJoinCauseKeepsItsIdentityAfterEarlierCauseRemoval() { + Player sender = fixture.player("Alice"); + Player leader = fixture.player("Bob"); + Faction home = faction("home", "Bob"); + Movement movement = movement(home, "Bob"); + Cause earlier = leadershipCause(movement); + Cause selected = leadershipCause(movement); + leadershipCause(movement); + when(movement.getJoiningAs(sender)).thenReturn("Alice"); + when(movement.canJoin(eq("Alice"), any())).thenReturn(true); + FactionManager.requestMovementJoin(sender, movement, "supporter", selected); + movement.getCauses().remove(earlier); + + FactionManager.acceptMovementJoinRequest(leader); + + verify(movement).join("Alice", selected); + } + + @Test + void deletedWantedLeaderCauseCannotRetargetTheNextProposal() { + Player sender = fixture.player("Alice"); + Player target = fixture.player("Bob"); + Faction home = faction("home", "Alice"); + when(home.canBecomeLeader("Bob")).thenReturn(true); + Movement movement = movement(home, "Alice"); + Cause removed = leadershipCause(movement); + Cause survivor = leadershipCause(movement); + FactionManager.requestMovementLeaderTarget(sender, movement, removed, "Bob"); + movement.getCauses().remove(removed); + + FactionManager.acceptMovementLeaderTargetRequest(target); + + assertNull(survivor.getProposal().getTarget()); + verify(target).sendMessage("§cSpecified cause no longer exists"); + assertTrue(databases.constructed().isEmpty()); + } + + @Test + void managerLookupsPreserveCaseInsensitivityAndRepairLegacyLeaderMembership() { + Faction home = faction("Home", "Alice"); + Faction other = faction("Other", "Bob"); + Guild traders = guild(home, "Traders", "Carol"); + traders.getMembers().add("Dave"); + Title title = fixture.title("County", "county", 4); + home.getProvinces().add(4); + when(home.hasTitle(title)).thenReturn(true); + assertSame(home, FactionManager.getByString("HOME")); + assertSame(home, FactionManager.getByLeader("aLiCe")); + assertSame(home, FactionManager.getByMember("ALICE")); + home.getMembers().clear(); + assertSame(home, FactionManager.getByMember("aLiCe")); + assertEquals(List.of("aLiCe"), home.getMembers()); + assertSame(home, FactionManager.getByRGB("home-RGB")); + assertSame(home, FactionManager.getByProvince(4)); + assertSame(home, FactionManager.getTitleOwner(title)); + assertSame(traders, FactionManager.getGuildByString("TRADERS")); + assertSame(traders, FactionManager.getGuildByLeader("cARoL")); + assertSame(traders, FactionManager.getGuildByMember("dAVE")); + assertTrue(FactionManager.guildExists("traders")); + assertFalse(FactionManager.guildExists("missing")); + assertNull(FactionManager.getByString("missing")); + assertNull(FactionManager.getByLeader("missing")); + assertNull(FactionManager.getByMember("missing")); + assertNull(FactionManager.getByMember(null)); + assertNull(FactionManager.getByRGB("missing")); + assertNull(FactionManager.getByProvince(9)); + assertNull(FactionManager.getTitleOwner(fixture.title("Elsewhere", "county", 9))); + assertNull(FactionManager.getGuildByString("missing")); + assertNull(FactionManager.getGuildByLeader("missing")); + assertNull(FactionManager.getGuildByMember("missing")); + assertNull(FactionManager.getGuildByMember(null)); + assertNull(FactionManager.getMovementById("missing")); + assertNull(FactionManager.getMovementById(null)); + List<Faction> copy = FactionManager.getCopy(); + copy.clear(); + assertEquals(List.of(home, other), FactionManager.factions); + assertSame(fixture.map, FactionManager.getMap()); + assertSame(fixture.inventory, FactionManager.getInv()); + assertEquals(List.of(traders), FactionManager.getAllGuilds()); + } + + @Test + void joiningGuildIsAllowedForUnaffiliatedAndRealmMembersButNotGuildLeaders() { + Faction home = faction("home", "Alice"); + Guild guild = guild(home, "guild", "Bob"); + guild.getMembers().add("Carol"); + assertTrue(FactionManager.canJoinGuild(fixture.player("Dave"))); + assertFalse(FactionManager.canJoinGuild(fixture.player("BOB"))); + assertFalse(FactionManager.canJoinGuild(fixture.player("Carol"))); + when(guild.getType()).thenReturn(GuildLoader.map.get("realm")); + assertTrue(FactionManager.canJoinGuild(fixture.player("Carol"))); + } + + @ParameterizedTest + @CsvSource(value = {"0,0,0|0", "255, 12, 3|0", "1,2|1", "1,2,3,4|1", "x,2,3|2", "-1,2,3|3", "256,2,3|3"}, delimiter = '|') + void rgbValidationProvidesSpecificErrors(String input, int expected) { + assertEquals(expected, FactionManager.validateRGB(input)); + assertEquals(1, FactionManager.validateRGB(null)); + } + + @Test + void competitiveRankThresholdUsesTheHighestHolderOrTheConfiguredFloor() { + PrestigeRank rank = RankLoader.getLowest(); + rank.setMin(100.0); + rank.setPercentage(50.0); + assertEquals(100.0, FactionManager.getRankUpAmount(rank)); + Faction weak = faction("weak", "Alice"); + Faction strong = faction("strong", "Bob"); + when(weak.getPrestige()).thenReturn(30.0); + when(strong.getPrestige()).thenReturn(50.0); + assertEquals(100.0, FactionManager.getRankUpAmount(rank)); + when(strong.getPrestige()).thenReturn(300.0); + assertEquals(150.0, FactionManager.getRankUpAmount(rank)); + strong.setRank(RankLoader.getByString("renowned")); + assertEquals(100.0, FactionManager.getRankUpAmount(rank)); + } + + @Test + void wealthSummariesIncludeOnlyTheRequestedSourcesAndExcludeBaseGuildLiquidWealth() { + Faction home = faction("home", "Alice"); + Faction other = faction("other", "Bob"); + when(home.getWealth()).thenReturn(120.123); + when(other.getWealth()).thenReturn(25.5); + when(home.getWealthModifiers()).thenReturn(List.of(new Modifier("BANK", 45.0, false), new Modifier("nodes", 20.0, false), new Modifier("other", 30.0, false))); + Guild guild = guild(home, "guild", "Carol"); + Guild realm = guild(other, "realm", "Bob"); + when(realm.isBase()).thenReturn(true); + when(guild.getWealthModifiers()).thenReturn(List.of(new Modifier("Bank", 12.345, false), new Modifier("nodes", 30.0, false))); + when(realm.getWealthModifiers()).thenReturn(List.of(new Modifier("bank", 500.0, false))); + when(guild.getLedger().getInflationDelta()).thenReturn(3.25); + when(realm.getLedger().getInflationDelta()).thenReturn(-1.0); + when(guild.getTotalExpansionSpent()).thenReturn(7.5); + when(realm.getTotalExpansionSpent()).thenReturn(2.0); + MoneyManager money = mock(MoneyManager.class); + when(money.getServerBal(Accounts.POUCH)).thenReturn(90.126); + when(money.getServerBal(Accounts.BANK)).thenReturn(200.124); + try (MockedStatic<DenarEconomy> economy = mockStatic(DenarEconomy.class)) { + economy.when(DenarEconomy::getMoneyManager).thenReturn(money); + assertEquals(90.13, FactionManager.getPouchWealth()); + assertEquals(200.12, FactionManager.getBankWealth()); + } + assertEquals(145.62, FactionManager.getGlobalWealth()); + assertEquals(45.0, FactionManager.getGlobalLiquidWealth()); + assertEquals(20.0, FactionManager.getGlobalNodeWealth()); + assertEquals(12.35, FactionManager.getGuildLiquidWealth()); + assertEquals(2.25, FactionManager.getTotalGuildIncome()); + assertEquals(9.5, FactionManager.getGlobalGuildExpansions()); + } + + @Test + void queuedDiplomacyRestoresRelationsAndOverlaysAndDrainsEachQueue() { + Faction home = faction("home", "Alice"); + Faction subject = faction("subject", "Bob"); + Faction partner = faction("partner", "Carol"); + RelationType trade = fixture.relationType("trade", Map.of("trade-agreement", true)); + RelationType treaty = fixture.relationType("peace", Map.of("treaty", true)); + for (String invalid : Arrays.asList(null, "broken", "missing(neutral.neutral.1)", "home(neutral.neutral.1)", "partner(neutral)", "partner(missing.neutral.1)", "partner(neutral.missing.1)", "partner(neutral.neutral.invalid)", "partner(")) { + FactionManager.addDBRelation(home, invalid); + } + FactionManager.addDBRelation(home, "subject(vassal.neutral.12)"); + FactionManager.addDBRelation(home, "partner(neutral.neutral.42)"); + for (String invalid : Arrays.asList(null, "broken", "missing(trade)", "home(trade)", "partner(missing)", "partner(")) { + FactionManager.addDBTradeRelation(home, invalid); + } + FactionManager.addDBTradeRelation(home, "partner(trade)"); + fixture.relationType("clear", Map.of("treaty", true, "clear", true)); + for (String invalid : Arrays.asList(null, "broken", "missing(peace)", "home(peace)", "partner(missing)", "partner(neutral)", "partner(clear)", "partner(")) { + FactionManager.addDBTreatyRelation(home, invalid); + } + FactionManager.addDBTreatyRelation(home, "partner(peace)"); + + FactionManager.loadRelations(); + + assertEquals("vassal", home.getRelation("subject").getType().getId()); + assertEquals("overlord", subject.getRelation("home").getType().getId()); + assertEquals(12, home.getRelation("subject").getOpinion()); + assertEquals(42, home.getRelation("partner").getOpinion()); + assertEquals(2, home.getRelations().size()); + assertSame(trade, home.getDiplomacyHandler().getTradeRelation("partner")); + assertSame(treaty, home.getDiplomacyHandler().getTreatyRelation("partner")); + Relation preserved = subject.getRelation("home"); + FactionManager.addDBRelation(home, "subject(vassal.neutral.8)"); + FactionManager.loadRelations(); + assertSame(preserved, subject.getRelation("home"), "an explicit reverse must not be overwritten"); + home.getRelations().clear(); + home.getDiplomacyHandler().getTradeRelations().clear(); + home.getDiplomacyHandler().getTreatyRelations().clear(); + FactionManager.loadRelations(); + assertTrue(home.getRelations().isEmpty()); + assertTrue(home.getDiplomacyHandler().getTradeRelations().isEmpty()); + assertTrue(home.getDiplomacyHandler().getTreatyRelations().isEmpty()); + } + + @Test + void aStoredTradeWhoseTypeBecameDiplomaticDoesNotEnterTheTradeMap() { + Faction home = faction("home", "Alice"); + faction("other", "Bob"); + FactionManager.addDBTradeRelation(home, "other(vassal)"); + + FactionManager.loadRelations(); + + assertTrue(home.getDiplomacyHandler().getTradeRelations().isEmpty()); + } + + @Test + void invalidLegacyTradeTargetsStayUnmigratedAndMutualFallbackRetainsAgreementType() { + Faction home = faction("home", "Alice"); + Faction other = faction("other", "Bob"); + RelationType trade = fixture.relationType("trade", Map.of("trade-agreement", true, "mutual", true, "link", "neutral")); + Relation stale = new Relation(trade, null, 17); + home.getRelations().put("removed", stale); + home.getRelations().put("home", new Relation(trade, null, 0)); + home.setRelation(other, new Relation(trade, null, 25)); + + FactionManager.loadRelations(); + + assertSame(trade, stale.getType()); + assertSame(trade, home.getRelation("home").getType()); + assertSame(RelationLoader.getDefaultType(), home.getRelation("other").getType()); + assertSame(trade, other.getDiplomacyHandler().getTradeRelation("home")); + assertEquals(25, home.getRelation("other").getOpinion()); + assertEquals("trade.?.17", FactionManager.describeRelation(stale)); + assertEquals("null", FactionManager.describeRelation(null)); + assertEquals("null", FactionManager.describeRelation(new Relation(null, null))); + assertEquals("neutral.neutral.0(default)", FactionManager.describeRelation(new Relation())); + } + + @Test + void reloadingBindsTitlesRanksDiplomacyAndOutstandingRequestsToCurrentDefinitions() { + Faction home = faction("home", "Alice"); + Faction other = faction("other", "Bob"); + Title kept = fixture.title("kept", "county", 2); + Title removed = fixture.title("removed", "county", 3); + home.getTitles().addAll(List.of(kept, removed)); + TitleLoader.getTitles().clear(); + Title replacement = fixture.title("kept", "county", 5); + FactionManager.reloadTitles(); + verify(home).resetTitles(List.of(replacement)); + PrestigeRank oldRank = home.getRank(); + RankLoader.ranks.clear(); + PrestigeRank replacementRank = fixture.rank(oldRank.getId(), 1, 30); + other.setRank(null); + FactionManager.rebindRanks(); + assertSame(replacementRank, home.getRank()); + assertSame(replacementRank, other.getRank()); + Relation oldRelation = new Relation(RelationLoader.getType("vassal"), RelationLoader.getDefaultAttitude(), 19); + home.setRelation(other, oldRelation); + home.getRelations().put("unknown", new Relation(null, null)); + home.getRelations().put("invalid", null); + RelationType oldTrade = fixture.relationType("trade", Map.of("trade-agreement", true)); + home.getDiplomacyHandler().getTradeRelations().put("other", oldTrade); + home.getDiplomacyHandler().getTradeRelations().put("null", null); + home.getDiplomacyHandler().getTradeRelations().put("removed", RelationLoader.getType("vassal")); + home.getDiplomacyHandler().getTreatyRelations().put("removed", RelationLoader.getType("vassal")); + RelationLoader.types.clear(); + RelationType neutral = fixture.relationType("neutral", Map.of("default", true)); + RelationType replacementTrade = fixture.relationType("trade", Map.of("trade-agreement", true)); + RelationLoader.attitudes.clear(); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("neutral.default", true); + Attitude neutralAttitude = new Attitude("neutral", yaml.getConfigurationSection("neutral")); + RelationLoader.attitudes.add(neutralAttitude); + + FactionManager.rebindDiplomacy(); + + assertSame(neutral, oldRelation.getType()); + assertSame(neutralAttitude, oldRelation.getAttitude()); + assertEquals(19, oldRelation.getOpinion()); + assertSame(neutral, home.getRelation("unknown").getType()); + assertEquals(Map.of("other", replacementTrade), home.getDiplomacyHandler().getTradeRelations()); + assertTrue(home.getDiplomacyHandler().getTreatyRelations().isEmpty()); + RankLoader.ranks.clear(); + FactionManager.rebindRanks(); + assertNull(home.getRank()); + RelationLoader.types.clear(); + RelationLoader.attitudes.clear(); + FactionManager.rebindDiplomacy(); + assertNull(oldRelation.getType()); + assertNull(oldRelation.getAttitude()); + } + + @Test + void queuedLoansResolveLiveGuildsAndAreNotIssuedTwice() { + Faction home = faction("home", "Alice"); + Guild lender = guild(home, "lender", "Alice"); + Guild borrower = guild(home, "borrower", "Bob"); + Loan source = new Loan("loan", 120, lender, borrower, 1000L, 3, 2, 5, true); + FactionManager.addDBLoan(new LoanData(source)); + + FactionManager.loadDBLoans(); + FactionManager.loadDBLoans(); + + org.mockito.ArgumentCaptor<Loan> issued = org.mockito.ArgumentCaptor.forClass(Loan.class); + verify(lender.getLoanHandler()).issueLoan(issued.capture()); + assertEquals("loan", issued.getValue().getId()); + assertSame(lender, issued.getValue().getIssuer()); + assertSame(borrower, issued.getValue().getBorrower()); + assertEquals(120, issued.getValue().getAmount()); + } + + @Test + void startupListReplacementAndDeletionUpdateRegistryDiplomacyMapAndPersistence() { + Faction home = faction("home", "Alice"); + Faction other = faction("other", "Bob"); + home.setRelation(other, new Relation()); + other.setRelation(home, new Relation()); + manager.start(new ArrayList<>(List.of(home, other))); + + FactionManager.deleteFaction(other); + + assertEquals(List.of(home), FactionManager.factions); + assertFalse(home.getRelations().containsKey("other")); + verify(fixture.map).enqueue("nation", "other-rgb"); + verify(databases.constructed().getLast()).deleteFaction(other); + } + + @Test + void prestigePassesWaitForLoadingAndStopWhenRanksConverge() { + Faction home = faction("home", "Alice"); + Faction other = faction("other", "Bob"); + FactionManager.loading = true; + FactionManager.updateAllPrestige(); + verify(home, never()).updatePrestige(); + FactionManager.loading = false; + doAnswer(call -> { home.setRank(RankLoader.getByString("renowned")); return null; }).when(home).updatePrestige(); + + FactionManager.updateAllPrestigeConverged(); + + verify(home, times(2)).updatePrestige(); + verify(other, times(2)).updatePrestige(); + assertEquals("renowned", home.getRank().getId()); + } + + @Test + void prestigePassesAreBoundedEvenIfCollaboratorNeverSettles() { + Faction home = faction("home", "Alice"); + PrestigeRank low = RankLoader.getLowest(); + PrestigeRank high = RankLoader.getByString("renowned"); + doAnswer(call -> { home.setRank(home.getRank() == low ? high : low); return null; }).when(home).updatePrestige(); + FactionManager.updateAllPrestigeConverged(); + verify(home, times(RankLoader.getRanks().size())).updatePrestige(); + } + + @Test + void usurpingTransfersHighestTitleSubjectsAndTheFormerOverlordLink() { + Player actor = fixture.player("Alice"); + Faction winner = faction("winner", "Alice"); + Faction loser = faction("loser", "Bob"); + Faction subject = faction("subject", "Carol"); + Faction overlord = faction("overlord", "Dave"); + Title title = fixture.title("duchy", "duchy", 2); + when(loser.getHighestTitle()).thenReturn(title); + RelationType subjectType = fixture.relationType("subject", Map.of("overlord", true)); + try (MockedStatic<RelationManager> relations = mockStatic(RelationManager.class)) { + relations.when(() -> RelationManager.getSubjects(loser)).thenReturn(List.of(winner, subject)); + relations.when(() -> RelationManager.getOverlord(loser)).thenReturn("overlord"); + assertSame(title, FactionManager.usurp(actor, winner, loser)); + verify(winner).addTitle(title); + verify(loser).removeTitle(title); + relations.verify(() -> RelationManager.transferSubject(subject, winner)); + relations.verify(() -> RelationManager.transferSubject(winner, winner), never()); + relations.verify(() -> RelationManager.endVassalage(overlord, loser, false)); + relations.verify(() -> RelationManager.transferSubject(winner, overlord)); + relations.verify(() -> RelationManager.endVassalage(winner, loser, true)); + relations.verify(() -> RelationManager.setRelationForced(subjectType, loser, winner)); + } + when(loser.getHighestTitle()).thenReturn(null); + assertNull(FactionManager.usurp(actor, winner, loser)); + assertNull(FactionManager.usurp(null, winner, loser)); + verify(actor).sendMessage("§ctarget has no titles"); + } + + @Test + void relocationRequestRequiresOnlineLeaderAndCarriesTheNamedDestination() { + Player sender = fixture.player("Alice"); + Faction home = faction("home", "Alice"); + Faction target = faction("target", "Bob"); + Guild guild = guild(home, "Artisans", "Alice"); + FactionManager.requestRelocation(sender, guild, target, 7, "Riverside"); + verify(sender).sendMessage("§cTarget faction leader is not online"); + Player leader = fixture.player("Bob"); + FactionManager.requestRelocation(sender, guild, target, 7, "Riverside"); + RelocateRequest request = assertInstanceOf(RelocateRequest.class, RequestManager.getRequest(leader)); + assertSame(guild, request.getSender()); + assertEquals(7, request.getNewCapital()); + assertEquals("Riverside", request.getSettlementName()); + verify(leader).sendMessage("Artisans §7is requesting to relocate to your faction"); + } + + @Test + void relocationAcceptanceRechecksFactionDestinationNameAndAvailableFunds() { + Player sender = fixture.player("Alice"); + Player leader = fixture.player("Bob"); + Faction home = faction("home", "Alice"); + Faction target = faction("target", "Bob"); + when(target.hasProvince(7)).thenReturn(true); + when(target.getSettlementHandler().validateRelocationCapital(any(), eq(7), nullable(String.class))) + .thenReturn(net.tfminecraft.simplefactions.settlement.handler.CapitalResult.ok("Ready")); + Guild guild = guild(home, "Artisans", "Alice"); + RequestManager.addRequest(sender, leader, new RelocateRequest(guild, 7, null)); + FactionManager.factions.remove(target); + FactionManager.acceptRelocateRequest(leader); + verify(leader).sendMessage("§cYou do not have a faction"); + FactionManager.factions.add(target); + when(target.getSettlementHandler().requiresFoundingName(7)).thenReturn(true); + FactionManager.acceptRelocateRequest(leader); + verify(leader).sendMessage("§cA city name is required to relocate here"); + verify(sender).sendMessage("§cRelocation failed - a city name is required at the destination"); + RequestManager.remove(leader); + RequestManager.addRequest(sender, leader, new RelocateRequest(guild, 7, "Riverside")); + when(guild.getRelocationCost(7)).thenReturn(25.5); + when(guild.getBank().getWealth()).thenReturn(25.0); + FactionManager.acceptRelocateRequest(leader); + verify(guild.getBank(), never()).withdraw(anyDouble()); + verify(guild, never()).relocate(any(), anyInt(), any(), any()); + verify(sender).sendMessage(contains("does not have enough funds")); + when(guild.getBank().getWealth()).thenReturn(25.5); + FactionManager.acceptRelocateRequest(leader); + verify(guild.getBank()).withdraw(25.5); + verify(guild).relocate(target, 7, sender, "Riverside"); + verify(leader).sendMessage("Artisans§a has been relocated to your faction"); + } + + @Test + void relocationCanCompleteWithTheReceivingLeaderWhenTheGuildLeaderGoesOffline() { + Player leader = fixture.player("Bob"); + Faction home = faction("home", "Alice"); + Faction target = faction("target", "Bob"); + when(target.hasProvince(7)).thenReturn(true); + when(target.getSettlementHandler().validateRelocationCapital(any(), eq(7), nullable(String.class))) + .thenReturn(net.tfminecraft.simplefactions.settlement.handler.CapitalResult.ok("Ready")); + Guild guild = guild(home, "Artisans", "Alice"); + RequestManager.addRequest(leader, leader, new RelocateRequest(guild, 7, null)); + when(guild.getRelocationCost(7)).thenReturn(10.0); + when(guild.getBank().getWealth()).thenReturn(20.0); + FactionManager.acceptRelocateRequest(leader); + verify(guild).relocate(target, 7, leader, null); + verify(guild.getBank()).withdraw(10.0); + } + + @Test + void elevationRequestRequiresOnlineLeaderAndNamesTheSponsoringFaction() { + Player sender = fixture.player("Alice"); + Faction home = faction("home", "Alice"); + Guild guild = guild(home, "Artisans", "Bob"); + FactionManager.requestElevation(sender, guild); + verify(sender).sendMessage("§cTarget guild leader is not online"); + Player leader = fixture.player("Bob"); + FactionManager.requestElevation(sender, guild); + assertSame(guild, assertInstanceOf(ElevateRequest.class, RequestManager.getRequest(leader)).getSender()); + verify(leader).sendMessage("home §7wants to elevate your guild to a faction"); + } + + @Test + void elevationRechecksLeadershipAndPowerAndOnlyChargesSuccessfulElevation() { + Player leader = fixture.player("Bob"); + Faction home = faction("home", "Alice"); + Guild guild = guild(home, "Artisans", "Bob"); + RequestManager.addRequest(leader, leader, new ElevateRequest(guild)); + home.getGuildHandler().getGuilds().clear(); + FactionManager.acceptElevationRequest(leader); + verify(leader).sendMessage("§cYou are not the leader of a guild"); + home.getGuildHandler().getGuilds().add(guild); + when(guild.getElevationCost()).thenReturn(12.0); + when(home.getGovernment().getPower()).thenReturn(11.0); + FactionManager.acceptElevationRequest(leader); + verify(leader).sendMessage("§cCannot afford to elevate"); + verify(guild, never()).elevate(anyBoolean()); + when(home.getGovernment().getPower()).thenReturn(12.0); + when(guild.elevate(true)).thenReturn(null); + FactionManager.acceptElevationRequest(leader); + verify(home.getGovernment(), never()).spendPower(anyDouble()); + Faction elevated = faction("elevated", "Bob"); + when(guild.elevate(true)).thenAnswer(call -> { + when(guild.getFaction()).thenReturn(elevated); + return elevated; + }); + FactionManager.acceptElevationRequest(leader); + verify(home.getGovernment()).spendPower(12.0); + verify(elevated.getGovernment(), never()).spendPower(anyDouble()); + verify(leader).sendMessage("§aGuild elevated to Faction!"); + verify(leader).playSound(leader, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); + } + + @Test + void elevationConsentCannotBeTransferredToAnotherGuildTheRecipientNowLeads() { + Player sponsor = fixture.player("Alice"); + Player leader = fixture.player("Bob"); + Faction home = faction("home", "Alice"); + Guild requested = guild(home, "requested", "Bob"); + when(home.getGovernment().getPower()).thenReturn(100.0); + FactionManager.requestElevation(sponsor, requested); + when(requested.isLeader("Bob")).thenReturn(false); + when(requested.getLeader()).thenReturn("Carol"); + Guild replacement = guild(home, "replacement", "Bob"); + + FactionManager.acceptElevationRequest(leader); + + verify(requested, never()).elevate(anyBoolean()); + verify(replacement, never()).elevate(anyBoolean()); + verify(home.getGovernment(), never()).spendPower(anyDouble()); + } + + @Test + void elevationConsentExpiresWhenTheRequestedGuildMovesToAnotherSponsor() { + Player sponsor = fixture.player("Alice"); + Player leader = fixture.player("Bob"); + Faction home = faction("home", "Alice"); + Faction other = faction("other", "Carol"); + Guild requested = guild(home, "requested", "Bob"); + when(home.getGovernment().getPower()).thenReturn(100.0); + when(other.getGovernment().getPower()).thenReturn(100.0); + FactionManager.requestElevation(sponsor, requested); + home.getGuildHandler().getGuilds().remove(requested); + other.getGuildHandler().getGuilds().add(requested); + when(requested.getFaction()).thenReturn(other); + + FactionManager.acceptElevationRequest(leader); + + verify(requested, never()).elevate(anyBoolean()); + verify(home.getGovernment(), never()).spendPower(anyDouble()); + verify(other.getGovernment(), never()).spendPower(anyDouble()); + } + + @Test + void movementJoinRechecksOnlineSenderFactionMovementAndCurrentEligibility() { + Player sender = fixture.player("Alice"); + Faction home = faction("home", "Bob"); + Movement movement = movement(home, "Bob"); + FactionManager.requestMovementJoin(sender, movement, "supporter", null); + verify(sender).sendMessage("§cTarget movement leader is not online"); + Player leader = fixture.player("Bob"); + FactionManager.requestMovementJoin(sender, movement, "supporter", null); + assertInstanceOf(MovementJoinRequest.class, RequestManager.getRequest(leader)); + fixture.online.remove("Alice"); + FactionManager.acceptMovementJoinRequest(leader); + verify(leader).sendMessage("§cRequest sender is not online"); + fixture.online.put("Alice", sender); + FactionManager.factions.remove(home); + FactionManager.acceptMovementJoinRequest(leader); + verify(leader).sendMessage("§cTarget faction no longer exists"); + FactionManager.factions.add(home); + when(home.getGovernment().getMovementByLeader("Bob")).thenReturn(null); + FactionManager.acceptMovementJoinRequest(leader); + verify(leader).sendMessage("§cYou are not the leader of a movement"); + when(home.getGovernment().getMovementByLeader("Bob")).thenReturn(movement); + FactionManager.acceptMovementJoinRequest(leader); + verify(leader).sendMessage("§cSender cannot join movement as a supporter"); + when(movement.getJoiningAs(sender)).thenReturn("Alice"); + when(movement.joinBlockReason("Alice", null, true)).thenReturn("§cStaff block"); + when(movement.joinBlockReason("Alice", null, false)).thenReturn("§cPlayer block"); + FactionManager.acceptMovementJoinRequest(leader); + verify(leader).sendMessage("§cStaff block"); + verify(sender).sendMessage("§cPlayer block"); + when(movement.canJoin("Alice", null)).thenReturn(true); + FactionManager.acceptMovementJoinRequest(leader); + verify(movement).join("Alice", null); + verify(leader).sendMessage("§aAlice has joined your movement as a supporter!"); + } + + @Test + void foreignBackingRequiresALiveFactionAndUsesSeparateStaffAndPlayerDenialText() { + Player sender = fixture.player("Alice"); + Player leader = fixture.player("Bob"); + Faction home = faction("home", "Bob"); + Movement movement = movement(home, "Bob"); + FactionManager.requestMovementJoin(sender, movement, "foreign_backer", null); + FactionManager.acceptMovementJoinRequest(leader); + verify(leader).sendMessage("§cSender has no faction."); + Faction foreign = faction("foreign", "Alice"); + when(movement.foreignBackerBlockReason(foreign, true)).thenReturn("§cStaff denial"); + when(movement.foreignBackerBlockReason(foreign, false)).thenReturn("§cPlayer denial"); + FactionManager.acceptMovementJoinRequest(leader); + verify(leader).sendMessage("§cStaff denial"); + verify(sender).sendMessage("§cPlayer denial"); + verify(movement, never()).joinAsForeignBacker(any()); + when(movement.foreignBackerBlockReason(foreign, true)).thenReturn(null); + FactionManager.acceptMovementJoinRequest(leader); + verify(movement).joinAsForeignBacker(foreign); + } + + @Test + void wantedLeaderRequestValidatesTheCandidateBeforeOfferingConsent() { + Player sender = fixture.player("Alice"); + Faction home = faction("home", "Alice"); + Movement movement = movement(home, "Alice"); + Cause cause = leadershipCause(movement); + FactionManager.requestMovementLeaderTarget(null, movement, cause, "Bob"); + FactionManager.requestMovementLeaderTarget(sender, null, cause, "Bob"); + FactionManager.requestMovementLeaderTarget(sender, movement, null, "Bob"); + FactionManager.requestMovementLeaderTarget(sender, movement, cause, null); + FactionManager.requestMovementLeaderTarget(sender, movement, cause, "Bob"); + verify(sender).sendMessage("§cThat player must be online to become the wanted leader."); + Player target = fixture.player("Bob"); + FactionManager.requestMovementLeaderTarget(sender, movement, cause, "Bob"); + assertNull(RequestManager.getRequest(target)); + verify(sender).sendMessage("§cBob cannot be the wanted leader."); + when(home.canBecomeLeader("Bob")).thenReturn(true); + FactionManager.requestMovementLeaderTarget(sender, movement, cause, "Bob"); + MovementLeaderTargetRequest request = assertInstanceOf(MovementLeaderTargetRequest.class, RequestManager.getRequest(target)); + assertEquals("Alice", request.getRequester()); + assertEquals("movement", request.getMovementId()); + assertEquals("Bob", request.getProposedName()); + } + + @Test + void wantedLeaderAcceptanceRechecksMovementAndCandidateThenPersistsTheExactProposal() { + Player sender = fixture.player("Alice"); + Player target = fixture.player("Bob"); + Faction home = faction("home", "Alice"); + when(home.canBecomeLeader("Bob")).thenReturn(true); + Movement movement = movement(home, "Alice"); + Cause cause = leadershipCause(movement); + FactionManager.acceptMovementLeaderTargetRequest(target); + FactionManager.requestMovementLeaderTarget(sender, movement, cause, "Bob"); + when(movement.isFrozen()).thenReturn(true); + FactionManager.acceptMovementLeaderTargetRequest(target); + verify(target).sendMessage("§cThat movement is no longer available."); + when(movement.isFrozen()).thenReturn(false); + when(home.canBecomeLeader("Bob")).thenReturn(false); + FactionManager.acceptMovementLeaderTargetRequest(target); + verify(target).sendMessage("§cYou can no longer become the wanted leader."); + assertNull(cause.getProposal().getTarget()); + when(home.canBecomeLeader("Bob")).thenReturn(true); + FactionManager.acceptMovementLeaderTargetRequest(target); + assertEquals("Bob", cause.getProposal().getTarget()); + verify(databases.constructed().getLast()).saveFaction(home); + verify(sender).sendMessage("§aBob accepted becoming the wanted leader."); + verify(target).sendMessage("§aYou are now the wanted leader of this movement."); + } + + @Test + void wantedLeaderConsentExpiresWhenTheRequesterNoLongerLeadsThatCause() { + Player sender = fixture.player("Alice"); + Player target = fixture.player("Bob"); + Faction home = faction("home", "Alice"); + when(home.canBecomeLeader("Bob")).thenReturn(true); + Movement movement = movement(home, "Alice"); + Cause cause = leadershipCause(movement); + FactionManager.requestMovementLeaderTarget(sender, movement, cause, "Bob"); + cause.setLeader("Carol"); + + FactionManager.acceptMovementLeaderTargetRequest(target); + + assertNull(cause.getProposal().getTarget()); + assertTrue(databases.constructed().isEmpty()); + } + + @Test + void wantedLeaderConsentFollowsTheOriginalSurvivingCauseAfterReindexing() { + Player sender = fixture.player("Alice"); + Player target = fixture.player("Bob"); + Faction home = faction("home", "Alice"); + when(home.canBecomeLeader("Bob")).thenReturn(true); + Movement movement = movement(home, "Alice"); + Cause first = leadershipCause(movement); + Cause selected = leadershipCause(movement); + Cause later = leadershipCause(movement); + FactionManager.requestMovementLeaderTarget(sender, movement, selected, "Bob"); + movement.getCauses().remove(first); + fixture.online.remove("Alice"); + + FactionManager.acceptMovementLeaderTargetRequest(target); + + assertEquals("Bob", selected.getProposal().getTarget()); + assertNull(later.getProposal().getTarget()); + verify(databases.constructed().getLast()).saveFaction(home); + } + + @Test + void wantedLeaderConsentCannotApplyToAReplacementMovementWithTheSameId() { + Player sender = fixture.player("Alice"); + Player target = fixture.player("Bob"); + Faction home = faction("home", "Alice"); + when(home.canBecomeLeader("Bob")).thenReturn(true); + Movement original = movement(home, "Alice"); + Cause originalCause = leadershipCause(original); + FactionManager.requestMovementLeaderTarget(sender, original, originalCause, "Bob"); + Movement replacement = movement(home, "Alice"); + Cause replacementCause = leadershipCause(replacement); + FactionManager.acceptMovementLeaderTargetRequest(target); + assertNull(originalCause.getProposal().getTarget()); + assertNull(replacementCause.getProposal().getTarget()); + assertTrue(databases.constructed().isEmpty()); + } + + @Test + void aWantedLeaderRequestForAnotherPlayerIsRejectedWithoutChangingTheProposal() { + Player sender = fixture.player("Alice"); + Player target = fixture.player("Bob"); + Faction home = faction("home", "Alice"); + when(home.canBecomeLeader("Carol")).thenReturn(true); + Movement movement = movement(home, "Alice"); + Cause cause = leadershipCause(movement); + MovementLeaderTargetRequest request = new MovementLeaderTargetRequest(null, "Alice", movement, cause, "Carol"); + RequestManager.addRequest(sender, target, request); + FactionManager.acceptMovementLeaderTargetRequest(target); + verify(target).sendMessage("§cThat request is not for you."); + assertNull(cause.getProposal().getTarget()); + } + + @Test + void legacyOrdinalOnlyRequestsRetainTheirDataButCannotRetargetCurrentCauses() { + Player sender = fixture.player("Alice"); + Player target = fixture.player("Bob"); + Faction home = faction("home", "Bob"); + Movement movement = movement(home, "Bob"); + Cause cause = leadershipCause(movement); + when(movement.getJoiningAs(sender)).thenReturn("Alice"); + when(movement.canJoin(eq("Alice"), any())).thenReturn(true); + MovementJoinRequest join = new MovementJoinRequest(null, "Alice", "supporter", "home", 0); + assertEquals("Alice", join.getPlayer()); + assertEquals("supporter", join.getType()); + assertEquals("home", join.getTargetFactionId()); + assertEquals(0, join.getCauseIndex()); + RequestManager.addRequest(sender, target, join); + assertDoesNotThrow(() -> FactionManager.acceptMovementJoinRequest(target)); + verify(movement, never()).join(any(), any()); + RequestManager.remove(target); + MovementLeaderTargetRequest leader = new MovementLeaderTargetRequest(null, "Alice", "movement", 0, "Bob"); + assertEquals(0, leader.getCauseIndex()); + RequestManager.addRequest(sender, target, leader); + when(home.canBecomeLeader("Bob")).thenReturn(true); + FactionManager.acceptMovementLeaderTargetRequest(target); + assertNull(cause.getProposal().getTarget()); + } + + /** External daily services stay bounded; the manager and transfer buffer remain real. */ + private final class DailyServices implements AutoCloseable { + final MockedStatic<InactivityService> inactivity = mockStatic(InactivityService.class); + final MockedStatic<ContractAccrualService> accrual = mockStatic(ContractAccrualService.class); + final MockedStatic<ContractTerminationService> termination = mockStatic(ContractTerminationService.class); + final MockedStatic<PostSettlementPayouts> payouts = mockStatic(PostSettlementPayouts.class); + final MockedStatic<WarReparationsService> reparations = mockStatic(WarReparationsService.class); + final MockedStatic<TradeGraph> routes = mockStatic(TradeGraph.class); + final MockedStatic<FactionCleanup> cleanup = mockStatic(FactionCleanup.class); + final MockedStatic<PlayerEconomyManager> players = mockStatic(PlayerEconomyManager.class); + final MockedStatic<Ledger> histories = mockStatic(Ledger.class); + final PlayerEconomyManager economy = mock(PlayerEconomyManager.class); + final VehicleUpkeepService upkeep = mock(VehicleUpkeepService.class); + + DailyServices() { + players.when(PlayerEconomyManager::get).thenReturn(economy); + histories.when(() -> Ledger.collectHistoryDay(any())).thenReturn(Map.of()); + when(fixture.ui.plugin.getVehicleUpkeepService()).thenReturn(upkeep); + } + + @Override + public void close() { + histories.close(); + players.close(); + cleanup.close(); + routes.close(); + reparations.close(); + payouts.close(); + termination.close(); + accrual.close(); + inactivity.close(); + } + } + + @Test + void dailySettlementNetsTransfersRoundsBalancesAndPublishesHistoryBeforeClearingBuffer() { + Faction home = faction("home", "Alice"); + Guild payer = guild(home, "payer", "Alice"); + Guild recipient = guild(home, "recipient", "Bob"); + Guild zero = guild(home, "zero", "Carol"); + Guild noBank = guild(home, "no-bank", "Dave"); + when(noBank.getBank()).thenReturn(null); + Loan loan = mock(Loan.class); + when(loan.getId()).thenReturn("loan"); + when(payer.getLoanHandler().getLoansGiven()).thenReturn(Arrays.asList(null, loan)); + Ledger payerLedger = payer.getLedger(); + AtomicReference<DailyGuildTransfers> collected = new AtomicReference<>(); + doAnswer(call -> { + DailyGuildTransfers buffer = call.getArgument(0); + collected.set(buffer); + buffer.add(payer, recipient, 7.12); + buffer.addExternalDelta(payer, 10.004); + buffer.addExternalDelta(recipient, -1.004); + buffer.addExternalDelta(zero, 3.0); + buffer.addExternalDelta(zero, -3.0); + buffer.addExternalDelta(noBank, 2.0); + return null; + }).when(payerLedger).populateDailyTransfers(any()); + try (DailyServices daily = new DailyServices()) { + Map<LedgerHistory.Source, Map<String, Double>> before = new HashMap<>(); + daily.histories.when(() -> Ledger.collectHistoryDay(any())).thenReturn(Map.of(payer, before)); + daily.payouts.when(() -> PostSettlementPayouts.apply(any(), any(), any(), any())) + .thenAnswer(call -> { + DailyGuildTransfers buffer = call.getArgument(0); + assertEquals(7.12, buffer.getTransfers().get(payer).get(recipient)); + verify(payer.getBank()).deposit(2.88); + verify(recipient.getBank()).deposit(6.12); + return null; + }); + + manager.settleIncome(); + + verify(payer.getLedger().getHistory()).closeDay(same(before)); + verify(recipient.getLedger().getHistory()).closeDay(null); + verify(zero.getBank(), never()).deposit(anyDouble()); + verify(loan).tickDay(); + verify(payer).refreshDividendEligibility(); + verify(recipient).refreshDividendEligibility(); + daily.accrual.verify(ContractAccrualService::accrueDailyAndPush); + daily.termination.verify(ContractTerminationService::tickDay); + daily.reparations.verify(() -> WarReparationsService.tickAfterDailySettlement(home)); + verify(daily.upkeep).processDailyUpkeep(); + verify(fixture.ui.plugin).recordVehicleOwners(); + assertTrue(collected.get().getTransfers().isEmpty()); + assertTrue(collected.get().getExternalDeltas().isEmpty()); + } + } + + @Test + void settlementLiquidatesWhileAssetsShrinkAndStopsAStalledLiquidator() { + Faction home = faction("home", "Alice"); + Guild shrinking = guild(home, "shrinking", "Alice"); + Guild stalled = guild(home, "stalled", "Bob"); + AtomicInteger remaining = new AtomicInteger(5); + when(shrinking.isBankrupt()).thenAnswer(call -> remaining.get() > 3); + when(shrinking.canLiquidate()).thenReturn(true); + when(shrinking.getSize()).thenAnswer(call -> remaining.get()); + doAnswer(call -> { remaining.decrementAndGet(); return null; }).when(shrinking).liquidateRandom(); + when(stalled.isBankrupt()).thenReturn(true); + when(stalled.canLiquidate()).thenReturn(true); + when(stalled.getSize()).thenReturn(3); + Ledger shrinkingLedger = shrinking.getLedger(); + doAnswer(call -> { + DailyGuildTransfers buffer = call.getArgument(0); + buffer.addExternalDelta(shrinking, -20); + buffer.addExternalDelta(stalled, -20); + return null; + }).when(shrinkingLedger).populateDailyTransfers(any()); + try (DailyServices daily = new DailyServices()) { + manager.settleIncome(); + verify(shrinking, times(2)).liquidateRandom(); + assertEquals(3, remaining.get()); + verify(stalled).liquidateRandom(); + verify(daily.upkeep).processDailyUpkeep(); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void dailyFailureIsLoggedAndDoesNotPreventOtherGuildsOrFinalMaintenance(boolean availableLogger) { + Faction home = faction("home", "Alice"); + Guild broken = guild(home, "broken", "Alice"); + Guild healthy = guild(home, "healthy", "Bob"); + Logger logger = mock(Logger.class); + when(fixture.ui.plugin.getLogger()).thenReturn(availableLogger ? logger : null); + IllegalStateException failure = new IllegalStateException("broken ledger"); + Ledger brokenLedger = broken.getLedger(); + Ledger healthyLedger = healthy.getLedger(); + doThrow(failure).when(brokenLedger).populateDailyTransfers(any()); + doAnswer(call -> { ((DailyGuildTransfers) call.getArgument(0)).addExternalDelta(healthy, 10); return null; }) + .when(healthyLedger).populateDailyTransfers(any()); + try (DailyServices daily = new DailyServices()) { + assertDoesNotThrow(manager::settleIncome); + verify(healthy.getBank()).deposit(10.0); + verify(healthy.getLedger().getHistory()).closeDay(null); + verify(daily.upkeep).processDailyUpkeep(); + if (availableLogger) verify(logger).log(Level.SEVERE, "Daily tick failed during ledger broken", failure); + } + } + + @Test + void hourlyAndFiveMinuteTicksAdvancePowerAndPillageAndWarnOnlyOnlineOverCapLeaders() { + Player leader = fixture.player("Alice"); + Faction home = faction("home", "Alice"); + Faction offline = faction("offline", "Bob"); + Faction landless = faction("landless", "Carol"); + home.getProvinces().add(1); + offline.getProvinces().add(2); + Guild guild = guild(home, "guild", "Alice"); + try (DailyServices daily = new DailyServices(); MockedStatic<TitleManager> titles = mockStatic(TitleManager.class)) { + titles.when(() -> TitleManager.overProvinceCap(home)).thenReturn(true); + FactionManager.timer = 3599; + manager.time(); + assertEquals(3600, FactionManager.getTimer()); + assertEquals(82800, FactionManager.getSecondsUntilNewDay()); + verify(home.getGovernment()).powerTick(); + verify(guild).tickPillageHits(); + verify(leader).sendMessage("§cYou are over your province cap! Other nations can steal your provinces from you!"); + verify(leader).sendMessage("§cGet more prestige or unclaim provinces to counteract this!"); + titles.verify(() -> TitleManager.overProvinceCap(offline), never()); + titles.verify(() -> TitleManager.overProvinceCap(landless), never()); + verify(daily.upkeep).warnBankShortfalls(fixture.online.values(), 82800); + daily.inactivity.verify(() -> InactivityService.tickIfDue(FactionManager.factions)); + } + FactionManager.timer = -4; + assertEquals(86400, FactionManager.getSecondsUntilNewDay()); + FactionManager.timer = 90000; + assertEquals(0, FactionManager.getSecondsUntilNewDay()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void dayRolloverContinuesAfterOneFactionFailureAndDoesNotPayAgainNextSecond(boolean tracksChanged) { + Faction broken = faction("broken", "Alice"); + Faction healthy = faction("healthy", "Bob"); + IllegalStateException failure = new IllegalStateException("one faction failed"); + doThrow(failure).when(broken).newDay(); + Logger logger = mock(Logger.class); + when(fixture.ui.plugin.getLogger()).thenReturn(logger); + when(fixture.ui.plugin.refreshTrackProvinces()).thenReturn(tracksChanged); + try (DailyServices daily = new DailyServices()) { + FactionManager.timer = 86399; + FactionManager.day = 5; + manager.time(); + assertEquals(0, FactionManager.getTimer()); + assertEquals(6, FactionManager.getDay()); + verify(healthy).newDay(); + verify(daily.economy).clearAllDaily(); + daily.cleanup.verify(() -> FactionCleanup.advanceOfflineDays(FactionManager.factions)); + daily.routes.verify(TradeGraph::forgetRoutes); + verify(fixture.provinces, tracksChanged ? never() : times(1)).recalculate(); + verify(logger).log(Level.SEVERE, "Daily tick failed during faction broken", failure); + verify(daily.upkeep).processDailyUpkeep(); + manager.time(); + assertEquals(1, FactionManager.getTimer()); + assertEquals(6, FactionManager.getDay()); + verify(daily.upkeep, times(1)).processDailyUpkeep(); + verify(healthy, times(1)).newDay(); + } + } + + @Test + void runRestoresClockInitializesFactionsRepairsAlliesAndSchedulesTheRealTick() { + Faction home = faction("home", "Alice"); + Faction ally = faction("ally", "Bob"); + RelationType allyType = fixture.relationType("ally", Map.of("mutual", true)); + Relation reverse = new Relation(RelationLoader.getDefaultType(), RelationLoader.getDefaultAttitude(), 17); + ally.setRelation(home, reverse); + databases.close(); + databases = mockConstruction(Database.class, (database, context) -> { + when(database.getTimer()).thenReturn(42); + when(database.getDay()).thenReturn(9); + }); + try (DailyServices daily = new DailyServices(); MockedStatic<RelationManager> relations = mockStatic(RelationManager.class)) { + relations.when(() -> RelationManager.getAllies(home)).thenReturn(List.of(ally)); + assertFalse(FactionManager.isLoaded()); + manager.run(); + assertTrue(FactionManager.isLoaded()); + assertEquals(42, FactionManager.getTimer()); + assertEquals(9, FactionManager.getDay()); + verify(home.getLawHandler()).apply(); + verify(home).countyCheck(); + verify(home.getGovernment()).loadMovements(); + verify(home).ping(); + assertSame(allyType, reverse.getType()); + assertEquals(17, reverse.getOpinion()); + assertEquals(1, fixture.ui.repeatingTasks.size()); + fixture.ui.repeatingTasks.getFirst().run(); + verify(home).tick(); + verify(ally).tick(); + verify(fixture.map).tick(); + relations.verify(RelationManager::tick); + verify(fixture.inventory.getUpdater()).updateInventory(); + assertEquals(43, FactionManager.getTimer()); + manager.fixRelations(); + assertSame(allyType, reverse.getType()); + } + } + + private Block booth() { + Block block = mock(Block.class); + when(block.getLocation()).thenReturn(new Location(fixture.ui.world, 4, 64, 7)); + return block; + } + + private PlayerInteractEvent interact(Player player, Block block, org.bukkit.event.block.Action action) { + return new PlayerInteractEvent(player, action, null, block, BlockFace.UP); + } + + @Test + void ordinaryInteractionsAndUnknownBlocksDoNotOpenOrCancelVoting() { + Player player = fixture.player("Alice"); + faction("home", "Alice"); + Block block = booth(); + PlayerInteractEvent left = interact(player, block, org.bukkit.event.block.Action.LEFT_CLICK_BLOCK); + PlayerInteractEvent other = interact(player, block, org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK); + manager.openBooth(left); + manager.openBooth(other); + assertFalse(left.isCancelled()); + assertFalse(other.isCancelled()); + assertNull(manager.getByVotingBooth(null)); + assertNull(manager.getByVotingBooth(block)); + } + + @Test + void aBoothWithoutElectionsGivesFeedbackAndSuppressesDuplicateHandEvents() { + Player player = fixture.player("Alice"); + Location playerLocation = player.getLocation(); + Faction home = faction("home", "Alice"); + Block block = booth(); + when(home.getGovernment().isVotingBooth(block.getLocation())).thenReturn(true); + PlayerInteractEvent first = interact(player, block, org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK); + PlayerInteractEvent second = interact(player, block, org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK); + manager.openBooth(first); + manager.openBooth(second); + assertTrue(first.isCancelled()); + assertTrue(second.isCancelled()); + assertSame(home, manager.getByVotingBooth(block)); + verify(player, times(1)).sendMessage("§cThis faction has no elections"); + verify(player, times(1)).playSound(playerLocation, Sound.ENTITY_VILLAGER_NO, 1f, 1f); + } + + @Test + void votingRequiresCurrentRightsBeforeOpeningElectionMenu() { + Faction home = faction("home", "Alice"); + Player denied = fixture.player("Alice"); + Player allowed = fixture.player("Bob"); + Location deniedLocation = denied.getLocation(); + Block block = booth(); + when(home.getGovernment().isVotingBooth(block.getLocation())).thenReturn(true); + when(home.getGovernment().hasElections()).thenReturn(true); + when(home.canVote("Bob")).thenReturn(true); + try (MockedConstruction<InventoryManager> menus = mockConstruction(InventoryManager.class)) { + PlayerInteractEvent refusal = interact(denied, block, org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK); + manager.openBooth(refusal); + assertTrue(refusal.isCancelled()); + assertTrue(menus.constructed().isEmpty()); + verify(denied).sendMessage("§cYou have no voting rights in this faction"); + verify(denied).playSound(deniedLocation, Sound.ENTITY_VILLAGER_NO, 1f, 1f); + PlayerInteractEvent acceptance = interact(allowed, block, org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK); + manager.openBooth(acceptance); + assertTrue(acceptance.isCancelled()); + verify(menus.constructed().getFirst()).electionView(allowed, home); + } + } + + @Test + void nativeBlockEventsRegisterAndRemoveOnlyMatchingBoothsAndAvoidDuplicates() { + Player player = fixture.player("Alice"); + Location playerLocation = player.getLocation(); + Faction home = faction("home", "Alice"); + Block block = booth(); + BlockPlaceEvent place = mock(BlockPlaceEvent.class); + when(place.getPlayer()).thenReturn(player); + when(place.getBlock()).thenReturn(block); + BlockBreakEvent broken = new BlockBreakEvent(block, player); + try (MockedStatic<TLibs> tlibs = mockStatic(TLibs.class, RETURNS_DEEP_STUBS)) { + manager.placeVotingBooth(place); + manager.breakVotingBooth(broken); + verify(home.getGovernment(), never()).addVotingBooth(any()); + verify(home.getGovernment(), never()).removeVotingBooth(any()); + when(TLibs.getBlockAPI().getChecker().checkBlock(block, Cache.votingBlock)).thenReturn(true); + FactionManager.factions.clear(); + manager.placeVotingBooth(place); + FactionManager.factions.add(home); + manager.placeVotingBooth(place); + verify(home.getGovernment()).addVotingBooth(block.getLocation()); + verify(player).sendMessage("§aVoting booth added!"); + verify(player).playSound(playerLocation, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); + when(home.getGovernment().isVotingBooth(block.getLocation())).thenReturn(true); + manager.placeVotingBooth(place); + verify(home.getGovernment(), times(1)).addVotingBooth(any()); + manager.breakVotingBooth(broken); + verify(home.getGovernment()).removeVotingBooth(block.getLocation()); + verify(player).sendMessage("§cVoting booth removed!"); + verify(player).playSound(playerLocation, Sound.BLOCK_ANVIL_LAND, 1f, 1f); + when(home.getGovernment().isVotingBooth(block.getLocation())).thenReturn(false); + manager.breakVotingBooth(broken); + verify(home.getGovernment(), times(1)).removeVotingBooth(any()); + } + } + + @Test + void furnitureConfiguredBoothsAreLeftToTheFurnitureListener() { + Player player = fixture.player("Alice"); + Block block = booth(); + Cache.votingBlock = "iaf.tfmc:voting_booth"; + BlockPlaceEvent place = mock(BlockPlaceEvent.class); + BlockBreakEvent broken = new BlockBreakEvent(block, player); + try (MockedStatic<TLibs> tlibs = mockStatic(TLibs.class)) { + manager.placeVotingBooth(place); + manager.breakVotingBooth(broken); + tlibs.verifyNoInteractions(); + verifyNoInteractions(place); + } + } + + @Test + void aPartiallyLoadedSnapshotCanBeInspectedWithoutInventingRelationsOrGuilds() { + Faction complete = faction("complete", "Alice"); + Guild guild = guild(complete, "guild", "Alice"); + manager.start(new ArrayList<>(Arrays.asList(null, complete, null))); + assertDoesNotThrow(FactionManager::loadRelations); + assertEquals(List.of(guild), FactionManager.getAllGuilds()); + assertTrue(complete.getRelations().isEmpty()); + Faction partial = mock(Faction.class); + when(partial.getProvinces()).thenReturn(List.of()); + manager.start(new ArrayList<>(List.of(partial))); + assertTrue(FactionManager.getAllGuilds().isEmpty()); + try (DailyServices daily = new DailyServices()) { + FactionManager.timer = 3599; + assertDoesNotThrow(manager::time); + assertEquals(3600, FactionManager.getTimer()); + verify(daily.upkeep).warnBankShortfalls(fixture.online.values(), 82800); + } + } + + @Test + void addingFactionWithAnUnplacedMainGuildDoesNotInventACapital() { + Faction home = faction("home", "Alice"); + home.getProvinces().add(4); + when(home.getCapital()).thenReturn(-1); + when(home.getOrCreateMainGuild().hasCapital()).thenReturn(false); + FactionManager.factions.clear(); + try (MockedStatic<net.tfminecraft.simplefactions.espionage.EspionageService> espionage = mockStatic(net.tfminecraft.simplefactions.espionage.EspionageService.class)) { + FactionManager.addFaction(home); + assertEquals(List.of(home), FactionManager.factions); + espionage.verify(() -> net.tfminecraft.simplefactions.espionage.EspionageService.initializeFounder(home)); + verify(home, never()).setCapital(anyInt(), anyBoolean()); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerCoverageTest.java new file mode 100644 index 00000000..bcbb4d93 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerCoverageTest.java @@ -0,0 +1,1754 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.army.Military; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.enums.Member; +import net.tfminecraft.simplefactions.enums.Rules; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.government.Government; +import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.government.movement.cause.Cause; +import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.government.proposal.Proposal; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.Ledger; +import net.tfminecraft.simplefactions.guild.loans.Loan; +import net.tfminecraft.simplefactions.guild.loans.LoanHandler; +import net.tfminecraft.simplefactions.installation.handler.ConstructResult; +import net.tfminecraft.simplefactions.installation.handler.InstallationHandler; +import net.tfminecraft.simplefactions.managers.holder.*; +import net.tfminecraft.simplefactions.managers.inventory.*; +import net.tfminecraft.simplefactions.mercenary.MercenaryResult; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompanyService; +import net.tfminecraft.simplefactions.mercenary.contract.ContractHandler; +import net.tfminecraft.simplefactions.mercenary.contract.MercenaryContract; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.Bracket; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.handler.GuildHandler; +import net.tfminecraft.simplefactions.objects.handler.TaxHandler; +import net.tfminecraft.simplefactions.objects.handler.VehicleFeeHandler; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import net.tfminecraft.simplefactions.tiers.Tier; +import net.tfminecraft.simplefactions.war.battle.campaign.warband.BattleWarbandRetreatService.ConfirmHandler; +import net.tfminecraft.simplefactions.war.campaign.progression.WhitePeaceService; +import net.tfminecraft.simplefactions.war.civilwar.CivilWarCopy; +import net.tfminecraft.simplefactions.war.core.Participant; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.declare.WarDeclareRequest; +import org.bukkit.Chunk; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +/** Tests InventoryManager's routing contract; individual views have their own rendering tests. */ +@SuppressWarnings("deprecation") +class InventoryManagerCoverageTest { + private GuiTestFixture gui; + private InventoryManager manager; + private MockedStatic<FactionManager> factions; + private Player player; + private Faction faction; + private Guild guild; + private Government government; + private TaxHandler taxes; + private VehicleFeeHandler fees; + + @BeforeEach + void setUp() { + gui = new GuiTestFixture(); + player = gui.player("Leader"); + faction = mock(Faction.class); + guild = mock(Guild.class); + government = mock(Government.class); + when(faction.getId()).thenReturn("realm"); + when(faction.getLeader()).thenReturn("Leader"); + when(faction.isLeader("Leader")).thenReturn(true); + when(faction.getGovernment()).thenReturn(government); + when(government.getFaction()).thenReturn(faction); + when(guild.getId()).thenReturn("guild"); + when(guild.getFaction()).thenReturn(faction); + when(guild.isLeader(player)).thenReturn(true); + taxes = new TaxHandler(faction, 10, 0, 0, 0, 0); + fees = new VehicleFeeHandler(faction); + when(faction.getTaxHandler()).thenReturn(taxes); + when(faction.getVehicleFeeHandler()).thenReturn(fees); + when(faction.hasFactionRule(any(Rules.class))).thenReturn(true); + fees.applyBracket(FeeKind.REGISTRATION_FEE, new Bracket(0, 100)); + factions = mockStatic(FactionManager.class); + factions.when(() -> FactionManager.getByString("realm")).thenReturn(faction); + factions.when(() -> FactionManager.getGuildByString("guild")).thenReturn(guild); + factions.when(() -> FactionManager.getGuildByLeader("Leader")).thenReturn(guild); + manager = new InventoryManager(); + manager.warView = mock(WarView.class); + manager.campaignView = mock(CampaignView.class); + manager.campaignInstallationPickView = mock(CampaignInstallationPickView.class); + manager.campaignRaidLaunchView = mock(CampaignRaidLaunchView.class); + manager.declareWarView = mock(DeclareWarView.class); + manager.factionView = mock(FactionView.class); + manager.guildView = mock(GuildView.class); + manager.companyView = mock(CompanyView.class); + manager.contractView = mock(ContractView.class); + manager.mercenaryMarketView = mock(MercenaryMarketView.class); + manager.lawView = mock(LawView.class); + manager.governmentView = mock(GovernmentView.class); + manager.vehicleFeeView = mock(VehicleFeeView.class); + manager.movementView = mock(MovementView.class); + manager.tierTitleView = mock(TierTitleView.class); + manager.militaryView = mock(MilitaryView.class); + manager.installationView = mock(InstallationView.class); + manager.relationView = mock(RelationView.class); + manager.electionView = mock(ElectionView.class); + manager.taxView = mock(TaxView.class); + manager.loanView = mock(LoanView.class); + } + + @AfterEach + void close() { + try { + if (factions != null) factions.close(); + } finally { + if (gui != null) gui.close(); + } + } + + @Test + void builtButtonsCarryTheirActionAndCopiesKeepIndependentMetadataAndAmounts() { + ItemStack button = manager.createButton("confirm", "action", "original"); + assertEquals(Material.GREEN_CONCRETE, button.getType()); + assertEquals("§cConfirm", button.getItemMeta().getDisplayName()); + assertEquals("original", gui.data(button, "action")); + ItemStack copy = button.clone(); + copy.setAmount(4); + ItemMeta edited = copy.getItemMeta(); + edited.setDisplayName("Edited"); + edited.setLore(List.of("Details")); + edited + .getPersistentDataContainer() + .set(gui.key("action"), PersistentDataType.STRING, "changed"); + assertEquals("original", gui.data(copy, "action"), "getItemMeta returns an independent copy"); + copy.setItemMeta(edited); + assertEquals(1, button.getAmount()); + assertEquals(4, copy.getAmount()); + assertEquals("original", gui.data(button, "action")); + assertEquals("changed", gui.data(copy, "action")); + assertEquals(List.of("Details"), copy.getItemMeta().getLore()); + assertFalse(button.isSimilar(copy)); + assertTrue(button.isSimilar(new ItemStack(button))); + ItemStack cancel = manager.createButton("CaNcEl", "action", "original"); + assertEquals(Material.RED_CONCRETE, cancel.getType()); + assertEquals("§cCancel", cancel.getItemMeta().getDisplayName()); + ItemStack back = manager.createBackButton(SFGUI.GUILD_VIEW); + assertEquals(Material.BARRIER, back.getType()); + assertEquals("§cBack", back.getItemMeta().getDisplayName()); + assertEquals("GUILD_VIEW", gui.data(back, "gui")); + ItemStack filler = manager.getFiller(Material.GRAY_STAINED_GLASS_PANE); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, filler.getType()); + assertEquals("§c", filler.getItemMeta().getDisplayName()); + } + + @Test + void chatIsCancelledImmediatelyButRateMutationRunsOnTheScheduledMainThread() { + manager.setChanging(faction, player, TaxTarget.CITIZENS, null); + AsyncPlayerChatEvent event = chat("25.126"); + manager.setRate(event); + assertTrue(event.isCancelled()); + assertEquals(10, taxes.getCitizenTax()); + assertEquals(1, gui.tasks.size()); + gui.runTasks(); + assertEquals(25.13, taxes.getCitizenTax()); + assertFalse(manager.chatTrigger(player)); + verify(manager.governmentView).governmentView(player, faction, null); + AsyncPlayerChatEvent publicChat = chat("hello"); + manager.setRate(publicChat); + assertFalse(publicChat.isCancelled()); + assertTrue(gui.tasks.isEmpty()); + } + + @Test + void startExpiresEveryPromptAfterThirtyTicksWithoutChangingValues() { + manager.setChanging(faction, player, TaxTarget.CITIZENS, null); + manager.setChangingFee(faction, player, FeeKind.REGISTRATION_FEE, null); + manager.loanPayments.put(player, new LoanPayment(guild, mock(Loan.class))); + manager.setChangingDividend(player, guild); + manager.setChangingDonation(player, guild); + manager.slotChanges.put(player, new SlotChangePrompt("guild", "contract")); + manager.start(); + assertNotNull(manager.getUpdater()); + verify(manager.guildView).setProvinceManager(gui.plugin.getProvinceManager()); + verify(gui.scheduler).runTaskTimer(eq(gui.plugin), any(Runnable.class), eq(0L), eq(20L)); + assertEquals(1, gui.repeatingTasks.size()); + Runnable tick = gui.repeatingTasks.getFirst(); + for (int i = 0; i < 29; i++) tick.run(); + assertTrue(manager.chatTrigger(player)); + assertEquals(1, manager.taxChange.size()); + assertEquals(1, manager.feeChange.size()); + assertTrue(manager.isPayingLoan(player)); + assertEquals(1, manager.dividendChange.size()); + assertEquals(1, manager.donationChange.size()); + assertEquals(1, manager.slotChanges.size()); + tick.run(); + assertFalse(manager.chatTrigger(player)); + for (String message : + List.of( + "Tax change timed out.", + "Fee change timed out.", + "Loan payment timed out.", + "Dividend change timed out.", + "Donation change timed out.", + "Slot change cancelled.")) verify(player).sendMessage("§c" + message); + assertEquals(10, taxes.getCitizenTax()); + verify(guild, never()).setDividendPercent(anyDouble()); + verify(guild, never()).setDonationAmount(anyDouble()); + } + + @Test + void queuedChatDoesNotResurrectAnExpiredPrompt() { + manager.setChanging(faction, player, TaxTarget.CITIZENS, null); + manager.setRate(chat("40")); + manager.start(); + for (int i = 0; i < 30; i++) gui.repeatingTasks.getFirst().run(); + gui.runTasks(); + assertFalse(manager.chatTrigger(player)); + assertEquals(10, taxes.getCitizenTax()); + verifyNoInteractions(manager.governmentView); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void rateOutsideItsCurrentLawBracketCanBeRetried(boolean tax) { + taxes.applyBracket(TaxTarget.CITIZENS, new Bracket(5, 30)); + fees.applyBracket(FeeKind.REGISTRATION_FEE, new Bracket(5, 30)); + if (tax) manager.setChanging(faction, player, TaxTarget.CITIZENS, null); + else manager.setChangingFee(faction, player, FeeKind.REGISTRATION_FEE, null); + for (String invalid : List.of("31", "4")) { + if (tax) manager.taxChat(player, chat(invalid)); + else manager.feeChat(player, chat(invalid)); + assertTrue(manager.chatTrigger(player)); + } + verify(player).sendMessage(startsWith("§cThe maximum")); + verify(player).sendMessage(startsWith("§cThe minimum")); + verifyNoInteractions(manager.governmentView); + if (tax) manager.taxChat(player, chat("20")); + else manager.feeChat(player, chat("20")); + assertFalse(manager.chatTrigger(player)); + assertEquals(20, tax ? taxes.getCitizenTax() : fees.getRate(FeeKind.REGISTRATION_FEE)); + } + + @Test + void deletedFactionPromptsAreDiscardedWithoutSubmitting() { + manager.setChanging(null, player, TaxTarget.CITIZENS, null); + manager.setChangingFee(null, player, FeeKind.REGISTRATION_FEE, null); + manager.taxChat(player, chat("20")); + manager.feeChat(player, chat("20")); + assertFalse(manager.chatTrigger(player)); + verifyNoInteractions(government, manager.governmentView); + } + + @ParameterizedTest + @EnumSource(FeeKind.class) + void feeErrorDescribesTheRightUnit(FeeKind kind) { + manager.setChangingFee(faction, player, kind, "cart"); + manager.feeChat(player, chat("invalid")); + assertTrue(manager.chatTrigger(player)); + verify(player).sendMessage(contains(kind.isPercent() ? "15.5% of upkeep" : "1.5 times upkeep")); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void councilReceivesTheRequestedTargetAndRoundedRateWithoutApplyingIt(boolean tax) { + when(government.hasCouncil()).thenReturn(true); + when(government.canBeProposed(any())).thenReturn(true); + when(government.canPropose(player)).thenReturn(true); + if (tax) { + manager.setChanging(faction, player, TaxTarget.GUILD_ID, "merchants"); + manager.taxChat(player, chat("15.126")); + } else { + manager.setChangingFee(faction, player, FeeKind.REGISTRATION_FEE, "cart"); + manager.feeChat(player, chat("15.126")); + } + ArgumentCaptor<Proposal> captured = ArgumentCaptor.forClass(Proposal.class); + verify(government).propose(captured.capture()); + Proposal proposal = captured.getValue(); + if (tax) { + assertEquals(TaxTarget.GUILD_ID, proposal.getTaxChange().getTarget()); + assertEquals("merchants", proposal.getTaxChange().getId()); + assertEquals(15.13, proposal.getTaxChange().getNewTax()); + } else { + assertEquals(FeeKind.REGISTRATION_FEE, proposal.getFeeChange().getKind()); + assertEquals("cart", proposal.getFeeChange().getVehicleTypeId()); + assertEquals(15.13, proposal.getFeeChange().getNewRate()); + } + assertEquals(10, taxes.getCitizenTax()); + assertEquals(0, fees.getRate(FeeKind.REGISTRATION_FEE)); + assertFalse(manager.chatTrigger(player)); + verify(player).sendTitle("", "§aProposal Added", 20, 80, 20); + verify(manager.governmentView).governmentView(player, faction, null); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void duplicateOrRevokedProposalPermissionDoesNotApplyTheRate(boolean duplicate) { + when(government.hasCouncil()).thenReturn(true); + when(government.canBeProposed(any())).thenReturn(!duplicate); + manager.setChanging(faction, player, TaxTarget.CITIZENS, null); + manager.taxChat(player, chat("20")); + assertFalse(manager.chatTrigger(player)); + assertEquals(10, taxes.getCitizenTax()); + verify(government, never()).propose(any()); + verify(government, never()).startMovement(anyString(), any()); + verify(player) + .sendMessage( + duplicate + ? "§cThere is already a proposal active for this target." + : "§cYou can no longer propose this change."); + verifyNoInteractions(manager.governmentView); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void eligibleMemberAddsToAnExistingMovementOrStartsOne(boolean existing) { + when(government.hasCouncil()).thenReturn(true); + when(government.canBeProposed(any())).thenReturn(true); + when(government.canProposeOrStartMovement(player)).thenReturn(true); + Movement movement = mock(Movement.class); + when(government.getMovementByMember("Leader")).thenReturn(existing ? movement : null); + manager.setChanging(faction, player, TaxTarget.CITIZENS, null); + manager.taxChat(player, chat("20")); + ArgumentCaptor<Proposal> proposal = ArgumentCaptor.forClass(Proposal.class); + if (existing) { + verify(movement).createCause(eq("Leader"), proposal.capture()); + verify(government, never()).startMovement(anyString(), any()); + } else verify(government).startMovement(eq("Leader"), proposal.capture()); + assertEquals(20, proposal.getValue().getTaxChange().getNewTax()); + assertEquals(10, taxes.getCitizenTax()); + assertFalse(manager.chatTrigger(player)); + verify(manager.governmentView).governmentView(player, faction, null); + } + + @Test + void singleProvinceHostCannotStartAGuildMovement() { + when(government.hasCouncil()).thenReturn(true); + when(government.canBeProposed(any())).thenReturn(true); + when(government.canProposeOrStartMovement(player)).thenReturn(true); + when(faction.getProvinces()).thenReturn(List.of(1)); + when(faction.getRelationToFaction("Leader")).thenReturn(Member.GUILD_LEADER); + manager.setChanging(faction, player, TaxTarget.CITIZENS, null); + manager.taxChat(player, chat("20")); + verify(government, never()).startMovement(anyString(), any()); + verify(player).sendMessage(CivilWarCopy.ONE_PROVINCE_HOST_GUILD); + assertEquals(10, taxes.getCitizenTax()); + assertFalse(manager.chatTrigger(player)); + } + + @ParameterizedTest + @CsvSource({"25.126,25.13,174.87,74.87", "150,100,100,0"}) + void loanChatWithdrawsOnlyTheActualRoundedPayment( + String text, double paid, double balance, double owed) { + Loan loan = loan(100, 200); + manager.setPayingLoan(player, loan); + assertTrue(manager.isPayingLoan(player)); + manager.setRate(chat(text)); + assertEquals(200, guild.getBank().getWealth()); + gui.runTasks(); + assertEquals(paid, loan.getPaid()); + assertEquals(balance, guild.getBank().getWealth(), 0.001); + assertEquals(owed, loan.getTotalOwed(), 0.001); + assertFalse(manager.isPayingLoan(player)); + verify(loan.getIssuer().getLedger()).addLoanPaymentEntry("guild", paid); + verify(guild).updateWealth(); + } + + @ParameterizedTest + @ValueSource(strings = {"invalid", "0", "-2", "NaN", "201"}) + void badLoanPaymentPreservesDebtFundsAndPrompt(String text) { + Loan loan = loan(100, 200); + manager.setPayingLoan(player, loan); + Object prompt = manager.loanPayments.get(player); + clearInvocations(player); + manager.loanPaymentChat(player, chat(text)); + assertSame(prompt, manager.loanPayments.get(player)); + assertEquals(200, guild.getBank().getWealth()); + assertEquals(100, loan.getTotalOwed()); + verify(player).sendMessage("§4Type 'cancel' to cancel."); + verifyNoInteractions(loan.getIssuer().getLedger()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void leavingOrChangingGuildAbandonsLoanPayment(boolean left) { + Loan loan = loan(100, 200); + manager.setPayingLoan(player, loan); + Guild other = mock(Guild.class); + when(other.getId()).thenReturn("other"); + factions.when(() -> FactionManager.getGuildByLeader("Leader")).thenReturn(left ? null : other); + manager.loanPaymentChat(player, chat("25")); + assertFalse(manager.isPayingLoan(player)); + assertEquals(100, loan.getTotalOwed()); + assertEquals(200, guild.getBank().getWealth()); + } + + @Test + void loanPromptRequiresALeadingGuildAndCanBeCancelled() { + Loan loan = loan(100, 200); + factions.when(() -> FactionManager.getGuildByLeader("Leader")).thenReturn(null); + manager.setPayingLoan(player, loan); + assertFalse(manager.isPayingLoan(player)); + verify(player, never()).closeInventory(); + factions.when(() -> FactionManager.getGuildByLeader("Leader")).thenReturn(guild); + manager.setPayingLoan(player, loan); + manager.loanPaymentChat(player, chat("CANCEL")); + assertFalse(manager.isPayingLoan(player)); + assertEquals(200, guild.getBank().getWealth()); + verify(manager.loanView).loanMainView(player, guild); + } + + @ParameterizedTest + @ValueSource(strings = {"25.126", "0"}) + void donationChatStoresRoundedDenarsAndDisplaysTheCostOrRemoval(String text) { + AtomicReference<Double> donation = donation(50); + manager.setChangingDonation(player, guild); + clearInvocations(player); + manager.setRate(chat(text)); + assertEquals(50, donation.get()); + gui.runTasks(); + assertEquals(Double.parseDouble(text) == 0 ? 0 : 25.13, donation.get()); + assertFalse(manager.chatTrigger(player)); + verify(player) + .sendMessage( + contains(text.equals("0") ? "Daily donation cleared." : "Daily donation set to")); + verify(manager.guildView).guildView(player, guild); + } + + @ParameterizedTest + @ValueSource(strings = {"bad", "NaN", "Infinity", "-1"}) + void invalidDonationCanBeRetriedWithoutChangingTheAmount(String text) { + AtomicReference<Double> donation = donation(50); + manager.setChangingDonation(player, guild); + Object prompt = manager.donationChange.get(player); + manager.donationChat(player, chat(text)); + assertEquals(50, donation.get()); + assertSame(prompt, manager.donationChange.get(player)); + verify(player).sendMessage("§4Type 'cancel' to cancel."); + verifyNoInteractions(manager.guildView); + } + + @Test + void donationAndDividendNullGuardsDoNotOpenPrompts() { + manager.setChangingDonation(player, null); + manager.setChangingDonation(null, guild); + manager.setChangingDividend(player, null); + manager.setChangingDividend(null, guild); + when(guild.isBase()).thenReturn(true); + manager.setChangingDonation(player, guild); + manager.donationChat(player, chat("20")); + manager.dividendChat(player, chat("20")); + manager.donationChange.put(player, new DonationChange(null)); + manager.dividendChange.put(player, new DividendChange(null)); + manager.donationChat(player, chat("20")); + manager.dividendChat(player, chat("20")); + assertFalse(manager.chatTrigger(player)); + verify(player, never()).closeInventory(); + verify(guild, never()).setDonationAmount(anyDouble()); + verify(guild, never()).setDividendPercent(anyDouble()); + } + + @Test + void donationCancellationLeavesTheExistingGiftAndReturnsToTheGuild() { + AtomicReference<Double> donation = donation(50); + manager.setChangingDonation(player, guild); + manager.donationChat(player, chat("CANCEL")); + assertEquals(50, donation.get()); + assertFalse(manager.chatTrigger(player)); + verify(manager.guildView).guildView(player, guild); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void donationPromptRechecksLeadershipAndBaseGuildStatus(boolean becameBase) { + donation(50); + manager.setChangingDonation(player, guild); + when(guild.isBase()).thenReturn(becameBase); + when(guild.isLeader(player)).thenReturn(becameBase); + manager.donationChat(player, chat("20")); + assertFalse(manager.chatTrigger(player)); + verify(guild, never()).setDonationAmount(anyDouble()); + verify(player).sendMessage("§cOnly the guild leader can set donations."); + } + + @ParameterizedTest + @ValueSource(strings = {"-1", "101"}) + void dividendOutsidePercentRangeCanBeRetried(String text) { + manager.setChangingDividend(player, guild); + Object prompt = manager.dividendChange.get(player); + manager.dividendChat(player, chat(text)); + assertSame(prompt, manager.dividendChange.get(player)); + verify(guild, never()).setDividendPercent(anyDouble()); + verify(player).sendMessage("§cDividend percentage must be between §e0 §cand §e100§c."); + } + + @Test + void dividendPromptRechecksLeadershipBeforeApplying() { + manager.setChangingDividend(player, guild); + when(guild.isLeader(player)).thenReturn(false); + manager.dividendChat(player, chat("20")); + assertFalse(manager.chatTrigger(player)); + verify(guild, never()).setDividendPercent(anyDouble()); + verify(player).sendMessage("§cOnly the guild leader can set dividends."); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void slotChatSubmitsAnIntegerAmendmentAndDisplaysItsResult(boolean accepted) { + MercenaryContract contract = contract(); + ContractHandler handler = guild.getCompany().getContractHandler(); + when(handler.proposeSlots(eq("contract"), eq("Leader"), eq(4), anyLong())) + .thenReturn(new MercenaryResult(accepted, accepted ? "Offer sent" : "Capacity changed")); + manager.beginSlotChange(player, guild, contract); + assertEquals("guild", manager.slotChanges.get(player).getGuildId()); + assertEquals("contract", manager.slotChanges.get(player).getContractId()); + verify(player).sendMessage(contains("slots.")); + manager.setRate(chat(" 4 ")); + verify(handler, never()).proposeSlots(anyString(), anyString(), anyInt(), anyLong()); + gui.runTasks(); + verify(handler).proposeSlots(eq("contract"), eq("Leader"), eq(4), anyLong()); + assertFalse(manager.chatTrigger(player)); + verify(player).sendMessage(accepted ? "§aOffer sent" : "§cCapacity changed"); + verify(manager.contractView).detailView(player, guild, "contract"); + } + + @Test + void slotInputRejectsFractionsThenCanBeCancelledWithoutAnAmendment() { + MercenaryContract contract = contract(); + manager.beginSlotChange(player, guild, contract); + Object prompt = manager.slotChanges.get(player); + manager.slotChat(player, chat("1.5")); + assertSame(prompt, manager.slotChanges.get(player)); + verify(player).sendMessage("§cEnter a whole number of slots, such as §e4§c."); + manager.slotChat(player, chat("cancel")); + assertFalse(manager.chatTrigger(player)); + verify(guild.getCompany().getContractHandler(), never()) + .proposeSlots(anyString(), anyString(), anyInt(), anyLong()); + verify(manager.contractView).detailView(player, guild, "contract"); + } + + @ParameterizedTest + @ValueSource(strings = {"guild", "company", "contract", "leader"}) + void slotPromptRechecksItsOwnerContractAndLeader(String removed) { + MercenaryContract contract = contract(); + MercenaryCompany company = guild.getCompany(); + ContractHandler handler = company.getContractHandler(); + manager.beginSlotChange(player, guild, contract); + switch (removed) { + case "guild" -> + factions.when(() -> FactionManager.getGuildByString("guild")).thenReturn(null); + case "company" -> when(guild.getCompany()).thenReturn(null); + case "contract" -> when(handler.getById("contract")).thenReturn(null); + case "leader" -> when(company.isLeader("Leader")).thenReturn(false); + } + manager.slotChat(player, chat("4")); + assertFalse(manager.chatTrigger(player)); + verify(handler, never()).proposeSlots(anyString(), anyString(), anyInt(), anyLong()); + verify(player) + .sendMessage( + removed.equals("leader") + ? "§cOnly the guild leader can change a contract's slots." + : "§cThat contract no longer exists."); + } + + @Test + void incompleteSlotPromptsDoNotCloseTheCurrentInventory() { + MercenaryContract contract = mock(MercenaryContract.class); + manager.beginSlotChange(null, guild, contract); + manager.beginSlotChange(player, null, contract); + manager.beginSlotChange(player, guild, null); + manager.beginSlotChange(player, guild, contract); + manager.slotChat(player, chat("4")); + assertFalse(manager.chatTrigger(player)); + verify(player, never()).closeInventory(); + } + + @Test + void scheduledDividendInputUsesTheCurrentLeaderAndClearsItsPrompt() { + manager.setChangingDividend(player, guild); + manager.setRate(chat("20")); + verify(guild, never()).setDividendPercent(anyDouble()); + gui.runTasks(); + verify(guild).setDividendPercent(20); + assertFalse(manager.chatTrigger(player)); + verify(manager.guildView).guildView(player, guild); + } + + @Test + void scheduledFeeInputAppliesToItsRequestedVehicleType() { + manager.setChangingFee(faction, player, FeeKind.REGISTRATION_FEE, "cart"); + manager.setRate(chat("2.5")); + assertFalse(fees.hasTypeRate(FeeKind.REGISTRATION_FEE, "cart")); + gui.runTasks(); + assertEquals(2.5, fees.getRate(FeeKind.REGISTRATION_FEE, "cart")); + assertEquals(0, fees.getRate(FeeKind.REGISTRATION_FEE)); + assertFalse(manager.chatTrigger(player)); + } + + @Test + void quittingClearsCompanyAndSlotConfirmationsWithoutClearingAnotherPlayersState() { + Player other = gui.player("Other"); + manager.pendingCompanyFounds.put(player, "Company"); + manager.confirming.put(player, faction); + manager.slotChanges.put(player, new SlotChangePrompt("guild", "contract")); + manager.pendingCompanyFounds.put(other, "Other Company"); + manager.confirming.put(other, faction); + manager.clearPendingOnQuit(new PlayerQuitEvent(player, "quit")); + assertFalse(manager.pendingCompanyFounds.containsKey(player)); + assertFalse(manager.confirming.containsKey(player)); + assertFalse(manager.slotChanges.containsKey(player)); + assertEquals("Other Company", manager.pendingCompanyFounds.get(other)); + assertSame(faction, manager.confirming.get(other)); + manager.confirming.put(player, faction); + manager.clearPendingOnQuit(new PlayerQuitEvent(player, "quit")); + assertSame( + faction, + manager.confirming.get(player), + "Other confirmation types retain their own lifecycle"); + } + + @Test + void publicRealmNavigationPreservesTheInventoryAndDomainArguments() { + Inventory inventory = gui.inventory(null, 54, "Menu"); + Movement movement = mock(Movement.class); + Cause cause = mock(Cause.class); + Tier tier = mock(Tier.class); + manager.factionList(player); + manager.factionView(player, faction); + manager.lawView(player, faction, inventory); + manager.governmentView(player, faction, inventory); + manager.proposalView(player, faction, inventory); + manager.movementView(player, faction, movement, inventory); + manager.movementListView(player, faction, inventory); + manager.causesView(player, faction, movement, inventory); + manager.causeView(player, faction, movement, cause, inventory); + manager.tierView(inventory, player, faction, true); + manager.titleView(inventory, player, faction, false); + manager.titleTypeView(inventory, player, faction, tier, true, 3); + manager.militaryView(inventory, player, faction, false); + manager.installationsView(inventory, player, faction, true); + manager.installationDetailView(player, faction, "fort"); + manager.installationDetailView(player, faction, "fort", inventory); + manager.diplomacyView(inventory, player, faction, true); + manager.diplomacyListView(inventory, player, faction, false); + manager.attitudeView(inventory, player, faction, true); + manager.relationView(inventory, player, faction, false); + manager.electionView(player, faction); + manager.taxView(player, faction); + verify(manager.factionView).factionList(player); + verify(manager.factionView).factionView(player, faction); + verify(manager.lawView).lawView(player, faction, inventory); + verify(manager.governmentView).governmentView(player, faction, inventory); + verify(manager.governmentView).proposalView(player, faction, inventory); + verify(manager.movementView).movementView(player, faction, movement, inventory); + verify(manager.movementView).movementListView(player, faction, inventory); + verify(manager.movementView).causesView(player, faction, movement, inventory); + verify(manager.movementView).causeView(player, faction, movement, cause, inventory); + verify(manager.tierTitleView).tierView(inventory, player, faction, true); + verify(manager.tierTitleView).titleView(inventory, player, faction, false); + verify(manager.tierTitleView).titleTypeView(inventory, player, faction, tier, true, 3); + verify(manager.militaryView).militaryView(inventory, player, faction, false); + verify(manager.installationView).installationsView(inventory, player, faction, true); + verify(manager.installationView).installationDetailView(player, faction, "fort"); + verify(manager.installationView).installationDetailView(player, faction, "fort", inventory); + verify(manager.relationView).diplomacyView(inventory, player, faction, true); + verify(manager.relationView).diplomacyListView(inventory, player, faction, false); + verify(manager.relationView).attitudeView(inventory, player, faction, true); + verify(manager.relationView).relationView(inventory, player, faction, false); + verify(manager.electionView).electionView(player, faction); + verify(manager.taxView).taxView(player, faction); + } + + @Test + void publicGuildNavigationPreservesTheSelectedGuildContractAndLoan() { + Inventory inventory = gui.inventory(null, 54, "Guild"); + Loan loan = mock(Loan.class); + manager.guildList(player); + manager.guildView(player, guild); + manager.guildView(player, guild, inventory); + manager.upgradeView(player, guild); + manager.upgradeView(player, guild, inventory); + manager.ledgerView(player, guild, inventory); + manager.companyView(player, guild); + manager.companyView(player, guild, inventory); + manager.companySlotsView(player, guild, inventory); + manager.companyRosterView(player, guild, inventory); + manager.companyUpgradeView(player, guild, inventory); + manager.contractListView(player, guild); + manager.contractListView(player, guild, inventory); + manager.contractDetailView(player, guild, "contract"); + manager.contractDetailView(player, guild, inventory, "contract"); + manager.mercenaryMarketList(player); + manager.mercenaryMarketList(player, inventory); + manager.loanMainView(player, guild); + manager.loanMainView(player, guild, inventory); + manager.loansGivenView(player, guild); + manager.loansGivenView(player, guild, inventory); + manager.loansTakenView(player, guild); + manager.loansTakenView(player, guild, inventory); + manager.loanDetailView(player, guild, loan, true, inventory); + verify(manager.guildView).guildList(player); + verify(manager.guildView).guildView(player, guild); + verify(manager.guildView).guildView(player, guild, inventory); + verify(manager.guildView).upgradeView(player, guild); + verify(manager.guildView).upgradeView(player, guild, inventory); + verify(manager.guildView).ledgerView(player, guild, inventory); + verify(manager.companyView).companyView(player, guild); + verify(manager.companyView).companyView(player, guild, inventory); + verify(manager.companyView).slotsView(player, guild, inventory); + verify(manager.companyView).rosterView(player, guild, inventory); + verify(manager.companyView).companyUpgradeView(player, guild, inventory); + verify(manager.contractView).listView(player, guild); + verify(manager.contractView).listView(player, guild, inventory); + verify(manager.contractView).detailView(player, guild, "contract"); + verify(manager.contractView).detailView(player, guild, inventory, "contract"); + verify(manager.mercenaryMarketView).marketList(player); + verify(manager.mercenaryMarketView).marketList(player, inventory); + verify(manager.loanView).loanMainView(player, guild); + verify(manager.loanView).loanMainView(player, guild, inventory); + verify(manager.loanView).loansGivenView(player, guild); + verify(manager.loanView).loansGivenView(player, guild, inventory); + verify(manager.loanView).loansTakenView(player, guild); + verify(manager.loanView).loansTakenView(player, guild, inventory); + verify(manager.loanView).loanDetailView(player, guild, loan, true, inventory); + } + + @Test + void publicWarNavigationPreservesWarParticipantAndPickerTargets() { + Inventory inventory = gui.inventory(null, 54, "War"); + War war = mock(War.class); + Participant participant = mock(Participant.class); + Faction defender = mock(Faction.class); + manager.warList(player); + manager.warList(player, inventory); + manager.populateWarList(inventory); + manager.warView(inventory, player, war, false); + manager.participantView(inventory, player, war, participant, true); + manager.openDeclareWarGoalPicker(player, faction, defender); + verify(manager.warView).warList(player); + verify(manager.warView).warList(player, inventory); + verify(manager.warView).populateWarList(inventory); + verify(manager.warView).warView(inventory, player, war, false); + verify(manager.warView).participantView(inventory, player, war, participant, true); + verify(manager.declareWarView).openGoalPicker(player, faction, defender); + manager.openCampaignView(player, null); + manager.openCampaignView(player, war); + verify(player, times(2)).sendMessage("§cWar not found."); + verifyNoInteractions(manager.campaignView); + when(war.isActive()).thenReturn(true); + try (MockedStatic<WhitePeaceService> peace = mockStatic(WhitePeaceService.class); + MockedStatic<WarManager> wars = mockStatic(WarManager.class)) { + manager.openCampaignView(player, war); + peace.verify(() -> WhitePeaceService.recalculateProposals(war)); + wars.verify(() -> WarManager.persist(war)); + verify(manager.campaignView).campaignView(player, war, true); + } + } + + @ParameterizedTest + @CsvSource({ + "FACTION_LIST,FACTION", + "FACTION_VIEW,FACTION", + "FACTION_GUILDS,FACTION", + "GUILD_LIST,GUILD", + "GUILD_VIEW,GUILD", + "UPGRADE_VIEW,GUILD", + "COMPANY_VIEW,COMPANY", + "COMPANY_SLOTS_VIEW,COMPANY", + "COMPANY_ROSTER_VIEW,COMPANY", + "COMPANY_UPGRADE_VIEW,COMPANY", + "CONTRACT_LIST_VIEW,CONTRACT", + "CONTRACT_DETAIL_VIEW,CONTRACT", + "MERCENARY_MARKET_LIST,MARKET", + "LOAN_MAIN_VIEW,LOAN", + "LOANS_GIVEN_VIEW,LOAN", + "LOANS_TAKEN_VIEW,LOAN", + "TAKEN_LOAN_DETAIL_VIEW,LOAN", + "ISSUED_LOAN_DETAIL_VIEW,LOAN", + "LAW_VIEW,LAW", + "LAW_SELECT,LAW", + "MILITARY_VIEW,MILITARY", + "INSTALLATIONS_VIEW,INSTALLATION", + "INSTALLATION_DETAIL_VIEW,INSTALLATION", + "GOVERNMENT_VIEW,GOVERNMENT", + "PROPOSAL_VIEW,GOVERNMENT", + "PROPOSALS,GOVERNMENT", + "LAW_PROPOSAL_VIEW,GOVERNMENT", + "LAW_PROPOSAL_SELECT,GOVERNMENT", + "TAX_PROPOSAL_VIEW,GOVERNMENT", + "SPECIFIC_TAX_PROPOSAL_VIEW,GOVERNMENT", + "POLITICAL_PROPOSAL_VIEW,GOVERNMENT", + "WAR_PEACE_SELECT,GOVERNMENT", + "COUNCIL_VIEW,GOVERNMENT", + "COUNCIL_SELECT,GOVERNMENT", + "FAVOUR_REPRESS_MAIN,GOVERNMENT", + "FAVOUR_REPRESS_TYPE,GOVERNMENT", + "FAVOUR_REPRESS_SELECT,GOVERNMENT", + "FEE_PROPOSAL_VIEW,FEE", + "FEE_CATEGORY_VIEW,FEE", + "FEE_VEHICLE_VIEW,FEE", + "MOVEMENT_VIEW,MOVEMENT", + "MOVEMENT_LIST,MOVEMENT", + "CAUSES_VIEW,MOVEMENT", + "CAUSE_VIEW,MOVEMENT", + "MOVEMENT_DEMANDS,MOVEMENT", + "MOVEMENT_CRACKDOWN,MOVEMENT", + "TARGET_SELECT,MOVEMENT", + "DIPLOMACY_VIEW,RELATION", + "DIPLOMACY_LIST,RELATION", + "ATTITUDE_VIEW,RELATION", + "RELATION_VIEW,RELATION", + "TRADE_AGREEMENT_VIEW,RELATION", + "TREATY_VIEW,RELATION", + "ELECTION_VIEW,ELECTION", + "ELECTION_VOTING_VIEW,ELECTION", + "TIER_VIEW,TITLE", + "TITLE_VIEW,TITLE", + "TITLE_TYPE_VIEW,TITLE", + "WAR_LIST,WAR", + "TAX_VIEW,TAX", + "TAX_VIEW_SPECIFIC,TAX" + }) + void topMenuButtonsReachExactlyTheirOwningView(SFGUI type, String route) { + Inventory inventory = open(type, false); + inventory.setItem(4, new ItemStack(Material.PAPER)); + InventoryClickEvent click = gui.click(player, 4); + manager.clickButton(click); + assertTrue(click.isCancelled()); + switch (route) { + case "FACTION" -> verify(manager.factionView).click(click, inventory, player); + case "GUILD" -> verify(manager.guildView).click(click, inventory, player); + case "COMPANY" -> verify(manager.companyView).click(click, inventory, player); + case "CONTRACT" -> verify(manager.contractView).click(click, inventory, player); + case "MARKET" -> verify(manager.mercenaryMarketView).click(click, inventory, player); + case "LOAN" -> verify(manager.loanView).click(click, inventory, player); + case "LAW" -> verify(manager.lawView).click(click, inventory, player); + case "MILITARY" -> verify(manager.militaryView).click(click, inventory, player); + case "INSTALLATION" -> verify(manager.installationView).click(click, inventory, player); + case "GOVERNMENT" -> verify(manager.governmentView).click(click, inventory, player); + case "FEE" -> verify(manager.vehicleFeeView).click(click, inventory, player); + case "MOVEMENT" -> verify(manager.movementView).click(click, inventory, player); + case "RELATION" -> verify(manager.relationView).click(click, inventory, player); + case "ELECTION" -> verify(manager.electionView).click(click, inventory, player); + case "TITLE" -> verify(manager.tierTitleView).click(click, inventory, player); + case "WAR" -> verify(manager.warView).click(click, inventory, player); + case "TAX" -> verify(manager.taxView).click(click, inventory, player); + default -> fail("Unspecified owning view: " + route); + } + verifyNoMoreInteractions(views()); + } + + @ParameterizedTest + @MethodSource("warHolders") + void warHolderButtonsAreRoutedToTheirSpecificView(InventoryHolder holder, String route) { + Inventory inventory = gui.inventory(holder, 54, "War menu"); + inventory.setItem(0, new ItemStack(Material.PAPER)); + player.openInventory(inventory); + InventoryClickEvent click = gui.click(player, 0); + manager.clickButton(click); + assertTrue(click.isCancelled()); + switch (route) { + case "war" -> verify(manager.warView).click(click, inventory, player); + case "campaign" -> verify(manager.campaignView).click(click, inventory, player); + case "installation" -> + verify(manager.campaignInstallationPickView).click(click, inventory, player); + case "raid" -> verify(manager.campaignRaidLaunchView).click(click, inventory, player); + case "declare" -> verify(manager.declareWarView).click(click, inventory, player); + default -> fail(route); + } + verifyNoMoreInteractions(views()); + } + + static Stream<Arguments> warHolders() { + return Stream.of( + Arguments.of(new WarInventoryHolder(7, SFGUI.WAR_VIEW), "war"), + Arguments.of(new WarInventoryHolder(7, SFGUI.WAR_COUNTER_GOAL), "war"), + Arguments.of(new CampaignInventoryHolder(7, SFGUI.CAMPAIGN_VIEW), "campaign"), + Arguments.of( + new CampaignInventoryHolder(7, SFGUI.CAMPAIGN_INSTALLATION_PICK_VIEW), "installation"), + Arguments.of(new CampaignRaidLaunchHolder(7, "fort"), "raid"), + Arguments.of(new SFCombinedInventoryHolder(7, "realm", SFGUI.PARTICIPANT_VIEW), "war"), + Arguments.of( + new SFCombinedInventoryHolder(7, "realm", SFGUI.MERCENARY_ENGAGEMENT_LIST), "war"), + Arguments.of(new DeclareWarHolder("realm", "enemy", SFGUI.WAR_DECLARE_GOAL), "declare")); + } + + @ParameterizedTest + @ValueSource(ints = {-999, 54, 70}) + void playerInventoryAndOutsideClicksCannotDispatchFactionMenuButtons(int rawSlot) { + open(SFGUI.FACTION_VIEW, false); + player.getInventory().setItem(0, new ItemStack(Material.PAPER)); + InventoryClickEvent click = gui.click(player, rawSlot); + manager.clickButton(click); + assertTrue(click.isCancelled()); + verifyNoInteractions(views()); + } + + @Test + void emptySlotsAndReadOnlyPlayerLedgersCannotDispatchActions() { + open(SFGUI.FACTION_VIEW, false); + InventoryClickEvent empty = gui.click(player, 0); + manager.clickButton(empty); + assertTrue(empty.isCancelled()); + Inventory ledger = open(SFGUI.PLAYER_LEDGER_VIEW, false); + ledger.setItem(0, new ItemStack(Material.PAPER)); + InventoryClickEvent readOnly = gui.click(player, 0); + manager.clickButton(readOnly); + assertTrue(readOnly.isCancelled()); + verifyNoInteractions(views()); + } + + @Test + void missingPrivateMenuOwnerClosesTheInventoryBeforeDispatch() { + Inventory inventory = + gui.inventory(new SFInventoryHolder("deleted", SFGUI.MILITARY_VIEW), 54, "Military"); + inventory.setItem(0, new ItemStack(Material.PAPER)); + player.openInventory(inventory); + InventoryClickEvent click = gui.click(player, 0); + manager.clickButton(click); + assertTrue(click.isCancelled()); + verify(player).closeInventory(); + verifyNoInteractions(views()); + } + + @ParameterizedTest + @CsvSource({ + "ATTITUDE_VIEW,DIPLOMACY", + "DIPLOMACY_LIST,FACTION", + "DIPLOMACY_VIEW,FACTION", + "FACTION_VIEW,FACTION_LIST", + "FACTION_GUILDS,FACTION", + "GUILD_VIEW,GUILD_LIST", + "MILITARY_VIEW,FACTION", + "INSTALLATIONS_VIEW,FACTION", + "INSTALLATION_DETAIL_VIEW,INSTALLATIONS", + "RELATION_VIEW,DIPLOMACY", + "TRADE_AGREEMENT_VIEW,DIPLOMACY", + "TREATY_VIEW,DIPLOMACY", + "TIER_VIEW,FACTION", + "TITLE_VIEW,FACTION", + "TITLE_TYPE_VIEW,TITLE", + "LAW_VIEW,FACTION", + "LAW_SELECT,LAW", + "GOVERNMENT_VIEW,FACTION", + "STABILITY_VIEW,GOVERNMENT", + "PROPOSAL_VIEW,GOVERNMENT", + "POLITICAL_PROPOSAL_VIEW,PROPOSAL", + "WAR_PEACE_SELECT,POLITICAL", + "LAW_PROPOSAL_VIEW,PROPOSAL", + "LAW_PROPOSAL_SELECT,LAW_PROPOSAL", + "TAX_PROPOSAL_VIEW,PROPOSAL", + "SPECIFIC_TAX_PROPOSAL_VIEW,TAX_PROPOSAL", + "FEE_PROPOSAL_VIEW,FEE", + "FEE_CATEGORY_VIEW,FEE", + "FEE_VEHICLE_VIEW,FEE", + "PROPOSALS,GOVERNMENT", + "COUNCIL_VIEW,GOVERNMENT", + "COUNCIL_SELECT,COUNCIL", + "MOVEMENT_VIEW,MOVEMENTS", + "MOVEMENT_LIST,GOVERNMENT", + "CAUSES_VIEW,MOVEMENT", + "CAUSE_VIEW,CAUSES", + "LEDGER_VIEW,FACTION", + "TAX_VIEW_SPECIFIC,TAX", + "TAX_VIEW,FACTION", + "UPGRADE_VIEW,GUILD", + "COMPANY_VIEW,GUILD", + "COMPANY_SLOTS_VIEW,COMPANY", + "COMPANY_ROSTER_VIEW,COMPANY", + "COMPANY_UPGRADE_VIEW,COMPANY", + "CONTRACT_LIST_VIEW,COMPANY", + "CONTRACT_DETAIL_VIEW,CONTRACTS", + "LOAN_MAIN_VIEW,GUILD", + "LOANS_GIVEN_VIEW,LOANS", + "LOANS_TAKEN_VIEW,LOANS", + "ISSUED_LOAN_DETAIL_VIEW,GIVEN", + "TAKEN_LOAN_DETAIL_VIEW,TAKEN", + "FAVOUR_REPRESS_SELECT,FAVOUR_TYPE", + "FAVOUR_REPRESS_TYPE,FAVOUR_MAIN", + "FAVOUR_REPRESS_MAIN,GOVERNMENT" + }) + void backButtonsReturnToTheirDocumentedParentMenu(SFGUI type, String destination) { + Movement movement = mock(Movement.class); + when(movement.getFaction()).thenReturn(faction); + factions.when(() -> FactionManager.getMovementById("realm")).thenReturn(movement); + Inventory inventory = open(type, false); + inventory.setItem(53, manager.createBackButton(type)); + InventoryClickEvent click = gui.click(player, 53); + manager.clickButton(click); + assertTrue(click.isCancelled()); + switch (destination) { + case "DIPLOMACY" -> verify(manager.relationView).diplomacyView(null, player, faction, true); + case "FACTION" -> verify(manager.factionView).factionView(player, faction); + case "FACTION_LIST" -> verify(manager.factionView).factionList(player); + case "GUILD_LIST" -> verify(manager.guildView).guildList(player); + case "INSTALLATIONS" -> + verify(manager.installationView).installationsView(null, player, faction, true); + case "TITLE" -> verify(manager.tierTitleView).titleView(null, player, faction, true); + case "LAW" -> verify(manager.lawView).lawView(player, faction, null); + case "GOVERNMENT" -> verify(manager.governmentView).governmentView(player, faction, null); + case "PROPOSAL" -> verify(manager.governmentView).proposalView(player, faction, null); + case "POLITICAL" -> + verify(manager.governmentView).politicalProposalView(player, faction, null); + case "LAW_PROPOSAL" -> verify(manager.governmentView).lawProposalView(player, faction, null); + case "TAX_PROPOSAL" -> verify(manager.governmentView).taxProposalView(player, faction, null); + case "FEE" -> + verify(manager.vehicleFeeView) + .back(player, faction, (SFInventoryHolder) inventory.getHolder()); + case "COUNCIL" -> verify(manager.governmentView).councilView(player, faction, null); + case "MOVEMENTS" -> verify(manager.movementView).movementListView(player, faction, null); + case "MOVEMENT" -> verify(manager.movementView).movementView(player, faction, movement, null); + case "CAUSES" -> verify(manager.movementView).causesView(player, faction, movement, null); + case "TAX" -> verify(manager.taxView).taxView(player, faction); + case "GUILD" -> verify(manager.guildView).guildView(player, guild); + case "COMPANY" -> verify(manager.companyView).companyView(player, guild); + case "CONTRACTS" -> verify(manager.contractView).listView(player, guild); + case "LOANS" -> verify(manager.loanView).loanMainView(player, guild); + case "GIVEN" -> verify(manager.loanView).loansGivenView(player, guild); + case "TAKEN" -> verify(manager.loanView).loansTakenView(player, guild); + case "FAVOUR_TYPE" -> + verify(manager.governmentView).favourRepressTypeView(player, faction, false, null); + case "FAVOUR_MAIN" -> + verify(manager.governmentView).favourRepressMainView(player, faction, null); + default -> fail(destination); + } + verify(player).playSound(player, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); + } + + @Test + void guildAndLedgerBackButtonsRetainTheirEntryContext() { + Inventory guildMenu = open(SFGUI.GUILD_VIEW, true); + guildMenu.setItem(53, manager.createBackButton(SFGUI.GUILD_VIEW)); + manager.clickButton(gui.click(player, 53)); + verify(manager.factionView).factionGuildsView(player, faction, null); + Inventory ledger = open(SFGUI.LEDGER_VIEW, true); + ledger.setItem(53, manager.createBackButton(SFGUI.LEDGER_VIEW)); + manager.clickButton(gui.click(player, 53)); + verify(manager.guildView).guildView(player, guild); + factions.when(() -> FactionManager.getByMember("Leader")).thenReturn(faction); + Inventory diplomacy = open(SFGUI.DIPLOMACY_VIEW, false); + diplomacy.setItem(53, manager.createBackButton(SFGUI.DIPLOMACY_VIEW)); + manager.clickButton(gui.click(player, 53)); + verify(manager.relationView).diplomacyListView(null, player, faction, true); + } + + @Test + void ledgerClosesIfItsGuildLosesTheFactionAfterTheAccessSnapshot() { + Guild accessSnapshot = mock(Guild.class); + when(accessSnapshot.getFaction()).thenReturn(faction); + // Navigation performs a fresh registry lookup after the access check. Model an orphaned + // replacement between those lookups, without bypassing the actual permission predicate. + factions.when(() -> FactionManager.getGuildByString("guild")).thenReturn(accessSnapshot, guild); + when(guild.getFaction()).thenReturn(null); + Inventory inventory = open(SFGUI.LEDGER_VIEW, false); + inventory.setItem(53, manager.createBackButton(SFGUI.LEDGER_VIEW)); + InventoryClickEvent click = gui.click(player, 53); + manager.clickButton(click); + assertTrue(click.isCancelled()); + verify(player).closeInventory(); + verifyNoInteractions(manager.factionView, manager.guildView); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void politicalMovementPickerReturnsToItsCauseOrSurvivingGovernment(boolean survivingCause) { + Movement movement = mock(Movement.class); + Cause cause = mock(Cause.class); + when(government.getMovementByMember("Leader")).thenReturn(movement); + when(movement.getCauses()).thenReturn(survivingCause ? List.of(cause) : List.of()); + Inventory inventory = open(SFGUI.WAR_PEACE_SELECT, true); + when(cause.getMovement()).thenReturn(movement); + when(manager.governmentView.displayedWarPeaceCause(player, faction, inventory)) + .thenReturn(survivingCause ? cause : null); + inventory.setItem(53, manager.createBackButton(SFGUI.WAR_PEACE_SELECT)); + manager.clickButton(gui.click(player, 53)); + if (survivingCause) + verify(manager.movementView).causeView(player, faction, movement, cause, null); + else verify(manager.governmentView).governmentView(player, faction, null); + } + + @ParameterizedTest + @CsvSource({"true,25,2", "false,0,0"}) + void capitalConfirmationDisplaysCostBalanceAndLosses(boolean rename, double cost, int losses) { + when(faction.getBank()).thenReturn(new Bank(guild, 100, mock(Chunk.class))); + manager.confirmCapitalMoveView(player, faction, rename, cost, losses); + Inventory inventory = player.getOpenInventory().getTopInventory(); + assertEquals("§7Confirm Action", player.getOpenInventory().getTitle()); + assertEquals(27, inventory.getSize()); + ItemMeta info = inventory.getItem(13).getItemMeta(); + assertEquals( + rename ? "§eRename faction capital?" : "§eMove faction capital?", info.getDisplayName()); + assertTrue(info.getLore().stream().anyMatch(line -> line.contains(cost > 0 ? "25" : "Free"))); + if (cost > 0) + assertTrue( + info.getLore().stream() + .anyMatch(line -> line.contains("Faction bank:") && line.contains("100"))); + if (losses > 0) assertTrue(info.getLore().contains("§cYou will lose 2 provinces")); + assertEquals("1", gui.data(inventory.getItem(11), "setcapital")); + assertEquals(Material.RED_CONCRETE, inventory.getItem(15).getType()); + } + + @Test + void capitalConfirmationOmitsUnknownBankBalance() { + manager.confirmCapitalMoveView(player, faction, false, 50, 0); + List<String> lore = + player.getOpenInventory().getTopInventory().getItem(13).getItemMeta().getLore(); + assertEquals(List.of("§7Cost: §e50.00d §7from the faction bank"), lore); + } + + @ParameterizedTest + @EnumSource(QueueCancelPayload.Type.class) + void queueConfirmationCarriesTheOriginalOwnerAndCancelsExactlyThatEntry( + QueueCancelPayload.Type type) { + QueueState queue = queue(type, true); + manager.openQueueCancelConfirm(player, faction, queue.payload(), "Cancel queue entry"); + Inventory inventory = player.getOpenInventory().getTopInventory(); + assertEquals("Cancel queue entry", inventory.getItem(13).getItemMeta().getDisplayName()); + assertEquals(queue.payload(), gui.data(inventory.getItem(11), "queue_cancel")); + assertEquals(queue.payload(), gui.data(inventory.getItem(15), "queue_cancel")); + assertSame(faction, manager.confirming.get(player)); + manager.clickButton(gui.click(player, 11)); + queue.verifyCancellation(); + verify(player).sendMessage("§aQueue item cancelled."); + assertFalse(manager.confirming.containsKey(player)); + verifyQueueParent(type); + } + + @ParameterizedTest + @EnumSource(QueueCancelPayload.Type.class) + void cancellingQueueConfirmationKeepsTheQueueAndReturnsToItsOwner(QueueCancelPayload.Type type) { + QueueState queue = queue(type, true); + manager.openQueueCancelConfirm(player, faction, queue.payload(), "Cancel queue entry"); + manager.clickButton(gui.click(player, 15)); + queue.verifyUnchanged(); + assertFalse(manager.confirming.containsKey(player)); + verifyQueueParent(type); + verify(player, never()).sendMessage("§aQueue item cancelled."); + } + + @ParameterizedTest + @EnumSource(QueueCancelPayload.Type.class) + void aQueueThatChangedWhileTheConfirmationWasOpenReportsFailure(QueueCancelPayload.Type type) { + QueueState queue = queue(type, false); + manager.openQueueCancelConfirm(player, faction, queue.payload(), "Cancel queue entry"); + manager.clickButton(gui.click(player, 11)); + queue.verifyCancellation(); + verify(player).sendMessage("§cCould not cancel queue item."); + assertFalse(manager.confirming.containsKey(player)); + verifyQueueParent(type); + } + + @ParameterizedTest + @EnumSource(QueueCancelPayload.Type.class) + void disappearingQueueOwnerDoesNotCancelOrNavigateToADifferentOwner( + QueueCancelPayload.Type type) { + QueueState queue = queue(type, true); + manager.openQueueCancelConfirm(player, faction, queue.payload(), "Cancel queue entry"); + factions.when(() -> FactionManager.getByString("realm")).thenReturn(null); + factions.when(() -> FactionManager.getGuildByString("guild")).thenReturn(null); + manager.clickButton(gui.click(player, 11)); + queue.verifyUnchanged(); + verify(player).sendMessage("§cCould not cancel queue item."); + assertFalse(manager.confirming.containsKey(player)); + verifyNoInteractions(views()); + } + + @Test + void malformedQueuePayloadIsDiscardedWithoutDomainMutation() { + manager.openQueueCancelConfirm(player, faction, "not a queue", "Cancel queue entry"); + manager.clickButton(gui.click(player, 11)); + assertFalse(manager.confirming.containsKey(player)); + verifyNoInteractions(views()); + verify(faction, never()).getMilitary(); + } + + @ParameterizedTest + @EnumSource(QueueCancelPayload.Type.class) + void queueCancellationRechecksLeadershipBeforeMutation(QueueCancelPayload.Type type) { + QueueState queue = queue(type, true); + manager.openQueueCancelConfirm(player, faction, queue.payload(), "Cancel queue entry"); + when(faction.getLeader()).thenReturn("NewLeader"); + when(faction.isLeader("Leader")).thenReturn(false); + when(guild.isLeader(player)).thenReturn(false); + when(queue.company().isLeader("Leader")).thenReturn(false); + manager.clickButton(gui.click(player, 11)); + queue.verifyUnchanged(); + assertFalse(manager.confirming.containsKey(player)); + } + + @ParameterizedTest + @EnumSource( + value = QueueCancelPayload.Type.class, + names = {"COMPANY_SLOT", "COMPANY_UPGRADE"}) + void disappearingCompanyDoesNotCancelItsFormerQueue(QueueCancelPayload.Type type) { + QueueState queue = queue(type, true); + manager.openQueueCancelConfirm(player, faction, queue.payload(), "Cancel queue entry"); + when(guild.getCompany()).thenReturn(null); + manager.clickButton(gui.click(player, 11)); + queue.verifyUnchanged(); + verify(player).sendMessage("§cCould not cancel queue item."); + assertFalse(manager.confirming.containsKey(player)); + } + + @ParameterizedTest + @ValueSource(ints = {-999, 27}) + void confirmationIgnoresButtonsInThePlayersInventoryOrOutsideIt(int rawSlot) { + manager.confirming.put(player, faction); + manager.confirmView(player, faction, "campaign_push", "7"); + player.getInventory().setItem(0, manager.createButton("confirm", "campaign_push", "7")); + InventoryClickEvent click = gui.click(player, rawSlot); + manager.clickButton(click); + assertEquals(rawSlot >= 0, click.isCancelled()); + verifyNoInteractions(manager.campaignView); + assertSame(faction, manager.confirming.get(player)); + } + + @Test + void unregisteredAndNonActionItemsCannotConfirmAnything() { + manager.confirmView(player, faction, "campaign_push", "7"); + manager.clickButton(gui.click(player, 11)); + assertTrue(manager.confirming.isEmpty()); + manager.confirming.put(player, faction); + Inventory inventory = player.getOpenInventory().getTopInventory(); + inventory.setItem(11, new ItemStack(Material.PAPER)); + manager.clickButton(gui.click(player, 11)); + inventory.setItem(11, manager.getFiller(Material.PAPER)); + manager.clickButton(gui.click(player, 11)); + verifyNoInteractions(views()); + assertSame(faction, manager.confirming.get(player)); + } + + @ParameterizedTest + @MethodSource("campaignActions") + void campaignConfirmationsForwardTheExactActionTargetAndDecision( + String action, boolean confirmed) { + manager.confirming.put(player, faction); + manager.confirmView(player, faction, action, "campaign-7"); + manager.clickButton(gui.click(player, confirmed ? 11 : 15)); + verify(manager.campaignView).handleConfirm(player, action, "campaign-7", confirmed); + verifyNoMoreInteractions(manager.campaignView); + } + + static Stream<Arguments> campaignActions() { + return Stream.of( + "campaign_push", + "campaign_hold", + "campaign_attack", + "campaign_loser_peace", + "campaign_retreat", + "campaign_surrender", + "campaign_accept_peace") + .flatMap(action -> Stream.of(Arguments.of(action, true), Arguments.of(action, false))); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void retreatAndCapitalDecisionsReachTheirOwningConfirmationServices(boolean confirmed) { + manager.confirming.put(player, faction); + manager.confirmBattleRetreatView(player, "battle-7"); + Inventory retreat = player.getOpenInventory().getTopInventory(); + assertEquals("§eRetreat from battle?", retreat.getItem(13).getItemMeta().getDisplayName()); + assertEquals( + List.of("§7Your side will lose the battle.", "§7Casualties already taken will apply."), + retreat.getItem(13).getItemMeta().getLore()); + assertEquals("battle-7", gui.data(retreat.getItem(11), "warband_battle_retreat")); + try (MockedStatic<ConfirmHandler> handler = mockStatic(ConfirmHandler.class)) { + manager.clickButton(gui.click(player, confirmed ? 11 : 15)); + handler.verify(() -> ConfirmHandler.handleConfirm(player, confirmed)); + } + manager.confirmView(player, faction, "setcapital", "1"); + try (MockedStatic<Cache> cache = mockStatic(Cache.class); + MockedStatic<CapitalMovePrompt> capital = mockStatic(CapitalMovePrompt.class)) { + cache.when(() -> Cache.requireProvinces(player)).thenReturn(true); + manager.clickButton(gui.click(player, confirmed ? 11 : 15)); + capital.verify(() -> CapitalMovePrompt.handleConfirm(player, confirmed)); + } + } + + @Test + void disabledProvincesCloseCapitalConfirmationBeforeItCanMoveAnything() { + manager.confirming.put(player, faction); + manager.confirmView(player, faction, "setcapital", "1"); + try (MockedStatic<Cache> cache = mockStatic(Cache.class); + MockedStatic<CapitalMovePrompt> capital = mockStatic(CapitalMovePrompt.class)) { + cache.when(() -> Cache.requireProvinces(player)).thenReturn(false); + manager.clickButton(gui.click(player, 11)); + capital.verifyNoInteractions(); + verify(player).closeInventory(); + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void movementEndingConfirmationKeepsTheMovementIdAndDecision(boolean confirmed) { + Movement movement = mock(Movement.class); + when(movement.getId()).thenReturn("movement"); + when(movement.getFaction()).thenReturn(faction); + when(movement.getSupporters()) + .thenReturn(new net.tfminecraft.simplefactions.government.movement.Pool()); + manager.movementView.creator = new MovementCreator(); + manager.confirming.put(player, faction); + manager.confirmEndMovementView(player, movement); + Inventory inventory = player.getOpenInventory().getTopInventory(); + assertTrue(inventory.getItem(13).getItemMeta().getDisplayName().contains("End this movement?")); + assertEquals("movement", gui.data(inventory.getItem(11), "end_movement")); + manager.clickButton(gui.click(player, confirmed ? 11 : 15)); + verify(manager.movementView).handleEndConfirm(player, "movement", confirmed); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void regimentSizeChangeRequiresConfirmationAndKeepsTheSelectedRegiment(boolean confirmed) { + Military military = mock(Military.class); + Regiment regiment = mock(Regiment.class); + when(faction.getMilitary()).thenReturn(military); + when(military.getRegiment("archers")).thenReturn(regiment); + when(regiment.getName()).thenReturn("Archers"); + manager.confirming.put(player, faction); + manager.confirmView(player, faction, "regiment", "archers"); + manager.clickButton(gui.click(player, confirmed ? 11 : 15)); + verify(regiment, times(confirmed ? 1 : 0)).sizeDecrease(); + if (confirmed) verify(player).sendMessage("§cDecreased size of Archers"); + verify(manager.militaryView).militaryView(null, player, faction, true); + } + + @ParameterizedTest + @ValueSource(strings = {"faction", "leader", "regiment"}) + void staleRegimentConfirmationCannotChangeTheRegiment(String missing) { + Military military = mock(Military.class); + Regiment regiment = mock(Regiment.class); + when(faction.getMilitary()).thenReturn(military); + when(military.getRegiment("archers")).thenReturn(regiment); + manager.confirming.put(player, missing.equals("faction") ? null : faction); + if (missing.equals("leader")) when(faction.getLeader()).thenReturn("NewLeader"); + if (missing.equals("regiment")) when(military.getRegiment("archers")).thenReturn(null); + manager.confirmView(player, faction, "regiment", "archers"); + manager.clickButton(gui.click(player, 11)); + verify(regiment, never()).sizeDecrease(); + verifyNoInteractions(manager.militaryView); + } + + @Test + void allowedDissolutionReturnsToItsSuccessorAndClearsTheConfirmation() { + GuildHandler handler = new GuildHandler(faction); + when(faction.getGuildHandler()).thenReturn(handler); + when(faction.canDissolve()).thenReturn(true); + Faction successor = mock(Faction.class); + List<Faction> subjects = List.of(mock(Faction.class)); + List<Guild> guilds = handler.getGuilds(); + when(faction.getSubjects()).thenReturn(subjects); + when(faction.dissolve(subjects, guilds)).thenReturn(successor); + manager.confirming.put(player, faction); + manager.confirmView(player, faction, "dissolve", "true"); + manager.clickButton(gui.click(player, 11)); + verify(faction).dissolve(subjects, guilds); + assertFalse(manager.confirming.containsKey(player)); + verify(manager.factionView).factionView(player, successor); + } + + @ParameterizedTest + @CsvSource({"true,false,11", "false,true,11", "true,true,15"}) + void cancelledOrIneligibleDissolutionKeepsTheFaction(String payload, boolean eligible, int slot) { + when(faction.canDissolve()).thenReturn(eligible); + manager.confirming.put(player, faction); + manager.confirmView(player, faction, "dissolve", payload); + manager.clickButton(gui.click(player, slot)); + verify(faction, never()).dissolve(anyList(), anyList()); + if (slot == 15) verify(manager.factionView).factionView(player, faction); + else verifyNoInteractions(manager.factionView); + } + + @ParameterizedTest + @CsvSource({ + "installation,true", + "installation,false", + "installation_upgrade,true", + "installation_upgrade,false", + "installation_cancel_upgrade,true", + "installation_cancel_upgrade,false" + }) + void installationConfirmationReportsDomainResultAndReturnsToInstallations( + String action, boolean success) { + InstallationHandler handler = mock(InstallationHandler.class); + when(faction.getInstallationHandler()).thenReturn(handler); + when(handler.deconstruct("fort")) + .thenReturn(new ConstructResult(success, "Deconstruction result")); + when(handler.upgrade("fort")).thenReturn(new ConstructResult(success, "Upgrade result")); + when(handler.cancelPendingUpgrade("fort")).thenReturn(success); + manager.confirming.put(player, faction); + manager.confirmView(player, faction, action, "fort"); + manager.clickButton(gui.click(player, 11)); + switch (action) { + case "installation" -> { + verify(handler).deconstruct("fort"); + verify(player).sendMessage("Deconstruction result"); + } + case "installation_upgrade" -> { + verify(handler).upgrade("fort"); + verify(player).sendMessage("Upgrade result"); + } + case "installation_cancel_upgrade" -> { + verify(handler).cancelPendingUpgrade("fort"); + verify(player) + .sendMessage( + success + ? "§aCancelled upgrade for installation §ffort" + : "§cNo pending upgrade for §ffort"); + } + default -> fail(action); + } + verifyNoMoreInteractions(handler); + verify(manager.installationView).installationsView(null, player, faction, true); + } + + @ParameterizedTest + @ValueSource( + strings = {"installation", "installation_upgrade", "installation_cancel_upgrade", "dissolve"}) + void destructiveConfirmationRechecksLeadershipBeforeMutation(String action) { + InstallationHandler installations = mock(InstallationHandler.class); + when(faction.getInstallationHandler()).thenReturn(installations); + when(installations.deconstruct("fort")).thenReturn(ConstructResult.ok("Removed")); + when(installations.upgrade("fort")).thenReturn(ConstructResult.ok("Upgrade queued")); + when(installations.cancelPendingUpgrade("fort")).thenReturn(true); + when(faction.getGuildHandler()).thenReturn(new GuildHandler(faction)); + when(faction.canDissolve()).thenReturn(true); + manager.confirming.put(player, faction); + manager.confirmView(player, faction, action, action.equals("dissolve") ? "true" : "fort"); + when(faction.getLeader()).thenReturn("NewLeader"); + when(faction.isLeader("Leader")).thenReturn(false); + manager.clickButton(gui.click(player, 11)); + verify(installations, never()).deconstruct(anyString()); + verify(installations, never()).upgrade(anyString()); + verify(installations, never()).cancelPendingUpgrade(anyString()); + verify(faction, never()).dissolve(anyList(), anyList()); + } + + @ParameterizedTest + @CsvSource({ + "installation,false", + "installation,true", + "installation_upgrade,false", + "installation_cancel_upgrade,false" + }) + void cancellingInstallationConfirmationPreservesItAndReturnsToTheEntryPage( + String action, boolean fromCommand) { + InstallationHandler handler = mock(InstallationHandler.class); + when(faction.getInstallationHandler()).thenReturn(handler); + manager.confirming.put(player, faction); + if (fromCommand) manager.installationConfirmFromCommand.put(player, true); + manager.confirmView(player, faction, action, "fort"); + manager.clickButton(gui.click(player, 15)); + verifyNoInteractions(handler); + if (fromCommand) + verify(manager.installationView).installationsView(null, player, faction, true); + else verify(manager.installationView).installationDetailView(player, faction, "fort"); + assertFalse(manager.installationConfirmFromCommand.containsKey(player)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void companyFoundingSubmitsTheOriginalNameAndDisplaysTheServiceResult(boolean success) { + manager.companyView.creator = new CompanyCreator(); + manager.confirmCompanyFoundView(player, guild, "Silver Shields"); + Inventory inventory = player.getOpenInventory().getTopInventory(); + assertTrue(inventory.getItem(13).getItemMeta().getDisplayName().contains("Silver Shields")); + assertEquals("guild", gui.data(inventory.getItem(11), "company_found")); + assertEquals("Silver Shields", manager.pendingCompanyFounds.get(player)); + assertSame(faction, manager.confirming.get(player)); + try (MockedStatic<MercenaryCompanyService> service = + mockStatic(MercenaryCompanyService.class)) { + service + .when(() -> MercenaryCompanyService.requestFormation(guild, "Leader", "Silver Shields")) + .thenReturn( + new MercenaryResult(success, success ? "Charter started" : "Not enough funds")); + manager.clickButton(gui.click(player, 11)); + service.verify( + () -> MercenaryCompanyService.requestFormation(guild, "Leader", "Silver Shields")); + } + assertFalse(manager.pendingCompanyFounds.containsKey(player)); + assertFalse(manager.confirming.containsKey(player)); + verify(player).sendMessage(success ? "§aCharter started" : "§cNot enough funds"); + if (success) verify(manager.companyView).companyView(player, guild); + else verify(player).closeInventory(); + } + + @Test + void cancellingCompanyFoundingDoesNotRequestOrChargeForTheCompany() { + manager.companyView.creator = new CompanyCreator(); + manager.confirmCompanyFoundView(player, guild, "Silver Shields"); + try (MockedStatic<MercenaryCompanyService> service = + mockStatic(MercenaryCompanyService.class)) { + manager.clickButton(gui.click(player, 15)); + service.verifyNoInteractions(); + } + assertFalse(manager.pendingCompanyFounds.containsKey(player)); + assertFalse(manager.confirming.containsKey(player)); + verify(player).sendMessage("§7Company founding cancelled. Nothing was charged."); + verify(player).closeInventory(); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void lostCompanyNameOrGuildDiscardsTheStaleFoundingPrompt(boolean missingGuild) { + manager.companyView.creator = new CompanyCreator(); + manager.confirmCompanyFoundView(player, guild, "Silver Shields"); + if (missingGuild) + factions.when(() -> FactionManager.getGuildByString("guild")).thenReturn(null); + else manager.pendingCompanyFounds.remove(player); + try (MockedStatic<MercenaryCompanyService> service = + mockStatic(MercenaryCompanyService.class)) { + manager.clickButton(gui.click(player, 11)); + service.verifyNoInteractions(); + } + assertFalse(manager.pendingCompanyFounds.containsKey(player)); + assertFalse(manager.confirming.containsKey(player)); + verify(player).closeInventory(); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void warDeclarationConfirmationKeepsItsOriginalRequestAndDecision(boolean confirmed) { + Faction defender = mock(Faction.class); + when(defender.getId()).thenReturn("enemy"); + WarDeclareRequest request = + WarDeclareRequest.of( + faction, defender, net.tfminecraft.simplefactions.war.enums.WarGoalType.WAR); + ItemStack summary = manager.getFiller(Material.IRON_SWORD); + manager.declareWarView.creator = mock(DeclareWarCreator.class); + when(manager.declareWarView.creator.createConfirmSummaryItem(request)).thenReturn(summary); + manager.confirmWarDeclareView(player, request); + Inventory inventory = player.getOpenInventory().getTopInventory(); + assertSame(summary, inventory.getItem(13)); + assertEquals("enemy", gui.data(inventory.getItem(11), "war_declare")); + assertSame(request, manager.pendingWarDeclares.get(player)); + assertSame(faction, manager.confirming.get(player)); + manager.clickButton(gui.click(player, confirmed ? 11 : 15)); + verify(manager.declareWarView).handleConfirm(player, request, confirmed); + assertFalse(manager.pendingWarDeclares.containsKey(player)); + assertFalse(manager.confirming.containsKey(player)); + } + + @Test + void missingWarRequestIsDiscardedWithoutDeclaringWar() { + manager.confirming.put(player, faction); + manager.confirmView(player, faction, "war_declare", "enemy"); + manager.clickButton(gui.click(player, 11)); + assertFalse(manager.confirming.containsKey(player)); + verifyNoInteractions(manager.declareWarView); + } + + @Test + void espionageMenuAndReportedInformationUseTheirRestrictedDispatchers() { + Inventory inventory = open(SFGUI.SPECIAL_POSITIONS, false); + inventory.setItem(0, new ItemStack(Material.PAPER)); + InventoryClickEvent click = gui.click(player, 0); + SFInventoryHolder holder = (SFInventoryHolder) inventory.getHolder(); + try (MockedStatic<EspionageView> espionage = mockStatic(EspionageView.class)) { + espionage.when(() -> EspionageView.handles(SFGUI.SPECIAL_POSITIONS)).thenReturn(true); + manager.clickButton(click); + espionage.verify(() -> EspionageView.click(click, player, holder, manager)); + } + assertTrue(click.isCancelled()); + inventory = open(SFGUI.MILITARY_VIEW, false); + inventory.setItem(0, new ItemStack(Material.PAPER)); + SFInventoryHolder reportedHolder = (SFInventoryHolder) inventory.getHolder(); + reportedHolder.markReported(); + InventoryClickEvent reportedClick = gui.click(player, 0); + try (MockedStatic<net.tfminecraft.simplefactions.espionage.EspionageService> access = + mockStatic(net.tfminecraft.simplefactions.espionage.EspionageService.class); + MockedStatic<ReportedMenus> reports = mockStatic(ReportedMenus.class)) { + manager.clickButton(reportedClick); + reports.verify(() -> ReportedMenus.navigate(reportedClick, player, reportedHolder, manager)); + } + assertTrue(reportedClick.isCancelled()); + verifyNoInteractions(manager.militaryView); + } + + private record QueueState( + QueueCancelPayload.Type type, + String payload, + Military military, + Guild guild, + InstallationHandler installations, + MercenaryCompany company) { + void verifyCancellation() { + switch (type) { + case MILITARY -> verify(military).cancelQueue(2); + case GUILD_UPGRADE -> verify(guild).cancelUpgradeQueue(2); + case INSTALLATION -> verify(installations).cancelPending("construction"); + case COMPANY_SLOT -> verify(company).cancelSlotQueue(2); + case COMPANY_UPGRADE -> verify(company).cancelUpgradeQueue(2); + } + } + + void verifyUnchanged() { + verify(military, never()).cancelQueue(anyInt()); + verify(guild, never()).cancelUpgradeQueue(anyInt()); + verify(installations, never()).cancelPending(anyString()); + verify(company, never()).cancelSlotQueue(anyInt()); + verify(company, never()).cancelUpgradeQueue(anyInt()); + } + } + + private QueueState queue(QueueCancelPayload.Type type, boolean result) { + Military military = mock(Military.class); + InstallationHandler installations = mock(InstallationHandler.class); + MercenaryCompany company = mock(MercenaryCompany.class); + when(faction.getMilitary()).thenReturn(military); + when(faction.getInstallationHandler()).thenReturn(installations); + when(guild.getCompany()).thenReturn(company); + when(company.isLeader("Leader")).thenReturn(true); + when(military.cancelQueue(2)).thenReturn(result); + when(guild.cancelUpgradeQueue(2)).thenReturn(result); + when(installations.cancelPending("construction")).thenReturn(result); + when(company.cancelSlotQueue(2)).thenReturn(result); + when(company.cancelUpgradeQueue(2)).thenReturn(result); + String payload = + switch (type) { + case MILITARY -> QueueCancelPayload.military("realm", 2); + case GUILD_UPGRADE -> QueueCancelPayload.guildUpgrade("guild", 2); + case INSTALLATION -> QueueCancelPayload.installation("realm", "construction"); + case COMPANY_SLOT -> QueueCancelPayload.companySlot("guild", 2); + case COMPANY_UPGRADE -> QueueCancelPayload.companyUpgrade("guild", 2); + }; + return new QueueState(type, payload, military, guild, installations, company); + } + + private void verifyQueueParent(QueueCancelPayload.Type type) { + switch (type) { + case MILITARY -> verify(manager.militaryView).militaryView(null, player, faction, true); + case GUILD_UPGRADE -> verify(manager.guildView).upgradeView(player, guild); + case INSTALLATION -> + verify(manager.installationView).installationsView(null, player, faction, true); + case COMPANY_SLOT -> verify(manager.companyView).slotsView(player, guild); + case COMPANY_UPGRADE -> verify(manager.companyView).companyUpgradeView(player, guild); + } + } + + private Inventory open(SFGUI type, boolean flag) { + String id = + switch (type) { + case GUILD_VIEW, + UPGRADE_VIEW, + LEDGER_VIEW, + COMPANY_VIEW, + COMPANY_SLOTS_VIEW, + COMPANY_ROSTER_VIEW, + COMPANY_UPGRADE_VIEW, + CONTRACT_LIST_VIEW, + CONTRACT_DETAIL_VIEW, + LOAN_MAIN_VIEW, + LOANS_GIVEN_VIEW, + LOANS_TAKEN_VIEW, + ISSUED_LOAN_DETAIL_VIEW, + TAKEN_LOAN_DETAIL_VIEW -> + "guild"; + default -> "realm"; + }; + Inventory inventory = gui.inventory(new SFInventoryHolder(id, type, 0, flag), 54, "Menu"); + player.openInventory(inventory); + return inventory; + } + + private Object[] views() { + return new Object[] { + manager.factionView, + manager.guildView, + manager.companyView, + manager.contractView, + manager.mercenaryMarketView, + manager.loanView, + manager.lawView, + manager.militaryView, + manager.installationView, + manager.governmentView, + manager.vehicleFeeView, + manager.movementView, + manager.relationView, + manager.electionView, + manager.tierTitleView, + manager.warView, + manager.taxView, + manager.campaignView, + manager.campaignInstallationPickView, + manager.campaignRaidLaunchView, + manager.declareWarView + }; + } + + private MercenaryContract contract() { + MercenaryCompany company = mock(MercenaryCompany.class); + ContractHandler handler = mock(ContractHandler.class); + MercenaryContract contract = mock(MercenaryContract.class); + when(contract.getId()).thenReturn("contract"); + when(contract.getSlots()).thenReturn(2); + when(contract.getDueDate()).thenReturn(System.currentTimeMillis() + 86_400_000); + when(company.getSlots()).thenReturn(5); + when(company.isLeader("Leader")).thenReturn(true); + when(company.getContractHandler()).thenReturn(handler); + when(handler.getById("contract")).thenReturn(contract); + when(guild.getCompany()).thenReturn(company); + return contract; + } + + private AsyncPlayerChatEvent chat(String text) { + return new AsyncPlayerChatEvent(true, player, text, Set.of()); + } + + private Loan loan(double amount, double balance) { + Bank bank = new Bank(guild, balance, mock(Chunk.class)); + when(guild.getBank()).thenReturn(bank); + when(guild.getLoanHandler()).thenReturn(new LoanHandler(guild)); + Guild issuer = mock(Guild.class); + when(issuer.getLedger()).thenReturn(mock(Ledger.class)); + return new Loan("loan", amount, issuer, guild, System.currentTimeMillis(), 7, 0, 0, false); + } + + private AtomicReference<Double> donation(double amount) { + AtomicReference<Double> current = new AtomicReference<>(amount); + when(guild.getDonationAmount()).thenAnswer(call -> current.get()); + doAnswer( + call -> { + current.set(call.getArgument(0)); + return null; + }) + .when(guild) + .setDonationAmount(anyDouble()); + return current; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerRegressionTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerRegressionTest.java new file mode 100644 index 00000000..4f13a349 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerRegressionTest.java @@ -0,0 +1,374 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; +import net.tfminecraft.simplefactions.enums.Rules; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.government.Government; +import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.government.movement.cause.Cause; +import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.holder.CampaignInventoryHolder; +import net.tfminecraft.simplefactions.managers.holder.CampaignRaidLaunchHolder; +import net.tfminecraft.simplefactions.managers.holder.DeclareWarHolder; +import net.tfminecraft.simplefactions.managers.holder.SFCombinedInventoryHolder; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.managers.holder.WarInventoryHolder; +import net.tfminecraft.simplefactions.managers.inventory.GovernmentView; +import net.tfminecraft.simplefactions.managers.inventory.GuildView; +import net.tfminecraft.simplefactions.managers.inventory.MovementView; +import net.tfminecraft.simplefactions.objects.Bracket; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.handler.TaxHandler; +import net.tfminecraft.simplefactions.objects.handler.VehicleFeeHandler; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.MockedStatic; + +@SuppressWarnings("deprecation") +class InventoryManagerRegressionTest { + @ParameterizedTest + @EnumSource(SFGUI.class) + void everyFactionMenuProtectsItsTopSlotsFromDrags(SFGUI type) { + InventoryDragEvent event = drag(new SFInventoryHolder("owner", type), "Menu", Set.of(0, 54)); + + new InventoryManager().dragInWarGui(event); + + verify(event).setCancelled(true); + } + + @ParameterizedTest + @EnumSource(SFGUI.class) + void factionMenusAllowDragsConfinedToThePlayersInventory(SFGUI type) { + InventoryDragEvent event = drag(new SFInventoryHolder("owner", type), "Menu", Set.of(54, 89)); + + new InventoryManager().dragInWarGui(event); + + verify(event, never()).setCancelled(true); + } + + @ParameterizedTest + @MethodSource("otherMenuHolders") + void otherPluginMenusProtectTopSlotsAndAllowPlayerOnlyDrags(InventoryHolder holder) { + InventoryManager manager = new InventoryManager(); + InventoryDragEvent top = drag(holder, "Menu", Set.of(53, 54)); + InventoryDragEvent bottom = drag(holder, "Menu", Set.of(54, 89)); + + manager.dragInWarGui(top); + manager.dragInWarGui(bottom); + + verify(top).setCancelled(true); + verify(bottom, never()).setCancelled(true); + } + + @Test + void holderlessConfirmMenusProtectTopSlotsAndAllowPlayerOnlyDrags() { + InventoryManager manager = new InventoryManager(); + InventoryDragEvent top = drag(null, "§7Confirm Action", Set.of(0)); + InventoryDragEvent bottom = drag(null, "§7Confirm Action", Set.of(54)); + + manager.dragInWarGui(top); + manager.dragInWarGui(bottom); + + verify(top).setCancelled(true); + verify(bottom, never()).setCancelled(true); + } + + @Test + void unrelatedInventoriesKeepNormalDragBehavior() { + InventoryDragEvent event = drag(mock(InventoryHolder.class), "Chest", Set.of(0, 54)); + + new InventoryManager().dragInWarGui(event); + + verify(event, never()).setCancelled(true); + } + + @ParameterizedTest + @MethodSource("invalidRates") + void invalidRateTextPreservesTheCurrentRateAndPrompt(RateKind kind, String input) { + RateFixture fixture = new RateFixture(kind); + Object prompt = fixture.prompt(); + + fixture.submit(input); + + assertEquals(RateFixture.INITIAL_RATE, fixture.rate()); + assertSame( + prompt, fixture.prompt(), "Invalid input must leave the same prompt available for retry"); + verify(fixture.player).sendMessage(startsWith("§cError inputting the amount,")); + verifyNoInteractions(fixture.manager.governmentView, fixture.manager.guildView); + + fixture.submit("20.126"); + + assertEquals(20.13, fixture.rate()); + assertNull(fixture.prompt()); + fixture.verifyRedraw(); + } + + @ParameterizedTest + @MethodSource("validRates") + void validRatesRetainRoundingAndBoundaryBehavior(RateKind kind, String input, double expected) { + RateFixture fixture = new RateFixture(kind); + + fixture.submit(input); + + assertEquals(expected, fixture.rate()); + assertNull(fixture.prompt()); + fixture.verifyRedraw(); + } + + @ParameterizedTest + @EnumSource(RateKind.class) + void cancellationKeepsTheRateAndReturnsToItsMenu(RateKind kind) { + RateFixture fixture = new RateFixture(kind); + + fixture.submit("CaNcEl"); + + assertEquals(RateFixture.INITIAL_RATE, fixture.rate()); + assertNull(fixture.prompt()); + fixture.verifyRedraw(); + } + + @Test + void finiteTaxAboveOneHundredKeepsItsExistingCap() { + RateFixture fixture = new RateFixture(RateKind.TAX); + + fixture.submit("150"); + + assertEquals(100.0, fixture.rate()); + assertNull(fixture.prompt()); + fixture.verifyRedraw(); + } + + @ParameterizedTest + @CsvSource({"0,0", "1,1", "99,1", "-2,1"}) + void backFromAStaleCausePageReturnsToTheMovement(int page, int remainingCauses) { + NavigationFixture fixture = + new NavigationFixture(page, remainingCauses == 0 ? List.of() : List.of(mock(Cause.class))); + + fixture.clickBack(); + + verify(fixture.event, atLeastOnce()).setCancelled(true); + verify(fixture.event, never()).setCancelled(false); + verify(fixture.manager.movementView) + .movementView(fixture.player, fixture.faction, fixture.movement, null); + verify(fixture.manager.movementView, never()) + .causeView( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + void backFromAValidCausePageKeepsItsExistingCauseNavigation() { + Cause cause = mock(Cause.class); + NavigationFixture fixture = new NavigationFixture(0, List.of(cause)); + when(fixture.manager.movementView.displayedCause(fixture.inventory, fixture.movement)) + .thenReturn(cause); + + fixture.clickBack(); + + verify(fixture.manager.movementView) + .causeView(fixture.player, fixture.faction, fixture.movement, cause, null); + verify(fixture.manager.movementView, never()) + .movementView(fixture.player, fixture.faction, fixture.movement, null); + } + + @Test + void backFromTheMovementLevelTargetPickerKeepsItsExistingNavigation() { + NavigationFixture fixture = new NavigationFixture(-1, List.of(mock(Cause.class))); + + fixture.clickBack(); + + verify(fixture.manager.movementView) + .movementView(fixture.player, fixture.faction, fixture.movement, null); + } + + private static InventoryDragEvent drag(InventoryHolder holder, String title, Set<Integer> slots) { + Inventory inventory = mock(Inventory.class); + when(inventory.getHolder()).thenReturn(holder); + when(inventory.getSize()).thenReturn(54); + InventoryView view = mock(InventoryView.class); + when(view.getTopInventory()).thenReturn(inventory); + when(view.getTitle()).thenReturn(title); + InventoryDragEvent event = mock(InventoryDragEvent.class); + when(event.getView()).thenReturn(view); + when(event.getRawSlots()).thenReturn(slots); + return event; + } + + private static Stream<InventoryHolder> otherMenuHolders() { + return Stream.of( + new WarInventoryHolder(1, SFGUI.WAR_VIEW), + new CampaignInventoryHolder(1, SFGUI.CAMPAIGN_VIEW), + new CampaignRaidLaunchHolder(1, "installation"), + new SFCombinedInventoryHolder(1, "faction", SFGUI.PARTICIPANT_VIEW), + new DeclareWarHolder("attacker", "defender", SFGUI.WAR_DECLARE_GOAL)); + } + + private static Stream<Arguments> invalidRates() { + return Stream.of(RateKind.values()) + .flatMap( + kind -> + Stream.of("NaN", "Infinity", "-Infinity", "1e309", "not-a-number") + .map(input -> Arguments.of(kind, input))); + } + + private static Stream<Arguments> validRates() { + return Stream.of(RateKind.values()) + .flatMap( + kind -> + Stream.of( + Arguments.of(kind, "0", 0.0), + Arguments.of(kind, "20.126", 20.13), + Arguments.of(kind, "100", 100.0))); + } + + private enum RateKind { + TAX, + FEE, + DIVIDEND + } + + private static final class RateFixture { + private static final double INITIAL_RATE = 12.5; + private final InventoryManager manager = new InventoryManager(); + private final Player player = mock(Player.class); + private final Faction faction = mock(Faction.class); + private final Guild guild = mock(Guild.class); + private final TaxHandler taxes = new TaxHandler(faction, INITIAL_RATE, 0, 0, 0, 0); + private final VehicleFeeHandler fees = new VehicleFeeHandler(faction); + private final AtomicReference<Double> dividend = new AtomicReference<>(INITIAL_RATE); + private final RateKind kind; + + private RateFixture(RateKind kind) { + this.kind = kind; + manager.governmentView = mock(GovernmentView.class); + manager.guildView = mock(GuildView.class); + when(player.getName()).thenReturn("Leader"); + when(faction.isLeader("Leader")).thenReturn(true); + when(faction.hasFactionRule(Rules.CITIZEN_TAX)).thenReturn(true); + when(faction.hasFactionRule(Rules.REGISTRATION_FEE)).thenReturn(true); + when(faction.getTaxHandler()).thenReturn(taxes); + when(faction.getVehicleFeeHandler()).thenReturn(fees); + Government government = mock(Government.class); + when(government.getFaction()).thenReturn(faction); + when(faction.getGovernment()).thenReturn(government); + fees.applyBracket(FeeKind.REGISTRATION_FEE, new Bracket(0, 100)); + fees.setRate(FeeKind.REGISTRATION_FEE, null, INITIAL_RATE); + when(guild.isLeader(player)).thenReturn(true); + when(guild.getDividendPercent()).thenAnswer(call -> dividend.get()); + doAnswer( + call -> { + dividend.set(call.getArgument(0)); + return null; + }) + .when(guild) + .setDividendPercent(anyDouble()); + switch (kind) { + case TAX -> manager.setChanging(faction, player, TaxTarget.CITIZENS, null); + case FEE -> manager.setChangingFee(faction, player, FeeKind.REGISTRATION_FEE, null); + case DIVIDEND -> manager.setChangingDividend(player, guild); + } + clearInvocations(player); + } + + private void submit(String input) { + AsyncPlayerChatEvent event = mock(AsyncPlayerChatEvent.class); + when(event.getMessage()).thenReturn(input); + switch (kind) { + case TAX -> manager.taxChat(player, event); + case FEE -> manager.feeChat(player, event); + case DIVIDEND -> manager.dividendChat(player, event); + } + } + + private double rate() { + return switch (kind) { + case TAX -> taxes.getCitizenTax(); + case FEE -> fees.getRate(FeeKind.REGISTRATION_FEE); + case DIVIDEND -> dividend.get(); + }; + } + + private Object prompt() { + return switch (kind) { + case TAX -> manager.taxChange.get(player); + case FEE -> manager.feeChange.get(player); + case DIVIDEND -> manager.dividendChange.get(player); + }; + } + + private void verifyRedraw() { + if (kind == RateKind.DIVIDEND) verify(manager.guildView).guildView(player, guild); + else verify(manager.governmentView).governmentView(player, faction, null); + } + } + + private static final class NavigationFixture { + private final InventoryManager manager = new InventoryManager(); + private final Player player = mock(Player.class); + private final Faction faction = mock(Faction.class); + private final Movement movement = mock(Movement.class); + private final Inventory inventory = mock(Inventory.class); + private final InventoryClickEvent event = mock(InventoryClickEvent.class); + + private NavigationFixture(int page, List<Cause> causes) { + manager.movementView = mock(MovementView.class); + when(movement.getFaction()).thenReturn(faction); + when(movement.getCauses()).thenReturn(causes); + when(inventory.getHolder()) + .thenReturn(new SFInventoryHolder("movement", SFGUI.TARGET_SELECT, page)); + when(inventory.getSize()).thenReturn(54); + InventoryView view = mock(InventoryView.class); + when(view.getTopInventory()).thenReturn(inventory); + when(view.getTitle()).thenReturn("Select Target"); + ItemStack back = mock(ItemStack.class); + when(back.getType()).thenReturn(Material.BARRIER); + when(event.getView()).thenReturn(view); + when(event.getWhoClicked()).thenReturn(player); + when(event.getCurrentItem()).thenReturn(back); + when(event.getRawSlot()).thenReturn(53); + } + + private void clickBack() { + try (MockedStatic<FactionManager> factions = mockStatic(FactionManager.class)) { + factions.when(() -> FactionManager.getMovementById("movement")).thenReturn(movement); + manager.clickButton(event); + } + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/LoggingLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/LoggingLifecycleCoverageTest.java new file mode 100644 index 00000000..a6685989 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/LoggingLifecycleCoverageTest.java @@ -0,0 +1,127 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.Field; +import java.nio.file.*; +import java.util.*; +import java.util.logging.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; + +class LoggingLifecycleCoverageTest { + @TempDir Path temp; + private final Map<Field, Object> original = new LinkedHashMap<>(); + private List<String> lines; + private List<String> oldLines; + private final List<String> warnings = new ArrayList<>(); + private Logger logger; + private Handler handler; + + @BeforeEach + @SuppressWarnings("unchecked") + void setup() throws Exception { + for (String name : + List.of( + "enabled", + "logFile", + "relationsLogFile", + "movementLogFile", + "civilwarLogFile", + "warLogFile")) { + Field field = LogManager.class.getDeclaredField(name); + field.setAccessible(true); + original.put(field, field.get(null)); + if (name.equals("enabled")) field.setBoolean(null, false); + else field.set(null, null); + } + Field field = LogManager.class.getDeclaredField("sessionLines"); + field.setAccessible(true); + lines = (List<String>) field.get(null); + oldLines = new ArrayList<>(lines); + lines.clear(); + logger = Logger.getLogger(LogManager.class.getName()); + handler = + new Handler() { + public void publish(LogRecord record) { + warnings.add(record.getMessage()); + } + + public void flush() {} + + public void close() {} + }; + logger.addHandler(handler); + } + + @AfterEach + void close() throws Exception { + logger.removeHandler(handler); + lines.clear(); + lines.addAll(oldLines); + for (var entry : original.entrySet()) entry.getKey().set(null, entry.getValue()); + } + + @Test + void disabledOrUnconfiguredLoggingDoesNotCreateFilesOrThrow() { + LogManager.configure(false, true, null); + LogManager.beginSession("disabled"); + LogManager.section("disabled"); + LogManager.line("%s", "disabled"); + LogManager.append("disabled"); + LogManager.relations("%s", "disabled"); + LogManager.movement("%s", "disabled"); + LogManager.civilwar("%s", "disabled"); + LogManager.war("%s", "disabled"); + LogManager.flush(); + assertTrue(lines.isEmpty()); + assertTrue(warnings.isEmpty()); + LogManager.configure(true, false, null); + LogManager.append("unconfigured"); + LogManager.relations("unconfigured"); + LogManager.movement("unconfigured"); + LogManager.flush(); + assertTrue(warnings.isEmpty()); + } + + @Test + void formattedDomainsAndSessionSectionsAreWrittenOnce() throws Exception { + LogManager.configure(true, false, temp.toFile()); + LogManager.beginSession("settlement"); + LogManager.section("Balances"); + LogManager.line("balance=%d", 12); + LogManager.line((String) null); + LogManager.flush(); + LogManager.flush(); + LogManager.relations("relation=%s", "ally"); + LogManager.movement("movement=%s", "cause"); + LogManager.civilwar("civilwar=%d", 3); + LogManager.war("war=%d", 4); + String log = Files.readString(temp.resolve("logs/log.txt")); + assertTrue(log.contains("--- Balances ---")); + assertEquals(1, log.split("balance=12", -1).length - 1); + assertTrue(Files.readString(temp.resolve("logs/relations.log")).contains("relation=ally")); + assertTrue(Files.readString(temp.resolve("logs/movement.log")).contains("movement=cause")); + assertTrue(Files.readString(temp.resolve("logs/civilwar.log")).contains("civilwar=3")); + assertTrue(Files.readString(temp.resolve("logs/war.log")).contains("war=4")); + } + + @Test + void writeFailuresReportTheFileAndDoNotThrowToTheGameplayCaller() throws Exception { + Files.writeString(temp.resolve("logs"), "a file blocks the log directory"); + LogManager.configure(true, false, temp.toFile()); + assertDoesNotThrow(() -> LogManager.append("event")); + assertTrue(warnings.stream().anyMatch(s -> s.startsWith("Failed to write log.txt:"))); + assertEquals("a file blocks the log directory", Files.readString(temp.resolve("logs"))); + } + + @Test + void failedWipePreservesUnexpectedDirectoryContentsAndReportsFailure() throws Exception { + Path nested = temp.resolve("logs/log.txt/keep.txt"); + Files.createDirectories(nested.getParent()); + Files.writeString(nested, "keep"); + assertDoesNotThrow(() -> LogManager.configure(true, true, temp.toFile())); + assertEquals("keep", Files.readString(nested)); + assertTrue(warnings.stream().anyMatch(s -> s.startsWith("Failed to wipe log.txt:"))); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/MercenaryCommandsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/MercenaryCommandsCoverageTest.java new file mode 100644 index 00000000..51580e0d --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/MercenaryCommandsCoverageTest.java @@ -0,0 +1,843 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.database.MercenaryCompanyData; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.upgrade.Upgrade; +import net.tfminecraft.simplefactions.keys.Keys; +import net.tfminecraft.simplefactions.loaders.CompanyUpgradeLoader; +import net.tfminecraft.simplefactions.loaders.RegimentLoader; +import net.tfminecraft.simplefactions.mercenary.MercenaryResult; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompanyService; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryEligibility; +import net.tfminecraft.simplefactions.mercenary.contract.ContractBook; +import net.tfminecraft.simplefactions.mercenary.contract.ContractHandler; +import net.tfminecraft.simplefactions.mercenary.contract.ContractKind; +import net.tfminecraft.simplefactions.mercenary.contract.ContractStatus; +import net.tfminecraft.simplefactions.mercenary.contract.ContractTerms; +import net.tfminecraft.simplefactions.mercenary.contract.MercenaryContract; +import net.tfminecraft.simplefactions.mercenary.contract.MercenaryMarket; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.request.MercenaryInviteRequest; +import net.tfminecraft.simplefactions.objects.request.Request; +import net.tfminecraft.simplefactions.rest.RestServer; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.utils.DisplayNameGate; +import net.tfminecraft.simplefactions.utils.Permissions; +import net.tfminecraft.simplefactions.war.core.War; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.meta.BookMeta; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +class MercenaryCommandsCoverageTest { + private FactionDomainFixture fixture; + private Guild guild; + private Faction host; + private Faction hirer; + private Player captain; + private Player customer; + private MercenaryCompany company; + private Regiment prototype; + private MercenaryCommandManager commands; + private Command command; + private MockedStatic<RestServer> rest; + private Map<String, Upgrade> previousUpgrades; + private final Map<Field, Object> globals = new LinkedHashMap<>(); + + @BeforeEach + void setup() throws Exception { + fixture = new FactionDomainFixture(); + save(Cache.class, "baseYear", "322 AE"); + save(Cache.class, "baseIrlYear", 2026); + save(Cache.class, "mercenaryFormationCost", 100.0); + save(Cache.class, "mercenaryFormationSeconds", 2); + save(Cache.class, "mercenarySlotUpkeep", 8.0); + save(Cache.class, "mercenaryMinPricePerBattle", 50.0); + save(Cache.class, "mercenaryMinPricePerDay", 10.0); + save(Cache.class, "mercenaryMaxContractDays", 14); + save(Cache.class, "mercenaryDefaultBreachRefund", 500.0); + save(MercenaryEligibility.class, "probe", MercenaryEligibility.Probe.OPEN); + save(RequestManager.class, "requests", new java.util.HashMap<>()); + previousUpgrades = new LinkedHashMap<>(CompanyUpgradeLoader.get()); + CompanyUpgradeLoader.get().clear(); + host = fixture.saved("host", "Host"); + host.addProvince(10); + guild = fixture.guild(host, "blades", "Captain"); + guild.setCapital(10, false); + guild.getBank().setWealth(1000.0); + hirer = fixture.saved("hirer", "Hirer"); + hirer.getOrCreateMainGuild(); + captain = fixture.player("Captain"); + when(captain.getWorld()).thenReturn(fixture.ui.world); + customer = fixture.player("Hirer"); + YamlConfiguration config = new YamlConfiguration(); + config.set("mercenary.name", "Mercenary"); + config.set("mercenary.mercenary", true); + config.set("mercenary.item.material", "IRON_SWORD"); + config.set("mercenary.upkeep", 8.0); + config.set("mercenary.expansion-time", 2); + prototype = new Regiment("mercenary", config.getConfigurationSection("mercenary")); + RegimentLoader.oList.add(prototype); + company = form(guild, "Hired Blades"); + PlayerInventory inventory = captain.getInventory(); + when(inventory.getItemInMainHand()).thenAnswer(call -> inventory.getItem(0)); + doAnswer( + call -> { + inventory.setItem(0, call.getArgument(0)); + return null; + }) + .when(inventory) + .setItemInMainHand(any()); + rest = mockStatic(RestServer.class); + rest.when(() -> RestServer.getProvince(any(Player.class))).thenReturn(10); + commands = new MercenaryCommandManager(); + command = mock(Command.class); + when(command.getName()).thenReturn("company"); + } + + @AfterEach + void close() throws Exception { + try { + if (captain != null) new DisplayNameGate().onPlayerQuit(new PlayerQuitEvent(captain, "")); + if (rest != null) rest.close(); + if (previousUpgrades != null) { + CompanyUpgradeLoader.get().clear(); + CompanyUpgradeLoader.get().putAll(previousUpgrades); + } + for (var entry : globals.entrySet()) entry.getKey().set(null, entry.getValue()); + } finally { + fixture.close(); + } + } + + @ParameterizedTest + @ValueSource( + strings = { + "draft", + "expired", + "missing_expiry", + "changed_terms", + "other_company", + "agreement" + }) + void offeringRequiresAnUnexpiredUnmodifiedReviewFromTheCurrentCompany(String state) { + ItemStack book = ContractBook.reviewBook(company, ContractTerms.defaults()); + if (state.equals("draft")) book = ContractBook.draftBook(company); + if (state.equals("other_company")) { + Guild other = fixture.guild(host, "other", "Other"); + book = ContractBook.reviewBook(form(other, "Other Blades"), ContractTerms.defaults()); + } + if (state.equals("agreement")) { + var offer = company.getContractHandler().offer(hirer, ContractTerms.defaults()); + assertTrue(offer.ok(), offer.message()); + book = ContractBook.agreementBook(offer.contract()); + company.getContractHandler().remove(offer.contract().getId()); + } + BookMeta meta = (BookMeta) book.getItemMeta(); + if (state.equals("missing_expiry")) meta.getPersistentDataContainer().remove(Keys.LONG); + if (state.equals("expired")) + meta.getPersistentDataContainer() + .set(Keys.LONG, PersistentDataType.LONG, System.currentTimeMillis() - 1000); + if (state.equals("changed_terms")) + meta.setPage(2, ContractBook.termsPage(company, new ContractTerms(1, 60, 20, 7, 60, 500))); + book.setItemMeta(meta); + captain.getInventory().setItem(0, book); + assertTrue(commands.onCommand(captain, command, "company", new String[] {"offer", "hirer"})); + assertTrue( + company.getContractHandler().getAll().isEmpty(), + "Rejected books must not reserve slots or publish an offer"); + assertSame(book, captain.getInventory().getItem(0)); + assertEquals(1000.0, guild.getBank().getWealth()); + } + + @Test + void fullInventoryDoesNotLoseTheRequestedDraft() { + for (int slot = 0; slot < 41; slot++) + captain.getInventory().setItem(slot, new ItemStack(Material.STONE, 64)); + assertTrue(commands.onCommand(captain, command, "company", new String[] {"draft"})); + ArgumentCaptor<ItemStack> dropped = ArgumentCaptor.forClass(ItemStack.class); + verify(fixture.ui.world).dropItemNaturally(eq(captain.getLocation()), dropped.capture()); + assertEquals( + ContractBook.STAGE_DRAFT, ContractBook.stage((BookMeta) dropped.getValue().getItemMeta())); + assertEquals( + guild.getId(), ContractBook.companyGuildId((BookMeta) dropped.getValue().getItemMeta())); + for (int slot = 0; slot < 41; slot++) + assertEquals(64, captain.getInventory().getItem(slot).getAmount()); + } + + @Test + void commandSelectionAndPlayerGateDoNotAffectCompanies() { + CommandSender console = mock(CommandSender.class); + when(command.getName()).thenReturn("unrelated"); + assertFalse(commands.onCommand(console, command, "other", new String[0])); + verifyNoInteractions(console); + when(command.getName()).thenReturn("company"); + assertTrue(commands.onCommand(console, command, "company", new String[0])); + verify(console).sendMessage(contains("only be used by players")); + assertEquals(1, company.getSlots()); + assertTrue(company.getContractHandler().getAll().isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = {"", "unknown"}) + void unknownOrEmptySubcommandListsTheSupportedActions(String subcommand) { + run(captain, subcommand.isEmpty() ? new String[0] : new String[] {subcommand}); + verify(captain).sendMessage(contains("/company found <name>")); + verify(captain).sendMessage(contains("/company draft")); + verify(captain).sendMessage(contains("/mercenaries hire <company>")); + assertEquals(1000, guild.getBank().getWealth()); + } + + @ParameterizedTest + @ValueSource(strings = {"found", "invite", "kick", "offer"}) + void missingRequiredArgumentsDoNotMutateCompanyState(String subcommand) { + run(captain, subcommand); + verify(captain).sendMessage(contains("Usage: /company " + subcommand)); + assertEquals(List.of("Captain"), company.getEnlisted()); + assertTrue(company.getContractHandler().getAll().isEmpty()); + } + + @ParameterizedTest + @ValueSource( + strings = {"valid", "name_warning", "poor", "outsider", "nonleader", "already_formed"}) + void foundingChecksTheNameAndCurrentGuildBeforeRequestingFinancialConfirmation(String state) { + if (!state.equals("already_formed")) guild.setCompany(null); + if (state.equals("poor")) guild.getBank().setWealth(50.0); + Player actor = state.equals("outsider") ? fixture.player("Outsider") : captain; + if (state.equals("nonleader")) { + guild.addMember("Successor"); + guild.setLeader("Successor"); + } + String name = state.equals("name_warning") ? "hired blades" : "Hired Blades"; + double balance = guild.getBank().getWealth(); + run(actor, "found", name); + if (state.equals("valid")) { + verify(fixture.inventory).confirmCompanyFoundView(captain, guild, name); + } else { + verify(fixture.inventory, never()).confirmCompanyFoundView(any(), any(), anyString()); + verify(actor, atLeastOnce()).sendMessage(anyString()); + } + assertEquals( + balance, guild.getBank().getWealth(), "Only the later confirmation may spend money"); + if (!state.equals("already_formed")) assertNull(guild.getCompany()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void invitedPlayersCanAcceptOrDeclineTheirOwnRequest(boolean accepts) { + assertTrue(company.adminAdjustSlots(1).ok()); + Player recruit = fixture.player("Recruit"); + run(captain, "invite", "Recruit"); + assertTrue(RequestManager.hasRequest(recruit)); + assertFalse(company.isEnlisted("Recruit")); + run(recruit, accepts ? "accept" : "decline"); + assertFalse(RequestManager.hasRequest(recruit)); + assertEquals(accepts, company.isEnlisted("Recruit")); + assertEquals(accepts ? 2 : 1, company.getEnlisted().size()); + assertEquals(1000, guild.getBank().getWealth()); + } + + @Test + void noInviteAndOfflineInviteRefusalsKeepTheRosterUnchanged() { + Player recruit = fixture.player("Recruit"); + run(recruit, "accept"); + run(recruit, "decline"); + verify(recruit).sendMessage(contains("no company invite to accept")); + verify(recruit).sendMessage(contains("no company invite to decline")); + assertTrue(company.adminAdjustSlots(1).ok()); + run(captain, "invite", "Offline"); + verify(captain).sendMessage(contains("Could not find the player Offline")); + assertEquals(List.of("Captain"), company.getEnlisted()); + } + + @Test + void expandingAndDismissingUseTheRealCurrentCompanyRules() { + run(captain, "expand"); + assertEquals(1, company.getSlotQueue().size()); + company.tick(); + company.tick(); + assertEquals(2, company.getSlots()); + assertTrue(company.enlist("Recruit")); + run(captain, "kick", "Recruit"); + assertFalse(company.isEnlisted("Recruit")); + assertEquals(2, company.getSlots()); + assertTrue(company.getSlotQueue().isEmpty()); + assertEquals(1000, guild.getBank().getWealth()); + } + + @ParameterizedTest + @ValueSource(strings = {"draft", "offer", "contracts", "expand", "kick"}) + void outsiderCommandsCannotOperateAnotherGuildsCompany(String subcommand) { + Player outsider = fixture.player("Outsider"); + run(outsider, subcommand, "hirer"); + verify(outsider, atLeastOnce()).sendMessage(anyString()); + assertEquals(List.of("Captain"), company.getEnlisted()); + assertTrue(company.getSlotQueue().isEmpty()); + assertTrue(company.getContractHandler().getAll().isEmpty()); + verify(fixture.inventory, never()).contractListView(any(), any()); + } + + @Test + void aCompanyMemberCanReadContractsButCannotDraftOrOfferOnTheLeadersBehalf() { + guild.addMember("Member"); + Player member = fixture.player("Member"); + run(member, "contracts"); + verify(fixture.inventory).contractListView(member, guild); + run(member, "draft"); + run(member, "offer", "hirer"); + verify(member).sendMessage(contains("Only a company leader may draft")); + verify(member).sendMessage(contains("Only a company leader may offer")); + assertTrue(company.getContractHandler().getAll().isEmpty()); + } + + @Test + void draftCommandCreatesExactlyOneCompanyBoundBookInAvailableInventorySpace() { + run(captain, "draft"); + BookMeta draft = (BookMeta) captain.getInventory().getItem(0).getItemMeta(); + assertEquals(ContractBook.STAGE_DRAFT, ContractBook.stage(draft)); + assertEquals(guild.getId(), ContractBook.companyGuildId(draft)); + assertNotNull(ContractBook.parseTerms(draft)); + assertNull(captain.getInventory().getItem(1)); + verify(fixture.ui.world, never()).dropItemNaturally(any(), any()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "null", + "ordinary", + "unmarked_book", + "unreadable", + "missing_faction", + "invalid_terms", + "valid_id", + "valid_display_name" + }) + void offeringValidatesTheHeldBookAndOnlyReplacesItWhenAnOfferIsPublished(String state) { + ItemStack held = null; + if (state.equals("ordinary")) held = new ItemStack(Material.STONE); + else if (state.equals("unmarked_book")) held = new ItemStack(Material.WRITABLE_BOOK); + else if (!state.equals("null")) { + ContractTerms terms = + state.equals("invalid_terms") + ? new ContractTerms(1, 1, 1, 7, 1, 500) + : ContractTerms.defaults(); + held = ContractBook.reviewBook(company, terms); + if (state.equals("unreadable")) { + BookMeta meta = (BookMeta) held.getItemMeta(); + meta.setPages(List.of("Instructions only")); + held.setItemMeta(meta); + } + } + captain.getInventory().setItem(0, held); + hirer.setName("Eastern Realm"); + String target = + state.equals("missing_faction") + ? "missing" + : state.equals("valid_display_name") ? "Eastern Realm" : "hirer"; + run(captain, "offer", target); + if (state.startsWith("valid_")) { + var all = company.getContractHandler().getAll(); + assertEquals(1, all.size()); + assertSame(hirer, all.getFirst().getHirer()); + assertEquals(ContractTerms.defaults(), all.getFirst().getTerms()); + BookMeta agreement = (BookMeta) captain.getInventory().getItem(0).getItemMeta(); + assertEquals(ContractBook.STAGE_AGREEMENT, ContractBook.stage(agreement)); + assertEquals(all.getFirst().getId(), ContractBook.contractId(agreement)); + assertTrue(ContractBook.matchesSnapshot(agreement)); + } else { + assertTrue(company.getContractHandler().getAll().isEmpty()); + assertSame(held, captain.getInventory().getItem(0)); + verify(captain, atLeastOnce()).sendMessage(anyString()); + } + assertEquals(1000, guild.getBank().getWealth()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "denied", + "missing", + "verb", + "unknown_company", + "not_number", + "zero", + "negative", + "default", + "give", + "take", + "by_id" + }) + void administrativeSlotChangesValidatePermissionTargetAndPositiveAmount(String state) { + if (!state.equals("denied")) + when(captain.hasPermission(Permissions.Permission_Admin)).thenReturn(true); + assertTrue(company.adminAdjustSlots(4).ok()); + String[] args = + switch (state) { + case "missing" -> new String[] {"admin"}; + case "verb" -> new String[] {"admin", "other", "Hired Blades"}; + case "unknown_company" -> new String[] {"admin", "give", "missing"}; + case "not_number" -> new String[] {"admin", "give", "Hired Blades", "oops"}; + case "zero" -> new String[] {"admin", "give", "Hired Blades", "0"}; + case "negative" -> new String[] {"admin", "give", "Hired Blades", "-1"}; + case "take" -> new String[] {"admin", "take", "Hired Blades", "2"}; + case "give" -> new String[] {"admin", "give", "Hired Blades", "2"}; + case "by_id" -> new String[] {"admin", "give", guild.getId()}; + default -> new String[] {"admin", "give", "Hired Blades"}; + }; + run(captain, args); + int expected = + switch (state) { + case "take" -> 3; + case "give" -> 7; + case "default", "by_id" -> 6; + default -> 5; + }; + assertEquals(expected, company.getSlots()); + assertEquals(List.of("Captain"), company.getEnlisted()); + assertEquals(1000, guild.getBank().getWealth()); + verify(captain, atLeastOnce()).sendMessage(anyString()); + } + + @Test + void marketCommandOpensTheHallListsAvailabilityAndChecksCompanyLookup() { + when(command.getName()).thenReturn("mercenaries"); + run(customer); + verify(fixture.inventory).mercenaryMarketList(customer); + run(customer, "list"); + verify(customer).sendMessage(contains("Companies for hire")); + verify(customer).sendMessage(contains("Hired Blades")); + run(customer, "hire", "Hired", "Blades"); + verify(customer).sendMessage(contains("You may hire Hired Blades")); + run(customer, "hire", "Missing"); + verify(customer).sendMessage(contains("No company by that name is for hire")); + guild.setCompany(null); + run(customer, "list"); + verify(customer).sendMessage(contains("No mercenary companies are for hire")); + } + + @ParameterizedTest + @ValueSource(ints = {-2, 0, 99, 10}) + void hiringCheckUsesTheCurrentProvinceAndNeverCreatesAContract(int province) { + when(command.getName()).thenReturn("mercenaries"); + rest.when(() -> RestServer.getProvince(customer)).thenReturn(province); + run(customer, "hire", "Hired Blades"); + verify(customer).sendMessage(startsWith(province == 10 ? "§a" : "§c")); + assertTrue(company.getContractHandler().getAll().isEmpty()); + assertEquals(1000, guild.getBank().getWealth()); + assertSame(company, MercenaryMarket.byName("hired_blades")); + assertNull(MercenaryMarket.byName(null)); + } + + @Test + void minimumIntegerAdministrativeRemovalIsRejectedWithoutChangingCapacity() { + var result = company.adminAdjustSlots(Integer.MIN_VALUE); + assertFalse(result.ok(), "An overflowing removal is not a successful administrative action"); + assertEquals(1, company.getSlots()); + assertEquals(List.of("Captain"), company.getEnlisted()); + assertEquals(1000.0, guild.getBank().getWealth()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void pendingInvitesCannotJoinACompanyThatWasDisbandedOrReplaced(boolean disbanded) { + assertTrue(company.adminAdjustSlots(1).ok()); + Player recruit = fixture.player("Recruit"); + run(captain, "invite", "Recruit"); + assertTrue(RequestManager.hasRequest(recruit)); + if (disbanded) company.disband(); + else form(guild, "Replacement Blades"); + run(recruit, "accept"); + assertFalse(RequestManager.hasRequest(recruit)); + assertFalse( + company.isEnlisted("Recruit"), "An obsolete invite must not enroll into an orphan company"); + if (!disbanded) assertFalse(guild.getCompany().isEnlisted("Recruit")); + verify(recruit, atLeastOnce()).sendMessage(startsWith("§c")); + assertEquals(1000.0, guild.getBank().getWealth()); + } + + @Test + void anExistingCompanyInviteMustNotBeReportedAsANewSuccessfulInvitation() { + assertTrue(company.adminAdjustSlots(1).ok()); + Player recruit = fixture.player("Recruit"); + Guild rivalGuild = fixture.guild(host, "rival", "Rival"); + MercenaryCompany rival = form(rivalGuild, "Rival Blades"); + MercenaryInviteRequest original = new MercenaryInviteRequest(rival); + RequestManager.addRequest(captain, recruit, original); + run(captain, "invite", "Recruit"); + assertSame(original, RequestManager.getRequest(recruit)); + verify(captain).sendMessage(contains("already considering another request")); + verify(captain, never()).sendMessage(contains("Invited Recruit")); + verify(recruit, never()).sendMessage(contains("offers you a slot")); + assertEquals(List.of("Captain"), company.getEnlisted()); + } + + @Test + void anUnrelatedPendingRequestBlocksAnInviteWithoutReplacingTheRequest() { + assertTrue(company.adminAdjustSlots(1).ok()); + Player recruit = fixture.player("Recruit"); + Request original = new Request(guild); + RequestManager.addRequest(captain, recruit, original); + run(captain, "invite", "Recruit"); + assertSame(original, RequestManager.getRequest(recruit)); + verify(captain).sendMessage(contains("already considering another request")); + verify(recruit, never()).sendMessage(contains("offers you a slot")); + } + + @Test + void anInviteIsRefusedWhenTheLastSlotWasTakenBeforeAcceptance() { + assertTrue(company.adminAdjustSlots(1).ok()); + Player recruit = fixture.player("Recruit"); + run(captain, "invite", "Recruit"); + assertTrue(company.enlist("Earlier Recruit")); + run(recruit, "accept"); + assertFalse(RequestManager.hasRequest(recruit)); + assertEquals(List.of("Captain", "Earlier Recruit"), company.getEnlisted()); + verify(recruit).sendMessage(contains("no free slot")); + } + + @Test + void fullAndStillFormingCompaniesCannotInvite() { + Player recruit = fixture.player("Recruit"); + run(captain, "invite", "Recruit"); + verify(captain).sendMessage(contains("Every slot is already filled")); + company = new MercenaryCompany(guild, "New Blades", new Regiment(prototype), 2); + guild.setCompany(company); + run(captain, "invite", "Recruit"); + verify(captain).sendMessage(contains("still being founded")); + assertFalse(RequestManager.hasRequest(recruit)); + assertTrue(company.getEnlisted().isEmpty()); + assertFalse(MercenaryCompanyService.removeSlot(guild, "Captain").ok()); + assertEquals(0, company.getSlots()); + } + + @Test + void missingBankAndMissingCompanyServiceInputsCannotSpendOrMutate() { + guild.setCompany(null); + var bank = guild.getBank(); + guild.setBank(null); + assertFalse(MercenaryCompanyService.canFound(guild, "Captain", "New Blades").ok()); + guild.setBank(bank); + assertFalse(MercenaryCompanyService.canInvite(guild, "Captain", "Recruit").ok()); + assertFalse(MercenaryCompanyService.canJoin(null, "Recruit").ok()); + assertFalse(MercenaryCompanyService.removeSlot(guild, "Captain").ok()); + assertFalse(MercenaryCompanyService.upgrade(guild, "Captain", "missing").ok()); + assertEquals(1000.0, bank.getWealth()); + assertEquals(List.of("Captain"), company.getEnlisted()); + } + + @Test + void ineligibleRecruitsDoNotConsumeTheFreeSlot() { + assertTrue(company.adminAdjustSlots(1).ok()); + MercenaryEligibility.setProbe(player -> MercenaryEligibility.Status.INELIGIBLE); + assertFalse(MercenaryCompanyService.join(company, "Recruit").ok()); + assertEquals(List.of("Captain"), company.getEnlisted()); + assertTrue(company.hasFreeSlot()); + } + + @Test + void upgradeServiceRejectsFormerLeadersUnknownMaxedAndFullQueues() { + Upgrade upgrade = companyUpgrade("company_health", 2); + CompanyUpgradeLoader.get().put(upgrade.getId(), upgrade); + company = form(guild, "Hired Blades"); + assertFalse(MercenaryCompanyService.upgrade(guild, "Former Captain", upgrade.getId()).ok()); + assertFalse(MercenaryCompanyService.upgrade(guild, "Captain", "missing").ok()); + Upgrade owned = company.getUpgrade(upgrade.getId()); + owned.setLevel(2); + assertFalse(MercenaryCompanyService.upgrade(guild, "Captain", upgrade.getId()).ok()); + owned.setLevel(0); + for (int i = 0; i < 3; i++) assertTrue(company.enqueueUpgrade(owned)); + assertFalse(MercenaryCompanyService.upgrade(guild, "Captain", upgrade.getId()).ok()); + assertEquals(3, company.getUpgradeQueue().size()); + assertEquals(0, owned.getLevel()); + assertFalse(company.cancelUpgradeQueue(-1)); + assertFalse(company.cancelUpgradeQueue(3)); + assertTrue(company.cancelUpgradeQueue(1)); + assertEquals(2, company.getUpgradeQueue().size()); + assertFalse(company.cancelSlotQueue(0)); + assertEquals(1000.0, guild.getBank().getWealth()); + } + + @Test + void companiesWithoutAConfiguredRegimentCannotGainOrLoseSlots() { + MercenaryCompany unconfigured = new MercenaryCompany(guild, "Unconfigured", null, 0); + assertFalse(unconfigured.canExpand().ok()); + assertFalse(unconfigured.adminAdjustSlots(1).ok()); + assertFalse(unconfigured.dropSlot()); + assertEquals(0, unconfigured.getSlots()); + assertNull(unconfigured.serialize().slots); + assertTrue(unconfigured.getEnlisted().isEmpty()); + } + + @Test + void corruptPersistedSlotNumbersDoNotDiscardValidCompanyIdentityAndQueues() { + company.setName("Renamed Blades"); + List<String> banner = new ArrayList<>(List.of("red.base")); + company.setBannerPatterns(banner); + banner.clear(); + assertEquals(List.of("red.base"), company.getBannerPatterns()); + MercenaryCompanyData data = company.serialize(); + data.slots = "mercenary.invalid"; + data.slotQueue = new ArrayList<>(List.of("mercenary.invalid", "mercenary.2")); + MercenaryCompany loaded = new MercenaryCompany(guild, data, new Regiment(prototype)); + assertEquals("Renamed Blades", loaded.getName()); + assertEquals(0, loaded.getSlots()); + assertEquals(List.of("red.base"), loaded.getBannerPatterns()); + assertEquals(1, loaded.getSlotQueue().size()); + assertEquals(2, loaded.getSlotQueue().getFirst().getTimeLeft()); + loaded.setBannerPatterns(null); + assertTrue(loaded.getBannerPatterns().isEmpty()); + assertFalse(company.kick("Missing Recruit")); + assertEquals(List.of("Captain"), company.getEnlisted()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void characterChangesDismissMembersOrDisbandAndNotifyOnlinePlayers(boolean leaderChanged) { + assertTrue(company.adminAdjustSlots(1).ok()); + assertTrue(company.enlist("Recruit")); + Player recruit = fixture.player("Recruit"); + MercenaryEligibility.setProbe( + player -> + player.equals(leaderChanged ? "Captain" : "Recruit") + ? MercenaryEligibility.Status.INELIGIBLE + : MercenaryEligibility.Status.ELIGIBLE); + company.tick(); + assertFalse(company.isEnlisted("Recruit")); + if (leaderChanged) { + assertNull(guild.getCompany()); + assertTrue(company.getEnlisted().isEmpty()); + verify(captain).sendMessage(contains("disbanded because")); + verify(recruit).sendMessage(contains("has disbanded")); + } else { + assertSame(company, guild.getCompany()); + assertEquals(List.of("Captain"), company.getEnlisted()); + verify(recruit).sendMessage(contains("dismissed from")); + verify(captain).sendMessage(contains("Recruit was dismissed")); + } + assertEquals(1000.0, guild.getBank().getWealth()); + } + + @Test + void activeContractIncomeAndPayrollAreProjectedWithoutMovingMoney() { + var contract = activeContract(); + company.getWageSettings().setPeacetimePerDay(4.0); + company.getWageSettings().setActivePercent(20.0); + assertEquals(10.0, company.getContractIncome()); + assertEquals(6.0, company.getWageUpkeep()); + assertEquals(14.0, company.getDailyBurn()); + assertEquals(-4.0, company.getNetPosition()); + assertEquals(60.0, contract.getTerms().maxDailyCostPerSlot()); + assertEquals(1000.0, guild.getBank().getWealth()); + assertEquals(0.0, contract.getAccruedToCompany()); + assertTrue(company.getPendingWages().isEmpty()); + } + + @Test + void companyTickExpiresOldOffersAndRefreshesTheContractScreen() { + long now = System.currentTimeMillis(); + MercenaryContract old = + new MercenaryContract( + company, + hirer, + ContractKind.MERCENARY, + ContractTerms.defaults(), + now - MercenaryContract.OFFER_WINDOW_MS - 1000); + company.getContractHandler().add(old); + company.tick(); + assertEquals(ContractStatus.TERMINATED, old.getStatus()); + assertTrue(company.getContractHandler().getReserving().isEmpty()); + verify(fixture.inventory.getUpdater()) + .inventorySound( + "minecraft:block.note_block.chime", + net.tfminecraft.simplefactions.enums.SFGUI.CONTRACT_LIST_VIEW); + assertEquals(1000.0, guild.getBank().getWealth()); + } + + @Test + void missingContractAndUnreadableOfferOperationsPreserveTheLedger() { + ContractHandler handler = company.getContractHandler(); + assertSame(company, handler.getCompany()); + handler.add(null); + assertNull(handler.getById("missing")); + assertFalse(handler.offer(null, ContractTerms.defaults()).ok()); + assertFalse(handler.offer(hirer, null).ok()); + long now = System.currentTimeMillis(); + assertFalse(handler.decline("missing").ok()); + assertFalse(handler.proposeSlots("missing", "Captain", 2, now).ok()); + assertFalse(handler.withdrawSlots("missing", "Captain", now).ok()); + assertFalse(handler.acceptSlots("missing", hirer, "Hirer", now).ok()); + assertFalse(handler.declineSlots("missing", hirer, "Hirer", now).ok()); + assertTrue(handler.getAll().isEmpty()); + assertEquals(1000.0, guild.getBank().getWealth()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "ended", + "no_capacity", + "withdraw_foreign", + "withdraw_absent", + "withdraw_expired", + "accept_inactive", + "accept_foreign", + "accept_absent", + "accept_expired", + "decline_foreign", + "decline_absent", + "decline_expired" + }) + void slotAmendmentDenialsPreserveTheSignedTermsAndClearOnlyExpiredProposals(String state) { + if (!state.equals("no_capacity")) assertTrue(company.adminAdjustSlots(1).ok()); + MercenaryContract contract = activeContract(); + ContractTerms original = contract.getTerms(); + ContractHandler handler = company.getContractHandler(); + long now = System.currentTimeMillis(); + boolean pending = state.endsWith("foreign") || state.endsWith("expired"); + if (pending) assertTrue(handler.proposeSlots(contract.getId(), "Captain", 2, now).ok()); + if (state.endsWith("expired")) now = contract.getPendingExpiry(); + if (state.equals("accept_inactive")) contract.finish(ContractStatus.COMPLETED); + MercenaryResult result = + switch (state) { + case "ended" -> + handler.proposeSlots(contract.getId(), "Captain", 2, contract.getDueDate()); + case "no_capacity" -> handler.proposeSlots(contract.getId(), "Captain", 2, now); + case "withdraw_foreign" -> handler.withdrawSlots(contract.getId(), "Outsider", now); + case "withdraw_absent", "withdraw_expired" -> + handler.withdrawSlots(contract.getId(), "Captain", now); + case "accept_foreign" -> handler.acceptSlots(contract.getId(), host, "Host", now); + case "accept_inactive", "accept_absent", "accept_expired" -> + handler.acceptSlots(contract.getId(), hirer, "Hirer", now); + case "decline_foreign" -> handler.declineSlots(contract.getId(), host, "Host", now); + default -> handler.declineSlots(contract.getId(), hirer, "Hirer", now); + }; + assertFalse(result.ok(), state); + assertEquals(original, contract.getTerms()); + assertEquals(pending && !state.endsWith("expired"), contract.hasPendingSlots(now)); + if (state.endsWith("expired")) assertEquals(0, contract.getPendingExpiry()); + assertEquals(1000.0, guild.getBank().getWealth()); + } + + @Test + void amendmentNotificationsReachOnlyTheHiringGovernmentAndFrozenIncreasesStayPending() { + assertTrue(company.adminAdjustSlots(1).ok()); + MercenaryContract contract = activeContract(); + Player outsider = fixture.player("Outsider"); + when(Bukkit.getOnlinePlayers()).thenAnswer(call -> List.of(captain, customer, outsider)); + long now = System.currentTimeMillis(); + assertTrue(company.getContractHandler().proposeSlots(contract.getId(), "Captain", 2, now).ok()); + verify(customer).sendMessage(contains("offered to change your contract")); + verify(captain, never()).sendMessage(contains("offered to change your contract")); + verify(outsider, never()).sendMessage(anyString()); + Faction enemy = fixture.saved("enemy", "Enemy"); + War frozen = new War(hirer, enemy); + frozen.setPreparationFrozenUntil(Instant.ofEpochMilli(now + 3600000)); + WarManager.get().add(frozen); + assertFalse( + company.getContractHandler().acceptSlots(contract.getId(), hirer, "Hirer", now).ok()); + assertEquals(1, contract.getSlots()); + assertEquals(2, contract.getPendingSlots(now)); + assertEquals(1000.0, guild.getBank().getWealth()); + } + + @Test + void restoredOvercommittedAmendmentIsDroppedWithoutChangingSignedCapacity() { + assertTrue(company.adminAdjustSlots(1).ok()); + MercenaryContract contract = activeContract(); + long now = System.currentTimeMillis(); + assertTrue(company.getContractHandler().proposeSlots(contract.getId(), "Captain", 2, now).ok()); + MercenaryCompanyData data = company.serialize(); + data.slots = "mercenary.1"; + MercenaryCompany restored = new MercenaryCompany(guild, data, new Regiment(prototype)); + guild.setCompany(restored); + MercenaryContract saved = restored.getContractHandler().getById(contract.getId()); + assertFalse(restored.getContractHandler().acceptSlots(saved.getId(), hirer, "Hirer", now).ok()); + assertEquals(1, saved.getSlots()); + assertFalse(saved.hasPendingSlots(now)); + assertEquals(ContractStatus.ACTIVE, saved.getStatus()); + verify(captain).sendMessage(contains("company no longer has room")); + assertEquals(1000.0, guild.getBank().getWealth()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void aWarStartingAfterTheOfferRechecksHostAndExistingContractLoyalty(boolean hostOpposes) { + assertTrue(company.adminAdjustSlots(1).ok()); + Faction opponent = hostOpposes ? host : fixture.saved("opponent", "Opponent"); + if (!hostOpposes) { + var other = company.getContractHandler().offer(opponent, ContractTerms.defaults()); + assertTrue(other.ok(), other.message()); + assertTrue(other.contract().activate()); + } + var offer = company.getContractHandler().offer(hirer, ContractTerms.defaults()); + assertTrue(offer.ok(), offer.message()); + WarManager.get().add(new War(hirer, opponent)); + var result = company.getContractHandler().accept(offer.contract().getId(), hirer, "Hirer"); + assertFalse(result.ok()); + assertEquals(ContractStatus.TERMINATED, offer.contract().getStatus()); + assertEquals(hostOpposes ? 0 : 1, company.getContractHandler().getActive().size()); + assertEquals(1000.0, guild.getBank().getWealth()); + } + + private MercenaryContract activeContract() { + var offer = company.getContractHandler().offer(hirer, ContractTerms.defaults()); + assertTrue(offer.ok(), offer.message()); + assertTrue(company.getContractHandler().accept(offer.contract().getId(), hirer, "Hirer").ok()); + return offer.contract(); + } + + private Upgrade companyUpgrade(String id, int maximum) { + YamlConfiguration config = new YamlConfiguration(); + config.set(id + ".name", id); + config.set(id + ".icon", "writable_book.0"); + config.set(id + ".max-level", maximum); + config.set(id + ".expansion-time", 2); + return new Upgrade(id, config.getConfigurationSection(id)); + } + + private void run(Player player, String... args) { + assertTrue(commands.onCommand(player, command, command.getName(), args)); + } + + private MercenaryCompany form(Guild owner, String name) { + MercenaryCompany created = new MercenaryCompany(owner, name, new Regiment(prototype), 0); + owner.setCompany(created); + created.enlistLeader(); + return created; + } + + private void save(Class<?> type, String name, Object value) throws Exception { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + globals.put(field, field.get(null)); + field.set(null, value); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerEventsLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerEventsLifecycleCoverageTest.java new file mode 100644 index 00000000..21701b41 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerEventsLifecycleCoverageTest.java @@ -0,0 +1,141 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import dev.lone.itemsadder.api.Events.*; +import java.util.*; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.identity.*; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.bukkit.Location; +import org.bukkit.block.Block; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerJoinEvent; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +class PlayerEventsLifecycleCoverageTest { + private FactionDomainFixture fixture; + private String oldVoting; + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + oldVoting = Cache.votingBlock; + Cache.votingBlock = "iaf(test:booth)"; + } + + @AfterEach + void close() { + try { + Cache.votingBlock = oldVoting; + } finally { + fixture.close(); + } + } + + @Test + void joiningRefreshesOnlyTheMatchingRealmAndGuildLeaderAfterTheDelay() { + var realm = fixture.saved("realm", "Alice"); + realm.getOrCreateMainGuild(); + var guild = fixture.guild(realm, "traders", "Bob"); + var names = new HashMap<>(Map.of("Alice", "Lady Alice", "Bob", "Master Bob")); + LeaderCharacters.setProbe(names::get); + var listener = new LeaderCharacterListener(fixture.ui.plugin); + listener.onJoin(new PlayerJoinEvent(fixture.player("Alice"), "join")); + assertNull(realm.getLeaderCharacter()); + fixture.ui.tasks.removeLast().run(); + assertEquals("Lady Alice", realm.getLeaderCharacter()); + assertNull(guild.getLeaderCharacter()); + verify(fixture.map, times(1)).markLeaderNamesChanged(); + listener.onJoin(new PlayerJoinEvent(fixture.player("Bob"), "join")); + fixture.ui.tasks.removeLast().run(); + assertEquals("Master Bob", guild.getLeaderCharacter()); + verify(fixture.map, times(2)).markLeaderNamesChanged(); + LeaderCharacterListener.refresh("alice"); + verify(fixture.map, times(2)).markLeaderNamesChanged(); + names.put("Alice", "Queen Alice"); + names.put("Bob", "Guildmaster Bob"); + LeaderCharacterListener.refresh(null); + assertEquals("Queen Alice", realm.getLeaderCharacter()); + assertEquals("Guildmaster Bob", guild.getLeaderCharacter()); + verify(fixture.map, times(3)).markLeaderNamesChanged(); + } + + @ParameterizedTest + @EnumSource( + value = Action.class, + names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"}) + void matchingVotingFurnitureCancelsOnlyWhenTheBoothWasPresented(Action action) { + FactionManager manager = mock(FactionManager.class); + var listener = new VotingBoothListener(manager); + Player player = fixture.player("Alice"); + Entity entity = mock(Entity.class); + Location location = mock(Location.class); + Block block = mock(Block.class); + when(entity.getLocation()).thenReturn(location); + when(location.getBlock()).thenReturn(block); + FurnitureInteractEvent event = mock(FurnitureInteractEvent.class); + when(event.getAction()).thenReturn(action); + when(event.getNamespacedID()).thenReturn("test:booth"); + when(event.getPlayer()).thenReturn(player); + when(event.getBukkitEntity()).thenReturn(entity); + when(manager.presentBooth(player, block)).thenReturn(true, false); + listener.openVotingFurniture(event); + listener.openVotingFurniture(event); + verify(manager, times(2)).presentBooth(player, block); + verify(event, times(1)).setCancelled(true); + } + + @ParameterizedTest + @ValueSource(strings = {"left", "different", "missing_entity"}) + void unrelatedFurnitureInteractionsDoNotOpenVoting(String reason) { + FactionManager manager = mock(FactionManager.class); + var event = mock(FurnitureInteractEvent.class); + when(event.getAction()) + .thenReturn(reason.equals("left") ? Action.LEFT_CLICK_BLOCK : Action.RIGHT_CLICK_BLOCK); + when(event.getNamespacedID()) + .thenReturn(reason.equals("different") ? "test:chair" : "test:booth"); + new VotingBoothListener(manager).openVotingFurniture(event); + verifyNoInteractions(manager); + verify(event, never()).setCancelled(true); + } + + @Test + void placingAndBreakingFurnitureMaintainTheSamePhysicalVotingBooth() { + FactionManager manager = mock(FactionManager.class); + var listener = new VotingBoothListener(manager); + Player player = fixture.player("Alice"); + Entity entity = mock(Entity.class); + Location location = mock(Location.class); + Block block = mock(Block.class); + when(entity.getLocation()).thenReturn(location); + when(location.getBlock()).thenReturn(block); + var placed = mock(FurniturePlaceSuccessEvent.class); + var broken = mock(FurnitureBreakEvent.class); + when(placed.getNamespacedID()).thenReturn("test:booth"); + when(broken.getNamespacedID()).thenReturn("test:booth"); + when(placed.getBukkitEntity()).thenReturn(entity); + when(broken.getBukkitEntity()).thenReturn(entity); + when(placed.getPlayer()).thenReturn(player); + when(broken.getPlayer()).thenReturn(player); + listener.placeVotingFurniture(placed); + listener.breakVotingFurniture(broken); + verify(manager).registerVotingBooth(player, block); + verify(manager).unregisterVotingBooth(player, block); + when(placed.getBukkitEntity()).thenReturn(null); + when(broken.getBukkitEntity()).thenReturn(null); + listener.placeVotingFurniture(placed); + listener.breakVotingFurniture(broken); + when(placed.getNamespacedID()).thenReturn("test:chair"); + when(broken.getNamespacedID()).thenReturn("test:chair"); + listener.placeVotingFurniture(placed); + listener.breakVotingFurniture(broken); + verifyNoMoreInteractions(manager); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java new file mode 100644 index 00000000..7e150b1e --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerCoverageTest.java @@ -0,0 +1,734 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; +import net.tfminecraft.denareconomy.DenarEconomy; +import net.tfminecraft.denareconomy.accounts.OfflineModifier; +import net.tfminecraft.denareconomy.enums.Accounts; +import net.tfminecraft.denareconomy.event.PlayerBankPulseEvent; +import net.tfminecraft.denareconomy.event.PlayerDepositMaterialsEvent; +import net.tfminecraft.denareconomy.event.PlayerEarnMoneyEvent; +import net.tfminecraft.denareconomy.item.Coin; +import net.tfminecraft.denareconomy.managers.MoneyManager; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.loans.Loan; +import net.tfminecraft.simplefactions.guild.loans.LoanBook; +import net.tfminecraft.simplefactions.keys.Keys; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.mercenary.contract.ContractBook; +import net.tfminecraft.simplefactions.mercenary.contract.ContractTerms; +import net.tfminecraft.simplefactions.mercenary.contract.MercenaryContract; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.player.PlayerEconomyManager; +import net.tfminecraft.simplefactions.player.income.PlayerCashflow; +import net.tfminecraft.simplefactions.player.income.PlayerLedger; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.utils.FactionCleanup; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerEditBookEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.meta.BookMeta; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +/** Player event workflows with actual books, guilds and financial handlers. */ +class PlayerManagerCoverageTest { + private FactionDomainFixture fixture; + private Faction faction; + private Guild lender; + private Player leader; + private String oldBaseYear; + private int oldBaseIrlYear; + private final List<MockedStatic<?>> scopes = new ArrayList<>(); + private final PlayerEconomyManager economy = new PlayerEconomyManager(); + private final PlayerManager listener = new PlayerManager(); + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + oldBaseYear = Cache.baseYear; + oldBaseIrlYear = Cache.baseIrlYear; + Cache.baseYear = "322 AE"; + Cache.baseIrlYear = 2026; + Chunk origin = fixture.ui.world.getChunkAt(0, 0); + when(fixture.ui.world.getChunkAt(any(Location.class))).thenReturn(origin); + faction = fixture.saved("realm", "Leader"); + lender = faction.getOrCreateMainGuild(); + lender.setBank(null); + leader = player("Leader"); + scoped(net.tfminecraft.simplefactions.rest.RestServer.class) + .when(() -> net.tfminecraft.simplefactions.rest.RestServer.getProvince(any(Player.class))) + .thenReturn(10); + scoped(PlayerEconomyManager.class).when(PlayerEconomyManager::get).thenReturn(economy); + scoped(OfflineModifier.class) + .when(() -> OfflineModifier.playerId(anyString())) + .thenAnswer( + call -> + UUID.nameUUIDFromBytes( + ((String) call.getArgument(0)) + .getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } + + @AfterEach + void close() { + try { + for (int i = scopes.size() - 1; i >= 0; i--) scopes.get(i).close(); + } finally { + Cache.baseYear = oldBaseYear; + Cache.baseIrlYear = oldBaseIrlYear; + fixture.close(); + } + } + + @Test + void delayedLoanReviewReplacesTheSignedBookWithoutOverwritingTheNewlyHeldItem() { + ItemStack draft = LoanBook.getBaseBook(lender); + leader.getInventory().setItem(0, draft); + leader.getInventory().setItem(1, new ItemStack(Material.DIAMOND, 7)); + PlayerEditBookEvent event = sign(leader, draft); + assertTrue(event.isCancelled()); + leader.getInventory().setHeldItemSlot(1); + fixture.ui.runTasks(); + assertEquals(Material.DIAMOND, leader.getInventory().getItem(1).getType()); + assertEquals(7, leader.getInventory().getItem(1).getAmount()); + BookMeta review = (BookMeta) leader.getInventory().getItem(0).getItemMeta(); + assertEquals(2, review.getPersistentDataContainer().get(Keys.INT, PersistentDataType.INTEGER)); + } + + @Test + void delayedBookReplacementDoesNotOverwriteAnItemMovedIntoTheSignedSlot() { + ItemStack draft = LoanBook.getBaseBook(lender); + leader.getInventory().setItem(0, draft); + sign(leader, draft); + leader.getInventory().setItem(0, new ItemStack(Material.EMERALD, 4)); + fixture.ui.runTasks(); + assertEquals(Material.EMERALD, leader.getInventory().getItem(0).getType()); + assertEquals(4, leader.getInventory().getItem(0).getAmount()); + } + + @Test + void loginPassesThePlayerToTheRealInactivityWorkflow() { + try (MockedStatic<FactionCleanup> cleanup = mockStatic(FactionCleanup.class)) { + listener.joinEvent(new PlayerJoinEvent(leader, "joined")); + cleanup.verify(() -> FactionCleanup.daysOffline("Leader")); + cleanup.verify(() -> FactionCleanup.ping("Leader")); + } + } + + @ParameterizedTest + @ValueSource(strings = {"not_signing", "ordinary", "missing_issuer", "no_stage", "unknown_stage"}) + void ordinaryOrObsoleteBooksDoNotStartFinancialWorkflows(String kind) { + ItemStack book = LoanBook.getBaseBook(lender); + if (kind.equals("ordinary")) book = new ItemStack(Material.WRITABLE_BOOK); + if (kind.equals("missing_issuer")) + edit( + book, + meta -> + meta.getPersistentDataContainer() + .set(Keys.STRING_KEY, PersistentDataType.STRING, "deleted")); + if (kind.equals("no_stage")) + edit(book, meta -> meta.getPersistentDataContainer().remove(Keys.INT)); + if (kind.equals("unknown_stage")) + edit( + book, + meta -> meta.getPersistentDataContainer().set(Keys.INT, PersistentDataType.INTEGER, 99)); + leader.getInventory().setItem(0, book); + BookMeta meta = (BookMeta) book.getItemMeta(); + PlayerEditBookEvent event = + new PlayerEditBookEvent(leader, 0, meta, meta, !kind.equals("not_signing")); + listener.signBook(event); + assertFalse(event.isCancelled()); + assertTrue(fixture.ui.tasks.isEmpty()); + assertTrue(lender.getLoanHandler().getLoansGiven().isEmpty()); + assertTrue(leader.getInventory().getItem(0).isSimilar(book)); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2}) + void onlyTheCurrentLenderLeaderCanDraftOrReview(int stage) { + Player other = player("Other"); + ItemStack book = loanBook(stage, null); + other.getInventory().setItem(0, book); + PlayerEditBookEvent event = sign(other, book); + assertTrue(event.isCancelled()); + assertTrue(fixture.ui.tasks.isEmpty()); + verify(other).sendMessage(contains("Only the leader of")); + assertTrue(lender.getLoanHandler().getLoansGiven().isEmpty()); + } + + @ParameterizedTest + @CsvSource({"1,false", "2,false", "1,true", "2,true"}) + void draftingAndReviewingValidateExpiryAndTermsBeforeProducingABook(int stage, boolean expired) { + ItemStack book = loanBook(stage, null); + if (expired) + edit( + book, + meta -> meta.getPersistentDataContainer().set(Keys.LONG, PersistentDataType.LONG, 1L)); + else edit(book, meta -> meta.setPage(2, "invalid terms")); + leader.getInventory().setItem(0, book); + assertTrue(sign(leader, book).isCancelled()); + fixture.ui.runTasks(); + assertTrue(lender.getLoanHandler().getLoansGiven().isEmpty()); + BookMeta result = (BookMeta) leader.getInventory().getItem(0).getItemMeta(); + if (expired) { + verify(leader).sendMessage(contains("expired")); + assertNull(result.getPersistentDataContainer().get(Keys.INT, PersistentDataType.INTEGER)); + } else { + verify(leader).sendMessage(LoanBook.INVALID_TERMS_MESSAGE); + assertEquals( + stage, result.getPersistentDataContainer().get(Keys.INT, PersistentDataType.INTEGER)); + } + } + + @Test + void aLeaderCanReviewAndPublishValidLoanTermsWithoutMovingMoney() { + lender.setBank(new Bank(lender, 10000, null)); + ItemStack draft = LoanBook.getBaseBook(lender); + leader.getInventory().setItem(0, draft); + sign(leader, draft); + fixture.ui.runTasks(); + ItemStack review = leader.getInventory().getItem(0); + assertEquals( + 2, + ((BookMeta) review.getItemMeta()) + .getPersistentDataContainer() + .get(Keys.INT, PersistentDataType.INTEGER)); + sign(leader, review); + fixture.ui.runTasks(); + BookMeta offer = (BookMeta) leader.getInventory().getItem(0).getItemMeta(); + assertEquals(3, offer.getPersistentDataContainer().get(Keys.INT, PersistentDataType.INTEGER)); + assertNotNull(LoanBook.offerId(offer)); + assertNotNull( + offer + .getPersistentDataContainer() + .get(Keys.SECONDARY_STRING_KEY, PersistentDataType.STRING)); + assertEquals(10000, lender.getBank().getWealth()); + assertTrue(lender.getLoanHandler().getLoansGiven().isEmpty()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "expired", + "old_offer", + "missing_snapshot", + "invalid_snapshot", + "invalid_terms", + "changed_amount", + "no_borrower", + "no_lender_bank", + "no_borrower_bank", + "insufficient", + "used_offer" + }) + void refusedLoanAgreementsNeverMoveMoneyOrCreateDebt(String reason) { + Guild borrower = borrower(); + Player signer = reason.equals("no_borrower") ? player("Visitor") : player("Borrower"); + ItemStack book = loanBook(3, borrower); + switch (reason) { + case "expired" -> + edit( + book, + meta -> + meta.getPersistentDataContainer().set(Keys.LONG, PersistentDataType.LONG, 1L)); + case "old_offer" -> + edit(book, meta -> meta.getPersistentDataContainer().remove(Keys.LOAN_OFFER)); + case "missing_snapshot" -> + edit(book, meta -> meta.getPersistentDataContainer().remove(Keys.SECONDARY_STRING_KEY)); + case "invalid_snapshot" -> + edit( + book, + meta -> + meta.getPersistentDataContainer() + .set(Keys.SECONDARY_STRING_KEY, PersistentDataType.STRING, "bad snapshot")); + case "invalid_terms" -> edit(book, meta -> meta.setPage(2, "bad page")); + case "changed_amount" -> + edit(book, meta -> meta.setPage(2, meta.getPage(2).replace("500.0", "900.0"))); + case "no_lender_bank" -> lender.setBank(null); + case "no_borrower_bank" -> borrower.setBank(null); + case "insufficient" -> lender.getBank().setWealth(20.0); + case "used_offer" -> + lender + .getLoanHandler() + .useOffer( + LoanBook.offerId((BookMeta) book.getItemMeta()), + System.currentTimeMillis() + 60000); + } + double beforeLender = lender.getBank() == null ? 0 : lender.getBank().getWealth(); + double beforeBorrower = borrower.getBank() == null ? 0 : borrower.getBank().getWealth(); + signer.getInventory().setItem(0, book); + assertTrue(sign(signer, book).isCancelled()); + fixture.ui.runTasks(); + if (lender.getBank() != null) assertEquals(beforeLender, lender.getBank().getWealth()); + if (borrower.getBank() != null) assertEquals(beforeBorrower, borrower.getBank().getWealth()); + assertTrue(lender.getLoanHandler().getLoansGiven().isEmpty()); + assertTrue(messages(signer).stream().anyMatch(m -> m.startsWith("§c")), reason); + } + + @ParameterizedTest + @ValueSource(strings = {"online", "offline", "absent", "no_expiry"}) + void acceptingALoanConservesFundsRecordsOneDebtAndProducesASignedReceipt(String notification) { + Guild borrower = borrower(); + Player signer = player("Borrower"); + if (!notification.equals("absent")) { + when(Bukkit.getPlayer("Leader")).thenReturn(leader); + when(leader.isOnline()).thenReturn(!notification.equals("offline")); + } + ItemStack book = loanBook(3, borrower); + if (notification.equals("no_expiry")) + edit(book, meta -> meta.getPersistentDataContainer().remove(Keys.LONG)); + signer.getInventory().setItem(0, book); + String offer = LoanBook.offerId((BookMeta) book.getItemMeta()); + double total = lender.getBank().getWealth() + borrower.getBank().getWealth(); + assertTrue(sign(signer, book).isCancelled()); + assertEquals(9500, lender.getBank().getWealth()); + assertEquals(600, borrower.getBank().getWealth()); + assertEquals(total, lender.getBank().getWealth() + borrower.getBank().getWealth()); + assertEquals(1, lender.getLoanHandler().getLoansGiven().size()); + Loan accepted = lender.getLoanHandler().getLoanById(offer); + assertNotNull(accepted); + assertSame(borrower, accepted.getBorrower()); + assertEquals(500, accepted.getAmount()); + sign(signer, book); + assertEquals(600, borrower.getBank().getWealth()); + assertEquals(1, lender.getLoanHandler().getLoansGiven().size()); + fixture.ui.runTasks(); + ItemStack signed = signer.getInventory().getItem(0); + assertEquals(Material.WRITTEN_BOOK, signed.getType()); + BookMeta meta = (BookMeta) signed.getItemMeta(); + assertEquals("Leader and Borrower", meta.getAuthor()); + assertTrue(meta.getTitle().contains("Loan Agreement")); + assertTrue(meta.getPageCount() >= 2); + verify(signer).sendMessage("§aLoan taken!"); + if (notification.equals("online") || notification.equals("no_expiry")) + verify(leader).sendMessage(contains("Your loan has been accepted")); + else verify(leader, never()).sendMessage(contains("Your loan has been accepted")); + } + + @ParameterizedTest + @ValueSource( + strings = {"unknown", "no_bank", "wrong_chunk", "null_chunk", "faction_bank", "guild_bank"}) + void bankPulsesRequireAnOwnedBankAtThePlayersCurrentChunk(String state) { + Player actor = state.equals("unknown") ? player("Stranger") : leader; + Chunk here = actor.getLocation().getChunk(); + if (state.equals("wrong_chunk")) lender.setBank(new Bank(lender, mock(Chunk.class))); + if (state.equals("null_chunk")) lender.setBank(new Bank(lender, (Chunk) null)); + if (state.equals("faction_bank")) lender.setBank(new Bank(lender, here)); + if (state.equals("guild_bank")) { + Guild guild = fixture.guild(faction, "merchant", "Merchant"); + actor = player("Merchant"); + guild.setBank(new Bank(guild, here)); + } + PlayerBankPulseEvent event = new PlayerBankPulseEvent(actor); + listener.bankPulse(event); + boolean allowed = state.endsWith("bank") && !state.equals("no_bank"); + assertEquals(!allowed, event.isCancelled()); + if (state.equals("unknown")) verify(actor).sendMessage(contains("must belong")); + else if (!allowed) verify(actor).sendMessage(contains("must be at your bank")); + } + + @ParameterizedTest + @ValueSource(strings = {"taxed", "zero_rate", "no_bank", "outsider", "zero_income"}) + void earningsRecordGrossAndTaxesWithoutLosingMoneyOrSettlingTheGuildEarly(String state) { + lender.setBank(new Bank(lender, 100, null)); + faction.setTaxRate(state.equals("zero_rate") ? 0 : 10); + if (state.equals("no_bank")) lender.setBank(null); + String name = state.equals("outsider") ? "Stranger" : "Leader"; + double gross = state.equals("zero_income") ? 0 : 200; + double expectedTax = state.equals("taxed") ? 20 : 0; + PlayerEarnMoneyEvent event = new PlayerEarnMoneyEvent(name, gross); + listener.earnMoney(event); + PlayerLedger ledger = economy.getLedger(name); + assertEquals(expectedTax, event.getAmount()); + assertEquals(gross, ledger.getAmount(PlayerCashflow.EARNINGS)); + assertEquals(-expectedTax, ledger.getAmount(PlayerCashflow.CITIZEN_TAX), 1e-9); + assertEquals(gross, ledger.getNetDaily() + expectedTax); + assertEquals(expectedTax, lender.getLedger().getCitizenTaxesCopy().getOrDefault(name, 0.0)); + if (lender.getBank() != null) assertEquals(100, lender.getBank().getWealth()); + } + + @ParameterizedTest + @ValueSource(strings = {"unknown_item", "coin", "away", "deposit"}) + void materialDepositsConsumeOnlySupportedMaterialsAtAnOwnedBank(String state) { + ItemStack material = new ItemStack(Material.GOLD_INGOT, 7); + YamlConfiguration config = new YamlConfiguration(); + config.set("value", 12.5); + config.set("withdraw", state.equals("coin")); + config.set("item", "minecraft.GOLD_INGOT"); + Coin coin = new Coin("gold", config); + MoneyManager money = mock(MoneyManager.class); + scoped(DenarEconomy.class).when(DenarEconomy::getMoneyManager).thenReturn(money); + when(money.getCoin(material)).thenReturn(state.equals("unknown_item") ? null : coin); + if (!state.equals("away")) lender.setBank(new Bank(lender, leader.getLocation().getChunk())); + double[] credited = {0}; + doAnswer( + call -> { + credited[0] += (Double) call.getArgument(1); + return null; + }) + .when(money) + .addMoneyToAccount(anyString(), anyDouble(), anyBoolean(), anyBoolean(), any()); + listener.depositMaterials(new PlayerDepositMaterialsEvent(leader, material)); + assertEquals(state.equals("deposit") ? 0 : 7, material.getAmount()); + assertEquals(87.5, credited[0] + material.getAmount() * coin.getValue()); + if (state.equals("deposit")) + verify(money) + .addMoneyToAccount(leader.getUniqueId().toString(), 87.5, false, true, Accounts.BANK); + else + verify(money, never()) + .addMoneyToAccount(anyString(), anyDouble(), anyBoolean(), anyBoolean(), any()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "missing_company", + "forming", + "expired", + "bad_terms", + "not_leader", + "invalid_duration" + }) + void contractDraftsRejectUnavailableCompaniesOrInvalidTerms(String reason) { + MercenaryCompany company = company(reason.equals("forming") ? 60 : 0); + Player captain = player("Captain"); + ItemStack draft = ContractBook.draftBook(company); + if (reason.equals("missing_company")) company.getGuild().setCompany(null); + if (reason.equals("expired")) + edit( + draft, + meta -> meta.getPersistentDataContainer().set(Keys.LONG, PersistentDataType.LONG, 1L)); + if (reason.equals("bad_terms")) + edit(draft, meta -> meta.setPages("Instructions without a terms page")); + if (reason.equals("not_leader")) company.getGuild().setLeader("Successor"); + if (reason.equals("invalid_duration")) + edit( + draft, + meta -> + meta.setPage( + 2, ContractBook.termsPage(company, new ContractTerms(1, 50, 10, 0, 50, 500)))); + captain.getInventory().setItem(0, draft); + assertTrue(sign(captain, draft).isCancelled()); + fixture.ui.runTasks(); + assertTrue(company.getContractHandler().getAll().isEmpty()); + assertTrue(messages(captain).stream().anyMatch(m -> m.startsWith("§c")), reason); + BookMeta after = (BookMeta) captain.getInventory().getItem(0).getItemMeta(); + if (reason.equals("expired")) assertNull(ContractBook.stage(after)); + else assertEquals(ContractBook.STAGE_DRAFT, ContractBook.stage(after)); + } + + @Test + void draftingAndReviewingAContractProducesReviewBooksAndAnOfferInstruction() { + MercenaryCompany company = company(0); + Player captain = player("Captain"); + ItemStack draft = ContractBook.draftBook(company); + captain.getInventory().setItem(0, draft); + assertTrue(sign(captain, draft).isCancelled()); + fixture.ui.runTasks(); + ItemStack review = captain.getInventory().getItem(0); + BookMeta meta = (BookMeta) review.getItemMeta(); + assertEquals(ContractBook.STAGE_REVIEW, ContractBook.stage(meta)); + assertEquals(ContractTerms.defaults(), ContractBook.parseTerms(meta)); + assertTrue(ContractBook.matchesSnapshot(meta)); + sign(captain, review); + fixture.ui.runTasks(); + verify(captain).sendMessage("§7Choose who to offer this to with §e/company offer <faction>"); + assertEquals( + ContractBook.STAGE_REVIEW, + ContractBook.stage((BookMeta) captain.getInventory().getItem(0).getItemMeta())); + assertTrue(company.getContractHandler().getAll().isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = {"not_leader", "tampered"}) + void reviewingAContractRequiresCurrentLeadershipAndTheSignedTerms(String reason) { + MercenaryCompany company = company(0); + Player captain = player("Captain"); + ItemStack review = ContractBook.reviewBook(company, ContractTerms.defaults()); + if (reason.equals("not_leader")) company.getGuild().setLeader("Successor"); + else edit(review, meta -> meta.getPersistentDataContainer().remove(Keys.SECONDARY_STRING_KEY)); + captain.getInventory().setItem(0, review); + assertTrue(sign(captain, review).isCancelled()); + fixture.ui.runTasks(); + verify(captain) + .sendMessage( + contains(reason.equals("not_leader") ? "Only the company leader" : "tampered")); + assertTrue(company.getContractHandler().getAll().isEmpty()); + if (reason.equals("tampered")) + assertNull(ContractBook.stage((BookMeta) captain.getInventory().getItem(0).getItemMeta())); + } + + @ParameterizedTest + @ValueSource(strings = {"missing", "tampered", "outsider", "accepted", "away"}) + void refusedMercenaryAgreementsDoNotActivateOrReplaceAnExistingContract(String reason) { + MercenaryCompany company = company(0); + Faction hirer = fixture.saved("hirer", "Hirer"); + hirer.getOrCreateMainGuild(); + var offer = company.getContractHandler().offer(hirer, ContractTerms.defaults()); + assertTrue(offer.ok(), offer.message()); + MercenaryContract contract = offer.contract(); + ItemStack book = ContractBook.agreementBook(contract); + Player signer = player(reason.equals("outsider") ? "Stranger" : "Hirer"); + if (reason.equals("away")) + when(net.tfminecraft.simplefactions.rest.RestServer.getProvince(signer)).thenReturn(99); + if (reason.equals("missing")) company.getContractHandler().remove(contract.getId()); + if (reason.equals("tampered")) + edit(book, meta -> meta.getPersistentDataContainer().remove(Keys.SECONDARY_STRING_KEY)); + if (reason.equals("accepted")) + assertTrue(company.getContractHandler().accept(contract.getId(), hirer, "Hirer").ok()); + boolean wasActive = contract.isActive(); + signer.getInventory().setItem(0, book); + assertTrue(sign(signer, book).isCancelled()); + fixture.ui.runTasks(); + assertEquals(wasActive, contract.isActive()); + assertTrue(messages(signer).stream().anyMatch(m -> m.startsWith("§c")), reason); + assertNotEquals(Material.WRITTEN_BOOK, signer.getInventory().getItem(0).getType()); + } + + @ParameterizedTest + @ValueSource(strings = {"online", "offline", "absent"}) + void acceptingMercenaryServiceActivatesTheOfferAndCreatesASignedAgreement(String notification) { + MercenaryCompany company = company(0); + Faction hirer = fixture.saved("hirer", "Hirer"); + Guild treasury = hirer.getOrCreateMainGuild(); + treasury.setBank(new Bank(treasury, 1000, null)); + company.getGuild().setBank(new Bank(company.getGuild(), 500, null)); + Player signer = player("Hirer"); + Player captain = player("Captain"); + if (!notification.equals("absent")) { + when(Bukkit.getPlayer("Captain")).thenReturn(captain); + when(captain.isOnline()).thenReturn(notification.equals("online")); + } + var offer = company.getContractHandler().offer(hirer, ContractTerms.defaults()); + assertTrue(offer.ok(), offer.message()); + MercenaryContract contract = offer.contract(); + ItemStack book = ContractBook.agreementBook(contract); + signer.getInventory().setItem(0, book); + sign(signer, book); + assertTrue(contract.isActive()); + assertEquals(1, company.getContractHandler().getActive().size()); + assertEquals(1000, treasury.getBank().getWealth()); + assertEquals(500, company.getGuild().getBank().getWealth()); + fixture.ui.runTasks(); + ItemStack signed = signer.getInventory().getItem(0); + assertEquals(Material.WRITTEN_BOOK, signed.getType()); + BookMeta meta = (BookMeta) signed.getItemMeta(); + assertEquals("Captain and Hirer", meta.getAuthor()); + assertTrue(meta.getTitle().contains("Mercenary Contract")); + assertEquals(6, meta.getPageCount()); + verify(signer).sendMessage(contains("entered your service")); + if (notification.equals("online")) + verify(captain).sendMessage(contains("entered your service")); + else verify(captain, never()).sendMessage(contains("entered your service")); + } + + @ParameterizedTest + @ValueSource( + strings = { + "review", + "offer", + "receipt", + "contract_review", + "contract_offer", + "contract_receipt", + "expired_loan", + "expired_contract" + }) + void everyDeferredBookStagePreservesANewlySelectedStack(String stage) { + Player signer = leader; + ItemStack book; + if (stage.startsWith("contract") || stage.equals("expired_contract")) { + MercenaryCompany company = company(0); + signer = player("Captain"); + if (stage.equals("contract_receipt")) { + Faction hirer = fixture.saved("hirer", "Hirer"); + hirer.getOrCreateMainGuild(); + signer = player("Hirer"); + var offer = company.getContractHandler().offer(hirer, ContractTerms.defaults()); + assertTrue(offer.ok(), offer.message()); + book = ContractBook.agreementBook(offer.contract()); + } else if (stage.equals("contract_offer")) { + book = ContractBook.reviewBook(company, ContractTerms.defaults()); + } else book = ContractBook.draftBook(company); + } else if (stage.equals("receipt")) { + Guild borrower = borrower(); + signer = player("Borrower"); + book = loanBook(3, borrower); + } else book = loanBook(stage.equals("offer") ? 2 : 1, null); + if (stage.startsWith("expired")) + edit( + book, + meta -> meta.getPersistentDataContainer().set(Keys.LONG, PersistentDataType.LONG, 1L)); + signer.getInventory().setItem(0, book); + signer.getInventory().setItem(1, new ItemStack(Material.DIAMOND, 3)); + assertTrue(sign(signer, book).isCancelled()); + signer.getInventory().setHeldItemSlot(1); + fixture.ui.runTasks(); + assertEquals(Material.DIAMOND, signer.getInventory().getItem(1).getType()); + assertEquals(3, signer.getInventory().getItem(1).getAmount()); + ItemStack replacement = signer.getInventory().getItem(0); + if (stage.endsWith("receipt")) assertEquals(Material.WRITTEN_BOOK, replacement.getType()); + else { + assertEquals(Material.WRITABLE_BOOK, replacement.getType()); + BookMeta meta = (BookMeta) replacement.getItemMeta(); + if (stage.startsWith("expired")) assertFalse(meta.hasPages()); + else if (stage.startsWith("contract")) + assertEquals(ContractBook.STAGE_REVIEW, ContractBook.stage(meta)); + else + assertEquals( + stage.equals("offer") ? 3 : 2, + meta.getPersistentDataContainer().get(Keys.INT, PersistentDataType.INTEGER)); + } + } + + @ParameterizedTest + @ValueSource(strings = {"offline", "removed", "different_book", "amount_changed"}) + void aDelayedBookUpdateRequiresTheOriginalUnchangedStackAndAnOnlinePlayer(String changed) { + ItemStack draft = LoanBook.getBaseBook(lender); + leader.getInventory().setItem(0, draft); + sign(leader, draft); + switch (changed) { + case "offline" -> when(leader.isOnline()).thenReturn(false); + case "removed" -> leader.getInventory().setItem(0, null); + case "different_book" -> { + ItemStack personal = new ItemStack(Material.WRITABLE_BOOK); + edit(personal, meta -> meta.setPages("My private notes")); + leader.getInventory().setItem(0, personal); + } + case "amount_changed" -> draft.setAmount(2); + } + fixture.ui.runTasks(); + ItemStack result = leader.getInventory().getItem(0); + if (changed.equals("removed")) assertNull(result); + else if (changed.equals("different_book")) + assertEquals("My private notes", ((BookMeta) result.getItemMeta()).getPage(1)); + else { + assertEquals( + 1, + ((BookMeta) result.getItemMeta()) + .getPersistentDataContainer() + .get(Keys.INT, PersistentDataType.INTEGER)); + assertEquals(changed.equals("amount_changed") ? 2 : 1, result.getAmount()); + } + } + + @ParameterizedTest + @ValueSource(ints = {-1, 40}) + void loanBookSignedInTheOffHandNeverReplacesTheMainHandItem(int eventSlot) { + ItemStack book = LoanBook.getBaseBook(lender); + leader.getInventory().setItem(0, new ItemStack(Material.NETHERITE_SWORD)); + leader.getInventory().setItem(40, book); + BookMeta meta = (BookMeta) book.getItemMeta(); + PlayerEditBookEvent event = new PlayerEditBookEvent(leader, eventSlot, meta, meta, true); + listener.signBook(event); + assertTrue(event.isCancelled()); + fixture.ui.runTasks(); + assertEquals(Material.NETHERITE_SWORD, leader.getInventory().getItem(0).getType()); + BookMeta review = (BookMeta) leader.getInventory().getItem(40).getItemMeta(); + assertEquals(2, review.getPersistentDataContainer().get(Keys.INT, PersistentDataType.INTEGER)); + } + + private Guild borrower() { + lender.setBank(new Bank(lender, 10000, null)); + Faction other = fixture.saved("borrowers", "Borrower"); + Guild borrower = other.getOrCreateMainGuild(); + borrower.setBank(new Bank(borrower, 100, null)); + return borrower; + } + + private ItemStack loanBook(int stage, Guild borrower) { + Loan loan = + new Loan("draft", 500, lender, borrower, System.currentTimeMillis(), 30, 6, 2, true); + return switch (stage) { + case 1 -> LoanBook.getBaseBook(lender); + case 2 -> LoanBook.getEstimatedBook(loan); + default -> LoanBook.getLoanBook(loan); + }; + } + + private MercenaryCompany company(int seconds) { + Guild guild = fixture.guild(faction, "company", "Captain"); + fixture.provinceData.put( + 10, new net.tfminecraft.simplefactions.map.provinces.Province(10, "PLAINS", 50)); + guild.setCapital(10, false); + MercenaryCompany company = + new MercenaryCompany( + guild, "River Guard", fixture.regiment("company", false, 1, 0), seconds); + guild.setCompany(company); + return company; + } + + private void edit(ItemStack book, Consumer<BookMeta> edit) { + BookMeta meta = (BookMeta) book.getItemMeta(); + edit.accept(meta); + book.setItemMeta(meta); + } + + private Player player(String name) { + Player result = fixture.player(name); + int[] selected = {0}; + PlayerInventory inventory = result.getInventory(); + when(inventory.getHeldItemSlot()).thenAnswer(call -> selected[0]); + doAnswer( + call -> { + selected[0] = call.getArgument(0); + return null; + }) + .when(inventory) + .setHeldItemSlot(anyInt()); + when(inventory.getItemInMainHand()).thenAnswer(call -> inventory.getItem(selected[0])); + doAnswer( + call -> { + inventory.setItem(selected[0], call.getArgument(0)); + return null; + }) + .when(inventory) + .setItemInMainHand(any()); + return result; + } + + private <T> MockedStatic<T> scoped(Class<T> type) { + MockedStatic<T> scope = mockStatic(type); + scopes.add(scope); + return scope; + } + + private List<String> messages(Player player) { + return mockingDetails(player).getInvocations().stream() + .filter( + call -> + call.getMethod().getName().equals("sendMessage") + && call.getArguments().length == 1 + && call.getArgument(0) instanceof String) + .map(call -> (String) call.getArgument(0)) + .toList(); + } + + private PlayerEditBookEvent sign(Player player, ItemStack book) { + BookMeta meta = (BookMeta) book.getItemMeta(); + PlayerEditBookEvent event = new PlayerEditBookEvent(player, 0, meta, meta, true); + listener.signBook(event); + return event; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerLoanBookTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerLoanBookTest.java index 518c3ef9..02e69c25 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerLoanBookTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/PlayerManagerLoanBookTest.java @@ -72,6 +72,7 @@ void setUp() { when(borrower.getId()).thenReturn("borrower"); when(borrower.getBank()).thenReturn(borrowerBank); when(player.getName()).thenReturn("Borrower"); + when(player.getInventory()).thenReturn(mock(org.bukkit.inventory.PlayerInventory.class)); } @AfterEach diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/ProvinceEconomyCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/ProvinceEconomyCoverageTest.java new file mode 100644 index 00000000..6200f834 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/ProvinceEconomyCoverageTest.java @@ -0,0 +1,655 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.enums.Terrain; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.branch.Branch; +import net.tfminecraft.simplefactions.guild.income.EconomicPreview; +import net.tfminecraft.simplefactions.guild.income.IncomePreviewContext; +import net.tfminecraft.simplefactions.guild.network.RailRoutes; +import net.tfminecraft.simplefactions.guild.network.TradeGraph; +import net.tfminecraft.simplefactions.guild.network.TradeGraphBuilder; +import net.tfminecraft.simplefactions.installation.Installation; +import net.tfminecraft.simplefactions.installation.InstallationKind; +import net.tfminecraft.simplefactions.loaders.BranchLoader; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.map.provinces.ProvinceDataEntry; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.war.resolution.PillageTradeHit; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ProvinceEconomyCoverageTest { + private FactionDomainFixture fixture; + private ProvinceManager manager; + private Map<Integer, Province> provinces; + private Map<Terrain, Double> oldCarry; + private double oldMaxUpkeep; + private TradeGraph oldGraph; + + @BeforeEach + void setup() { + oldCarry = new HashMap<>(Cache.tradeCarry); + oldMaxUpkeep = Cache.maxTradeUpkeep; + oldGraph = TradeGraph.live(); + fixture = new FactionDomainFixture(); + manager = new ProvinceManager(); + provinces = new LinkedHashMap<>(); + manager.start(provinces); + when(fixture.ui.plugin.getProvinceManager()).thenReturn(manager); + Cache.tradeCarry.clear(); + Cache.tradeCarry.put(Terrain.PLAINS, 0.5); + Cache.maxTradeUpkeep = 0.75; + } + + @AfterEach + void close() { + try { + if (fixture != null) fixture.close(); + } finally { + Cache.tradeCarry.clear(); + Cache.tradeCarry.putAll(oldCarry); + Cache.maxTradeUpkeep = oldMaxUpkeep; + TradeGraph.setLiveForTests(oldGraph); + } + } + + private Province province(int id) { + Province province = new Province(id, "PLAINS", 50); + provinces.put(id, province); + return province; + } + + private void productiveBranch() { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("industry.allowed-types", List.of("realm", "guild")); + yaml.set("industry.group", 0); + yaml.set( + "industry.modifiers", + List.of("TRADE_POWER 10 2", "PRODUCTION 10 5", "TRADE_CARRY 1 0", "TRADE_UPKEEP 0 0")); + BranchLoader.map.clear(); + BranchLoader.map.put( + "industry", new Branch("industry", yaml.getConfigurationSection("industry"))); + } + + @Test + void tradeModifiersRedistributeAProvincesIncomeWithoutCreatingAdditionalProsperity() { + fixture.lawGroup("economy", Map.of()); + Faction first = fixture.saved("first", "Alice"); + Faction second = fixture.saved("second", "Bob"); + var bonus = + fixture.law( + "economy", + "merchant_boost", + Map.of("effects.domestic_guilds.modifiers", List.of("TRADE_POWER(100)"))); + first.getLawHandler().getGroup("economy").setCurrent(bonus); + Guild boosted = first.getOrCreateMainGuild(); + Guild ordinary = second.getOrCreateMainGuild(); + Province province = province(1); + province.setData(boosted.getId(), new ProvinceDataEntry(boosted, 10, 0)); + province.setData(ordinary.getId(), new ProvinceDataEntry(ordinary, 10, 0)); + province.setProsperity(90); + assertEquals(20, province.getGuildTrade(boosted)); + assertEquals(10, province.getGuildTrade(ordinary)); + assertEquals(60, province.getIncome(boosted), 0.000001); + assertEquals(30, province.getIncome(ordinary), 0.000001); + assertEquals(1, province.getTradeShare(boosted) + province.getTradeShare(ordinary), 0.000001); + assertEquals(90, province.getIncome(boosted) + province.getIncome(ordinary), 0.000001); + } + + @Test + void readOnlyAndStoredIncomeBothApplyPillageWhileOnlyTheStoredReadChangesTheBreakdown() { + productiveBranch(); + Faction faction = fixture.saved("home", "Alice"); + Guild guild = faction.getOrCreateMainGuild(); + Province province = province(1); + province.setData(guild.getId(), new ProvinceDataEntry(guild, 10, 0)); + province.setProsperity(100); + fixture.provincesEnabled(true); + guild.getTradeBreakdown().setIncome(7); + PillageTradeHit.attach(guild, -25, 1); + assertEquals(75, manager.getIncome(guild, false), 0.000001); + assertEquals(7, guild.getTradeBreakdown().getIncome()); + assertEquals(75, manager.getIncome(guild, true), 0.000001); + assertEquals(75, guild.getTradeBreakdown().getIncome()); + assertEquals(75, manager.getGrossTradeIncome(guild), 0.000001); + } + + @Test + void singleGuildRecalculationStoresIncomeFromTheNewProsperity() { + productiveBranch(); + Faction faction = fixture.saved("home", "Alice"); + Guild guild = faction.getOrCreateMainGuild(); + Province province = province(1); + faction.addProvince(1); + faction.setCapital(1, true); + fixture.provincesEnabled(true); + assertEquals(0, province.getProsperity()); + manager.recalculateForSingleGuild(guild, true); + assertTrue(province.getProsperity() > 0); + assertEquals(manager.getGrossTradeIncome(guild), guild.getTradeBreakdown().getIncome(), 0.01); + assertTrue(guild.getTradeBreakdown().getIncome() > 0); + } + + @Test + void aStrongerForeignTradeRouteReplacesTheEarlierRouteEvenBelowItsBonusedStoredValue() { + productiveBranch(); + Faction owner = fixture.saved("owner", "Alice"); + Faction foreign = fixture.saved("foreign", "Bob"); + Guild guild = foreign.getOrCreateMainGuild(); + Province province = province(1); + owner.addProvince(1); + province.calculateTrade(manager, guild, 8, 0); + double stored = province.getStoredGuildTrade(guild); + assertTrue(stored > 8); + assertEquals(8, province.getRawGuildTrade(guild), 0.000001); + double stronger = (stored + 8) / 2; + province.calculateTrade(manager, guild, stronger, 0); + assertEquals(stronger, province.getRawGuildTrade(guild), 0.000001); + assertTrue(province.getStoredGuildTrade(guild) > stored); + } + + @ParameterizedTest + @ValueSource(ints = {-100, -200}) + void zeroOrNegativeTradeModifiersCannotStealIncomeFromAnIndependentCompetitor(int amount) { + fixture.lawGroup("economy", Map.of()); + Faction reduced = fixture.saved("reduced", "Alice"); + Faction ordinary = fixture.saved("ordinary", "Bob"); + var penalty = + fixture.law( + "economy", + "reduction", + Map.of("effects.domestic_guilds.modifiers", List.of("TRADE_POWER(" + amount + ")"))); + reduced.getLawHandler().getGroup("economy").setCurrent(penalty); + Guild first = reduced.getOrCreateMainGuild(); + Guild second = ordinary.getOrCreateMainGuild(); + Province province = province(1); + province.setData(first.getId(), new ProvinceDataEntry(first, 10, 0)); + province.setProsperity(80); + assertEquals(0, province.getTotalTrade()); + assertEquals(0, province.getIncome(first)); + assertEquals(0, province.getTradeShare(first)); + assertEquals(0, province.getTradeShare(second)); + province.setData(second.getId(), new ProvinceDataEntry(second, 10, 0)); + assertEquals(10, province.getTotalTrade()); + assertEquals(0, province.getGuildTrade(first)); + assertEquals(80, province.getIncome(second)); + assertEquals(1, province.getTradeShare(second)); + } + + @Test + void provinceTerrainIdsUseTheSameCaseRulesInEveryServerLocale() { + Locale original = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertEquals(Terrain.PLAINS, new Province(1, "plains", 50).getTerrain()); + } finally { + Locale.setDefault(original); + } + } + + @Test + void unknownTerrainAndUncachedGuildDataDoNotCreateTradeOrMutateTheProvince() { + Faction home = fixture.saved("home", "Alice"); + Guild guild = home.getOrCreateMainGuild(); + Province unknown = new Province(7, "not-configured", 20, 15, -12); + assertEquals(Terrain.UNKNOWN, unknown.getTerrain()); + assertEquals(20, unknown.getFertility()); + assertEquals(15, unknown.getCenterX()); + assertEquals(-12, unknown.getCenterZ()); + assertEquals(Terrain.UNKNOWN, new Province(8, null, 0).getTerrain()); + assertFalse(new Province().isValid()); + ProvinceDataEntry empty = unknown.getData(guild.getId()); + assertSame(guild, empty.getGuild()); + assertFalse(empty.isConsidered()); + assertTrue(unknown.getAllData().isEmpty()); + assertNull(unknown.getData("missing")); + empty.setProduction(2); + assertTrue(empty.isConsidered()); + assertEquals(0, unknown.getGuildProduction(guild)); + unknown.setData(guild.getId(), empty); + assertSame(empty, unknown.getData(guild.getId())); + assertEquals(2, unknown.getGuildProduction(guild)); + } + + @Test + void snapshotCopiesPreserveProvinceMetadataAndOwnTheirMutableEconomicEntries() { + Faction home = fixture.saved("home", "Alice"); + Guild guild = home.getOrCreateMainGuild(); + Province live = new Province(1, "PLAINS", 72, 5, -6); + provinces.put(1, live); + live.addNeighbour(2); + ProvinceDataEntry original = new ProvinceDataEntry(guild, 10, 6, 3); + live.setData(guild.getId(), original); + live.setProsperity(42); + ProvinceManager snapshot = manager.createSnapshotShell(); + Province copy = snapshot.get(1); + assertEquals(Set.of(2), copy.getNeighbours()); + assertEquals(72, copy.getFertility()); + assertEquals(5, copy.getCenterX()); + assertEquals(-6, copy.getCenterZ()); + assertTrue(copy.getAllData().isEmpty()); + assertEquals(0, copy.getProsperity()); + assertThrows(UnsupportedOperationException.class, () -> copy.getNeighbours().add(3)); + snapshot.copyAllDataFrom(manager); + ProvinceDataEntry copied = copy.getData(guild.getId()); + assertNotSame(original, copied); + assertEquals(10, copied.getTrade()); + assertEquals(6, copied.getProduction()); + assertEquals(3, copied.getDistance()); + assertTrue(copied.isConsidered()); + assertEquals(42, copy.getProsperity()); + copied.setTrade(999); + copied.setProduction(888); + copied.setDistance(0); + copy.setProsperity(100); + assertEquals(10, original.getTrade()); + assertEquals(6, original.getProduction()); + assertEquals(3, original.getDistance()); + assertEquals(42, live.getProsperity()); + province(2).setProsperity(50); + snapshot.copyAllDataFrom(manager); + assertFalse(snapshot.contains(2)); + assertFalse(snapshot.get(2).isValid()); + List<Province> returned = snapshot.getProvinces(); + returned.clear(); + assertTrue(snapshot.contains(1)); + assertEquals(1, snapshot.getProvinces().size()); + } + + @Test + void dirtyRecalculationReplacesEconomicsOnlyWhenTheModelChanges() { + productiveBranch(); + Faction home = fixture.saved("home", "Alice"); + Guild guild = home.getOrCreateMainGuild(); + Province source = province(1); + home.addProvince(1); + home.setCapital(1, true); + fixture.guild(home, "landless", "Bob"); + fixture.provincesEnabled(true); + manager.recalculateIfNeeded(); + ProvinceDataEntry first = source.getData(guild.getId()); + double prosperity = source.getProsperity(); + assertTrue(prosperity > 0); + assertEquals(prosperity, guild.getTradeBreakdown().getIncome(), 0.01); + manager.recalculateIfNeeded(); + assertSame(first, source.getData(guild.getId())); + manager.markDirty(); + manager.recalculateIfNeeded(); + assertNotSame(first, source.getData(guild.getId())); + assertEquals(prosperity, source.getProsperity(), 0.01); + assertEquals(prosperity, guild.getTradeBreakdown().getIncome(), 0.01); + } + + @Test + void disabledIncomeQueriesAndRecalculationRespectWhetherSavingWasRequested() { + productiveBranch(); + Faction home = fixture.saved("home", "Alice"); + Guild guild = home.getOrCreateMainGuild(); + Province source = province(1); + home.addProvince(1); + home.setCapital(1, true); + source.setData(guild.getId(), new ProvinceDataEntry(guild, 10, 3)); + source.setProsperity(40); + guild.getTradeBreakdown().setIncome(7); + manager.recalculate(); + manager.recalculateQuiet(null, null); + manager.recalculateForSingleGuild(guild, false); + assertEquals(40, source.getProsperity()); + assertEquals(10, source.getStoredGuildTrade(guild)); + assertEquals(0, manager.getGrossTradeIncome(guild)); + assertEquals(0, manager.getIncome(guild, false)); + assertEquals(7, guild.getTradeBreakdown().getIncome()); + assertEquals(0, manager.getIncome(guild, true)); + assertEquals(0, guild.getTradeBreakdown().getIncome()); + fixture.provincesEnabled(true); + Guild noCapital = fixture.guild(home, "landless", "Bob"); + manager.recalculateForSingleGuild(noCapital, true); + assertEquals(40, source.getProsperity()); + manager.clearGuildData(null); + assertEquals(10, source.getStoredGuildTrade(guild)); + manager.clearGuildData(guild.getId()); + assertTrue(source.getAllData().isEmpty()); + } + + @Test + void quietRecalculationDropsAbsentGuildsAndKeepsLiveBreakdownsUntouched() { + productiveBranch(); + Faction home = fixture.saved("home", "Alice"); + Guild guild = home.getOrCreateMainGuild(); + Guild idle = fixture.guild(home, "idle", "Bob"); + Province source = province(1); + home.addProvince(1); + home.setCapital(1, true); + source.setData("gone", new ProvinceDataEntry(guild, 100, 100)); + source.setData(null, new ProvinceDataEntry(guild, 100, 100)); + guild.getTradeBreakdown().setIncome(31); + fixture.provincesEnabled(true); + manager.recalculateQuiet(List.of(guild, idle), List.of(home)); + assertEquals(Set.of(guild.getId()), source.getAllData().keySet()); + assertTrue(source.getProsperity() > 0); + assertEquals(31, guild.getTradeBreakdown().getIncome()); + assertTrue(manager.getIncome(guild, false) > 0); + manager.clearPreviewLists(); + manager.recalculateQuiet(null, null); + assertTrue(source.getAllData().isEmpty()); + assertEquals(0, source.getProsperity()); + assertEquals(31, guild.getTradeBreakdown().getIncome()); + manager.clearPreviewLists(); + } + + @Test + void savedMissingCapitalCannotSeedTradeIntoAnUnrelatedProvince() { + productiveBranch(); + Faction home = fixture.saved("home", "Alice"); + Guild guild = fixture.guild(home, "missing_capital", "Bob"); + guild.setCapital(999); + Province province = province(1); + province.setData(guild.getId(), new ProvinceDataEntry(guild, 40, 12)); + fixture.provincesEnabled(true); + manager.recalculateForSingleGuild(guild, false); + assertTrue(province.getAllData().isEmpty()); + assertEquals(0, province.getProsperity()); + } + + @Test + void tariffPreviewIsTargetedAndLeavesRatesBalancesAndBreakdownsUntouched() { + productiveBranch(); + Faction owner = fixture.saved("owner", "Alice"); + Faction first = fixture.saved("first", "Bob"); + Faction second = fixture.saved("second", "Cara"); + Faction subject = fixture.saved("subject", "Dave"); + fixture.subject(owner, subject); + Guild one = first.getOrCreateMainGuild(); + Guild two = second.getOrCreateMainGuild(); + Guild domestic = owner.getOrCreateMainGuild(); + Guild vassal = subject.getOrCreateMainGuild(); + Guild absent = fixture.guild(first, "absent", "Eve"); + Province held = province(1); + Province foreign = province(2); + province(3); + owner.addProvince(1); + second.addProvince(2); + held.setData(one.getId(), new ProvinceDataEntry(one, 10, 0)); + held.setData(two.getId(), new ProvinceDataEntry(two, 10, 0)); + held.setData(domestic.getId(), new ProvinceDataEntry(domestic, 10, 0)); + held.setData(vassal.getId(), new ProvinceDataEntry(vassal, 10, 0)); + held.setProsperity(100); + foreign.setData(one.getId(), new ProvinceDataEntry(one, 10, 0)); + foreign.setProsperity(1000); + owner.getTaxHandler().setTariffs(10); + owner.getTaxHandler().setSpecificTax(TaxTarget.TARIFFS, second.getId(), 30); + one.getTradeBreakdown().setIncome(123); + Map<Guild, Double> base = manager.previewTariffRateChange(owner, null, 20); + assertEquals(-2.5, base.get(one), 0.000001); + assertEquals(0, base.get(two)); + assertEquals(0, base.get(domestic)); + assertEquals(0, base.get(vassal)); + assertEquals(0, base.get(absent)); + Map<Guild, Double> specific = manager.previewTariffRateChange(owner, "SECOND", 15); + assertEquals(3.75, specific.get(two), 0.000001); + assertEquals(0, specific.get(one)); + assertEquals(10, owner.getTaxHandler().getTariffs()); + assertEquals(30, owner.getTaxHandler().getSpecificTax(TaxTarget.TARIFFS, second.getId())); + assertEquals(123, one.getTradeBreakdown().getIncome()); + assertEquals(100, held.getProsperity()); + } + + @Test + void storedIncomeSeparatesForeignTariffsFromDomesticAndWaterIncome() { + productiveBranch(); + Faction home = fixture.saved("home", "Alice"); + Faction foreign = fixture.saved("foreign", "Bob"); + Guild guild = home.getOrCreateMainGuild(); + Province own = province(1); + Province other = province(2); + Province wild = province(3); + Province sea = new Province(4, "SEA", 0); + provinces.put(4, sea); + province(5); + home.addProvince(1); + foreign.addProvince(2); + for (Province province : List.of(own, other, wild, sea)) { + province.setData(guild.getId(), new ProvinceDataEntry(guild, 10, 0)); + province.setProsperity(100); + } + foreign.getTaxHandler().setTariffs(20); + fixture.provincesEnabled(true); + assertEquals(300, manager.getIncome(guild)); + var breakdown = guild.getTradeBreakdown(); + assertEquals(300, breakdown.getIncome()); + assertEquals(100, breakdown.getIncomeByFaction(home)); + assertEquals(100, breakdown.getIncomeByFaction(foreign)); + assertEquals( + 100 * foreign.getTaxRate(TaxTarget.TARIFFS, home.getId(), true) / 100, + breakdown.getTariffsByFaction(foreign), + 0.01); + assertEquals(0, breakdown.getTariffsByFaction(home)); + assertEquals(40, breakdown.getTradePower()); + assertEquals(300, manager.getGrossTradeIncome(guild)); + double tariffs = breakdown.getTariffs(); + own.calculateTariffs(); + own.calculateTariffs(); + assertEquals(10, own.getStoredGuildTrade(guild)); + assertEquals(0, own.getGuildProduction(guild)); + assertEquals(100, own.getProsperity()); + assertEquals(300, breakdown.getIncome()); + assertEquals(tariffs, breakdown.getTariffs()); + manager.recalculateQuiet(List.of(guild), List.of(home, foreign)); + own.setData(guild.getId(), new ProvinceDataEntry(guild, 10, 0)); + other.setData(guild.getId(), new ProvinceDataEntry(guild, 10, 0)); + wild.setData(guild.getId(), new ProvinceDataEntry(guild, 10, 0)); + for (Province province : List.of(own, other, wild)) province.setProsperity(100); + assertEquals(300, manager.getIncome(guild)); + assertEquals(100, breakdown.getIncomeByFaction(foreign)); + manager.clearPreviewLists(); + } + + @Test + void newTradeAndProductionSeedsSpreadToNeighborsAndNeverReplaceStrongerExistingData() { + productiveBranch(); + Faction home = fixture.saved("home", "Alice"); + Guild guild = home.getOrCreateMainGuild(); + Province first = province(1); + Province second = province(2); + first.addNeighbour(2); + second.addNeighbour(1); + first.seedTrade(manager, guild, 20); + assertEquals(20, first.getRawGuildTrade(guild)); + assertEquals(10, second.getRawGuildTrade(guild)); + assertEquals(1, second.getData(guild.getId()).getDistance()); + first.seedTrade(manager, guild, 1); + assertEquals(20, first.getRawGuildTrade(guild)); + first.clearData(); + second.clearData(); + first.seedProduction(manager, guild, 10); + assertEquals(10, first.getGuildProduction(guild)); + assertEquals(10 * Math.sqrt(0.5) * 0.05, second.getGuildProduction(guild), 0.000001); + assertEquals(1, second.getData(guild.getId()).getDistance()); + first.seedProduction(manager, guild, 1); + assertEquals(10, first.getGuildProduction(guild)); + first.clearData(); + second.clearData(); + first.calculateProduction(manager, guild, null, 0); + assertTrue(first.getGuildProduction(guild) > 0); + assertTrue(second.getGuildProduction(guild) > 0); + assertEquals(0, first.getData(guild.getId()).getTrade()); + } + + @Test + void vassalAndOverlordTradeUsesTheCorrectRegionalLawsWithoutAddingUnrelatedEffects() { + fixture.lawGroup( + "economy", + Map.of( + "effects.domestic_guilds.vassal_territory", List.of("TRADE_POWER(10)"), + "effects.overlord_guilds.our_territory", List.of("TRADE_POWER(20)"), + "effects.vassal_guilds.our_territory", List.of("TRADE_POWER(30)"), + "effects.domestic_guilds.our_territory", List.of("TRADE_POWER(40)"), + "effects.foreign_guilds.our_territory", List.of("TRADE_POWER(50)"))); + Faction overlord = fixture.saved("overlord", "Alice"); + Faction vassal = fixture.saved("vassal", "Bob"); + fixture.subject(overlord, vassal); + Province capital = province(1); + Province subject = province(2); + overlord.addProvince(1); + vassal.addProvince(2); + Guild master = overlord.getOrCreateMainGuild(); + Guild local = vassal.getOrCreateMainGuild(); + for (Province province : List.of(capital, subject)) { + province.setData(master.getId(), new ProvinceDataEntry(master, 10, 0)); + province.setData(local.getId(), new ProvinceDataEntry(local, 10, 0)); + province.setProsperity(100); + } + assertEquals(13, subject.getGuildTrade(master), 0.000001); + assertEquals(13, capital.getGuildTrade(local), 0.000001); + assertEquals(14, capital.getGuildTrade(master), 0.000001); + assertEquals(14, subject.getGuildTrade(local), 0.000001); + assertEquals(100, subject.getIncome(master) + subject.getIncome(local), 0.000001); + var agreement = + fixture.relationType( + "market_access", Map.of("trade-effects-them", List.of("TRADE_POWER(15)"))); + vassal.getDiplomacyHandler().setTradeRelation(overlord, agreement); + assertEquals(14.5, subject.getGuildTrade(master), 0.000001); + assertEquals(100, subject.getIncome(master) + subject.getIncome(local), 0.000001); + } + + @Test + void previewAndLiveGraphAccessUseTheAppropriateFactionSnapshot() { + productiveBranch(); + Faction home = fixture.saved("home", "Alice"); + Faction foreign = fixture.saved("foreign", "Bob"); + Guild guild = home.getOrCreateMainGuild(); + Province capital = province(1); + Province destination = province(2); + province(3); + home.addProvince(1); + foreign.addProvince(2); + home.setCapital(1, true); + var treaty = fixture.relationType("open_stations", Map.of("installation-access", 1.0)); + home.getDiplomacyHandler().setTradeRelation(foreign, treaty); + Installation start = + new Installation("start", "Start", InstallationKind.TRAIN_STATION, 1, 0, 0, 1L); + Installation end = new Installation("end", "End", InstallationKind.TRAIN_STATION, 2, 0, 0, 1L); + Installation missingOwner = + new Installation("gone", "Gone", InstallationKind.TRAIN_STATION, 3, 0, 0, 1L); + TradeGraph graph = + TradeGraphBuilder.build( + List.of( + new TradeGraphBuilder.Site("HOME", start, true), + new TradeGraphBuilder.Site("FOREIGN", end, true), + new TradeGraphBuilder.Site("missing", missingOwner, true)), + Map.of(), + (left, right) -> Optional.of(new RailRoutes.Route(0, List.of())), + point -> 0); + manager.setHighwayOverride(graph); + fixture.provincesEnabled(true); + manager.recalculateForSingleGuild(guild, false); + assertTrue(capital.getStoredGuildTrade(guild) > 0); + assertTrue(destination.getStoredGuildTrade(guild) > 0); + double available = destination.getStoredGuildTrade(guild); + manager.recalculateQuiet(List.of(guild), List.of(home, foreign)); + assertEquals(available, destination.getStoredGuildTrade(guild), 0.000001); + manager.recalculateQuiet(List.of(guild), List.of(home)); + assertEquals(0, destination.getStoredGuildTrade(guild)); + manager.clearPreviewLists(); + manager.recalculateForSingleGuild(guild, false); + assertEquals(available, destination.getStoredGuildTrade(guild), 0.000001); + ProvinceManager snapshot = manager.createSnapshotShell(); + manager.setHighwayOverride(null); + TradeGraph.setLiveForTests(null); + snapshot.recalculateForSingleGuild(guild, false); + assertEquals(available, snapshot.get(2).getStoredGuildTrade(guild), 0.000001); + } + + @Test + void incomePreviewsArePrivateCopiesAndThePerTickCacheRefreshesAtTheNextTick() throws Exception { + Field shared = EconomicPreview.class.getDeclaredField("shared"); + Field sharedTick = EconomicPreview.class.getDeclaredField("sharedTick"); + shared.setAccessible(true); + sharedTick.setAccessible(true); + Object oldPrepared = shared.get(null); + int oldTick = sharedTick.getInt(null); + try { + productiveBranch(); + fixture.lawGroup("economy", Map.of()); + Faction home = fixture.saved("home", "Alice"); + Guild guild = home.getOrCreateMainGuild(); + Province province = province(1); + home.addProvince(1); + home.setCapital(1, true); + fixture.guild(home, "landless", "Bob"); + fixture.provincesEnabled(true); + manager.recalculate(); + double prosperity = province.getProsperity(); + double income = guild.getTradeBreakdown().getIncome(); + when(Bukkit.getCurrentTick()).thenReturn(100, 100, 101); + shared.set(null, null); + var prepared = EconomicPreview.current(); + assertSame(prepared, EconomicPreview.current()); + ProvinceManager copied = EconomicPreview.copyOf(prepared); + copied.get(1).setProsperity(999); + assertEquals(prosperity, province.getProsperity()); + assertNotSame(prepared, EconomicPreview.current()); + var original = home.getLawHandler().getGroup("economy").getCurrent(); + var productiveLaw = + fixture.law( + "economy", + "production", + Map.of("effects.domestic_guilds.modifiers", List.of("PRODUCTION(100)"))); + Map<Guild, Double> delta = + manager.previewLawIncomeExact( + home, home.getLawHandler().getGroup("economy"), productiveLaw); + assertTrue(delta.get(guild) > 0); + assertSame(original, home.getLawHandler().getGroup("economy").getCurrent()); + assertEquals(prosperity, province.getProsperity()); + assertEquals(income, guild.getTradeBreakdown().getIncome()); + double beforeFavour = guild.getLedger().getNetIncome(); + double predictedFavour = + manager.previewFavourRepressIncomeExact(home, guild, true).get(guild); + assertFalse(guild.isFavoured()); + assertEquals(income, guild.getTradeBreakdown().getIncome()); + guild.setFavoured(true); + manager.recalculate(); + assertEquals(guild.getLedger().getNetIncome() - beforeFavour, predictedFavour, 0.02); + guild.setFavoured(false); + manager.recalculate(); + Branch branch = guild.getBranches().get(0); + int level = branch.getLevel(); + assertTrue(manager.previewUpgradeIncomeExact(guild, branch) > 0); + assertEquals(0, manager.previewDowngradeIncomeExact(guild, branch)); + assertEquals(level, branch.getLevel()); + branch.levelUp(); + manager.recalculate(); + double upgradedIncome = guild.getTradeBreakdown().getIncome(); + assertTrue(manager.previewDowngradeIncomeExact(guild, branch) < 0); + assertEquals(level + 1, branch.getLevel()); + assertEquals(upgradedIncome, guild.getTradeBreakdown().getIncome()); + Map<Guild, Double> projected = EconomicPreview.projectNets(EconomicPreview.copyOf(prepared)); + assertEquals(FactionManager.getAllGuilds().size(), projected.size()); + assertTrue(Double.isFinite(projected.get(guild))); + assertNull(IncomePreviewContext.current()); + } finally { + shared.set(null, oldPrepared); + sharedTick.setInt(null, oldTick); + IncomePreviewContext.clear(); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/RelocationLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/RelocationLifecycleCoverageTest.java new file mode 100644 index 00000000..73f8846e --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/RelocationLifecycleCoverageTest.java @@ -0,0 +1,312 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.*; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.request.RelocateRequest; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.utils.DisplayNameGate; +import org.bukkit.entity.Player; +import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@SuppressWarnings("deprecation") +class RelocationLifecycleCoverageTest { + private FactionDomainFixture fixture; + private Faction host, other; + private Guild guild; + private Player player, receiver; + private RelocationPrompt listener; + private final List<Runnable> deadlines = new ArrayList<>(); + private final Map<Map<Object, Object>, Map<Object, Object>> globals = new IdentityHashMap<>(); + + @BeforeEach + void setup() throws Exception { + fixture = new FactionDomainFixture(); + fixture.provincesEnabled(true); + for (var entry : + List.of( + Map.entry(RelocationPrompt.class, "pending"), + Map.entry(DisplayNameGate.class, "pending"), + Map.entry(RequestManager.class, "requests"))) { + Field field = entry.getKey().getDeclaredField(entry.getValue()); + field.setAccessible(true); + @SuppressWarnings("unchecked") + Map<Object, Object> map = (Map<Object, Object>) field.get(null); + globals.put(map, new LinkedHashMap<>(map)); + map.clear(); + } + var data = fixture.data("host", "Ruler"); + data.provinces.addAll(List.of(1, 2, 3)); + host = fixture.saved(data); + other = fixture.saved("other", "Bob"); + for (int id : List.of(1, 2, 3, 4, 5)) + fixture.provinceData.put(id, new Province(id, "PLAINS", 50)); + guild = fixture.guild(host, "traders", "Alice"); + guild.setCapital(1); + guild.getBank().setWealth(100.0); + host.getSettlementHandler().found("Old_City", 1, 0, 0); + player = fixture.player("Alice"); + receiver = fixture.player("Bob"); + listener = new RelocationPrompt(); + when(fixture.ui.scheduler.runTaskLater(any(), any(Runnable.class), anyLong())) + .thenAnswer( + call -> { + deadlines.add(call.getArgument(1)); + BukkitTask task = mock(BukkitTask.class); + when(task.getTaskId()).thenReturn(deadlines.size()); + return task; + }); + fixture.ui.tasks.clear(); + } + + @AfterEach + void close() { + try { + globals.forEach( + (map, old) -> { + map.clear(); + map.putAll(old); + }); + } finally { + fixture.close(); + } + } + + private AsyncPlayerChatEvent chat(String text) { + var event = new AsyncPlayerChatEvent(true, player, text, Set.of()); + listener.onPlayerChat(event); + return event; + } + + private void begin(int province) { + assertTrue(RelocationPrompt.begin(player, guild, host, province, false, 20)); + } + + @Test + void expiredOldPromptCannotRemoveItsReplacement() { + begin(2); + Runnable old = deadlines.getFirst(); + begin(3); + clearInvocations(player); + old.run(); + assertTrue(chat("New_City").isCancelled()); + fixture.ui.runTasks(); + assertEquals(3, guild.getCapital()); + assertEquals(80.0, guild.getBank().getWealth()); + verify(player, never()).sendMessage("§cRelocation timed out"); + } + + @Test + void anOldQueuedAnswerCannotApplyAfterAReplacementPrompt() { + begin(2); + assertTrue(chat("First_City").isCancelled()); + begin(3); + assertTrue(chat("Second_City").isCancelled()); + fixture.ui.runTasks(); + assertEquals(3, guild.getCapital()); + assertEquals(80.0, guild.getBank().getWealth()); + assertNull(host.getSettlementHandler().getByProvince(2)); + } + + @Test + void leadershipMustStillBelongToThePersonAnswering() { + begin(2); + guild.setLeader("Cara"); + assertTrue(chat("New_City").isCancelled()); + fixture.ui.runTasks(); + assertEquals(1, guild.getCapital()); + assertEquals(100.0, guild.getBank().getWealth()); + assertNull(host.getSettlementHandler().getByProvince(2)); + } + + @Test + void moneySpentWhilePromptWasOpenCannotBeSpentAgain() { + begin(2); + guild.getBank().setWealth(5.0); + assertTrue(chat("New_City").isCancelled()); + fixture.ui.runTasks(); + assertEquals(1, guild.getCapital()); + assertEquals(5.0, guild.getBank().getWealth()); + assertNull(host.getSettlementHandler().getByProvince(2)); + } + + @Test + void aRejectedNameMustPreserveTheExistingCapitalAndCity() { + var old = host.getSettlementHandler().getByProvince(1); + host.getSettlementHandler().found("Taken", 3, 0, 0); + assertFalse(RelocationPrompt.completeIntraFactionRelocate(player, guild, host, 2, "Taken", 20)); + assertEquals(1, guild.getCapital()); + assertSame(old, host.getSettlementHandler().getByProvince(1)); + assertNull(host.getSettlementHandler().getByProvince(2)); + assertEquals(100.0, guild.getBank().getWealth()); + } + + @Test + void ordinaryChatRemainsPublicAndAnExistingCityNeedsNoPrompt() { + assertFalse(RelocationPrompt.begin(player, guild, host, 1, false, 20)); + assertFalse(chat("ordinary conversation").isCancelled()); + assertTrue(deadlines.isEmpty()); + } + + @Test + void validRelocationUsesTheMainThreadAndChargesOnlyOnce() { + begin(2); + assertTrue(chat(" New_City ").isCancelled()); + assertEquals(1, guild.getCapital()); + assertEquals(100.0, guild.getBank().getWealth()); + fixture.ui.runTasks(); + assertEquals(2, guild.getCapital()); + assertEquals(80.0, guild.getBank().getWealth()); + assertNotNull(host.getSettlementHandler().getByProvince(2)); + assertNull(host.getSettlementHandler().getByProvince(1)); + verify(fixture.inventory).guildView(player, guild); + assertFalse(chat("ordinary conversation").isCancelled()); + } + + @Test + void blankInputKeepsThePromptAvailable() { + begin(2); + assertTrue(chat(" ").isCancelled()); + fixture.ui.runTasks(); + verify(player).sendMessage("§cA city name is required to relocate here"); + assertTrue(chat("New_City").isCancelled()); + fixture.ui.runTasks(); + assertEquals(2, guild.getCapital()); + } + + @Test + void softNameWarningRequiresTheSameNameTwice() { + begin(2); + assertTrue(chat("new_city").isCancelled()); + fixture.ui.runTasks(); + assertEquals(1, guild.getCapital()); + assertTrue(chat("new_city").isCancelled()); + fixture.ui.runTasks(); + assertEquals(2, guild.getCapital()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void timeoutNotifiesOnlyAnOnlinePlayer(boolean online) { + begin(2); + when(player.isOnline()).thenReturn(online); + deadlines.getFirst().run(); + assertFalse(chat("New_City").isCancelled()); + verify(player, times(online ? 1 : 0)).sendMessage("§cRelocation timed out"); + assertEquals(1, guild.getCapital()); + } + + @Test + void quitCancelsThePromptAndAlreadyQueuedWork() { + begin(2); + chat("New_City"); + listener.onPlayerQuit(new PlayerQuitEvent(player, "quit")); + fixture.ui.runTasks(); + assertEquals(1, guild.getCapital()); + assertEquals(100.0, guild.getBank().getWealth()); + assertFalse(chat("late").isCancelled()); + } + + @Test + void disconnectBeforeApplicationDoesNotMoveTheGuild() { + begin(2); + chat("New_City"); + when(player.isOnline()).thenReturn(false); + fixture.ui.runTasks(); + assertEquals(1, guild.getCapital()); + assertEquals(100.0, guild.getBank().getWealth()); + } + + @Test + void crossFactionPromptSendsTheNamedRequestWithoutChargingOrMoving() { + assertTrue(RelocationPrompt.begin(player, guild, other, 4, true, 20)); + chat("Far_City"); + fixture.ui.runTasks(); + var request = assertInstanceOf(RelocateRequest.class, RequestManager.getRequest(receiver)); + assertSame(guild, request.getSender()); + assertEquals(4, request.getNewCapital()); + assertEquals("Far_City", request.getSettlementName()); + assertSame(host, guild.getFaction()); + assertEquals(1, guild.getCapital()); + assertEquals(100.0, guild.getBank().getWealth()); + } + + @Test + void failedClaimRestoresTheOldCapitalWithoutCharging() { + var old = host.getSettlementHandler().getByProvince(1); + assertFalse( + RelocationPrompt.completeIntraFactionRelocate(player, guild, host, 4, "New_City", 20)); + assertEquals(1, guild.getCapital()); + assertSame(old, host.getSettlementHandler().getByProvince(1)); + assertEquals(100.0, guild.getBank().getWealth()); + verify(fixture.map).claim(player, host, 4, true); + } + + @Test + void successfulClaimMovesAndChargesAfterFounding() { + doAnswer( + call -> { + host.addProvince(4); + return null; + }) + .when(fixture.map) + .claim(player, host, 4, true); + assertTrue( + RelocationPrompt.completeIntraFactionRelocate(player, guild, host, 4, "New_City", 20)); + assertEquals(4, guild.getCapital()); + assertNotNull(host.getSettlementHandler().getByProvince(4)); + assertEquals(80.0, guild.getBank().getWealth()); + } + + @Test + void directRelocationRequiresTheCurrentGuildLeader() { + Player outsider = fixture.player("Outsider"); + assertFalse( + RelocationPrompt.completeIntraFactionRelocate(outsider, guild, host, 2, "New_City", 20)); + assertEquals(1, guild.getCapital()); + assertEquals(100.0, guild.getBank().getWealth()); + assertNull(host.getSettlementHandler().getByProvince(2)); + } + + @Test + void successfulNewClaimAlsoRemovesTheVacatedCity() { + doAnswer( + call -> { + host.addProvince(4); + return null; + }) + .when(fixture.map) + .claim(player, host, 4, true); + assertTrue( + RelocationPrompt.completeIntraFactionRelocate(player, guild, host, 4, "New_City", 20)); + assertNull(host.getSettlementHandler().getByProvince(1)); + assertNotNull(host.getSettlementHandler().getByProvince(4)); + } + + @Test + void anInvalidNameCannotClaimLandBeforeRelocationIsRejected() { + host.getSettlementHandler().found("Taken", 3, 0, 0); + doAnswer( + call -> { + host.addProvince(4); + return null; + }) + .when(fixture.map) + .claim(player, host, 4, true); + assertFalse(RelocationPrompt.completeIntraFactionRelocate(player, guild, host, 4, "Taken", 20)); + assertFalse(host.hasProvince(4)); + assertEquals(1, guild.getCapital()); + assertEquals(100.0, guild.getBank().getWealth()); + verify(fixture.map, never()).claim(player, host, 4, true); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/RequestLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/RequestLifecycleCoverageTest.java new file mode 100644 index 00000000..88411433 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/RequestLifecycleCoverageTest.java @@ -0,0 +1,170 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.simplefactions.diplomacy.RelationType; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.loaders.RelationLoader; +import net.tfminecraft.simplefactions.objects.request.*; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.vehicles.berth.*; +import net.tfminecraft.simplefactions.vehicles.handover.VehicleHandoverService; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; + +class RequestLifecycleCoverageTest { + private FactionDomainFixture fixture; + private Player alice, bob; + private Guild guild; + private Map<Player, Request> requests, original; + + @BeforeEach + @SuppressWarnings("unchecked") + void setup() throws Exception { + fixture = new FactionDomainFixture(); + alice = fixture.player("Alice"); + bob = fixture.player("Bob"); + guild = fixture.saved("realm", "Alice").getOrCreateMainGuild(); + var field = RequestManager.class.getDeclaredField("requests"); + field.setAccessible(true); + requests = (Map<Player, Request>) field.get(null); + original = new HashMap<>(requests); + requests.clear(); + } + + @AfterEach + void close() { + try { + requests.clear(); + requests.putAll(original); + } finally { + fixture.close(); + } + } + + private static void expire(Request request) throws Exception { + var field = Request.class.getDeclaredField("time"); + field.setAccessible(true); + field.setLong(request, 0); + } + + @Test + void acceptingAnExpiredVehicleOfferNeverInvokesTheTransfer() throws Exception { + VehicleHandoverService service = mock(VehicleHandoverService.class); + when(fixture.ui.plugin.getVehicleHandoverService()).thenReturn(service); + var request = + new VehicleHandoverRequest( + guild, + UUID.randomUUID().toString(), + "wagon", + alice.getUniqueId(), + "Alice", + bob.getUniqueId()); + expire(request); + RequestManager.addRequest(alice, bob, request); + RequestManager.accept(bob); + verify(service, never()).acceptRequest(bob); + verify(service).notifyExpired(request, bob); + assertFalse(RequestManager.hasRequest(bob)); + } + + @Test + void scheduledExpiryNotifiesTheCorrectVehicleServicesAndKeepsLiveRequests() throws Exception { + VehicleHandoverService handovers = mock(VehicleHandoverService.class); + FactionVehicleGiveService gifts = mock(FactionVehicleGiveService.class); + when(fixture.ui.plugin.getVehicleHandoverService()).thenReturn(handovers); + when(fixture.ui.plugin.getFactionVehicleGiveService()).thenReturn(gifts); + var handover = + new VehicleHandoverRequest( + guild, + UUID.randomUUID().toString(), + "wagon", + alice.getUniqueId(), + "Alice", + bob.getUniqueId()); + var gift = + new VehicleGiveConsentRequest( + guild, + "realm", + UUID.randomUUID().toString(), + "wagon", + alice.getUniqueId(), + bob.getUniqueId(), + "Bob"); + expire(handover); + expire(gift); + RequestManager.addRequest(alice, bob, handover); + RequestManager.addRequest(bob, alice, gift); + Player live = fixture.player("Carol"); + Request active = new Request(guild); + RequestManager.addRequest(alice, live, active); + RequestManager.start(); + fixture.ui.repeatingTasks.removeLast().run(); + verify(handovers).notifyExpired(handover, bob); + verify(gifts).notifyExpired(gift, alice); + assertFalse(RequestManager.hasRequest(alice)); + assertFalse(RequestManager.hasRequest(bob)); + assertSame(active, RequestManager.getRequest(live)); + } + + @Test + void relationReloadRebindsCurrentTypesCancelsRemovedTypesAndKeepsOtherRequests() { + RelationType old = + new RelationType("ally", new org.bukkit.configuration.file.YamlConfiguration()); + RelationType current = + new RelationType("ally", new org.bukkit.configuration.file.YamlConfiguration()); + RelationType removed = + new RelationType("removed", new org.bukkit.configuration.file.YamlConfiguration()); + RelationLoader.types.clear(); + RelationLoader.types.add(current); + RelationRequest first = new RelationRequest(guild, old, false); + RequestManager.addRequest(alice, bob, first); + RequestManager.addRequest(bob, alice, new RelationRequest(guild, removed, false)); + Player carol = fixture.player("Carol"); + RequestManager.addRequest(alice, carol, new RelationRequest(guild, null, false)); + Player dave = fixture.player("Dave"); + Request ordinary = new Request(guild); + RequestManager.addRequest(alice, dave, ordinary); + RequestManager.rebindRelationRequests(); + assertSame(current, first.getType()); + assertSame(first, RequestManager.getRequest(bob)); + assertFalse(RequestManager.hasRequest(alice)); + assertFalse(RequestManager.hasRequest(carol)); + assertSame(ordinary, RequestManager.getRequest(dave)); + } + + @Test + void requestDispatchUsesTheCurrentReceiverAndClearsConsumedRequests() { + List<Request> pending = + List.of( + new RelocateRequest(guild, 1, "City"), + new ElevateRequest(guild), + new MovementJoinRequest(guild, "Offline", "player", "realm", 0), + new MovementLeaderTargetRequest(guild, "Alice", "missing", 0, "Bob")); + for (Request request : pending) { + RequestManager.addRequest(alice, bob, request); + RequestManager.accept(bob); + assertFalse(RequestManager.hasRequest(bob)); + } + verify(bob).sendMessage("§cYou do not have a faction"); + verify(bob).sendMessage("§cYou are not the leader of a guild"); + verify(bob).sendMessage("§cRequest sender is not online"); + verify(bob).sendMessage("§cThat movement is no longer available."); + } + + @Test + void anInviteToADissolvedCompanyIsConsumedWithoutJoining() { + var company = + new net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany( + guild, "Company", fixture.regiment("company_troops", false, 2, 0), 0); + guild.setCompany(company); + RequestManager.addRequest(alice, bob, new MercenaryInviteRequest(company)); + guild.setCompany(null); + RequestManager.accept(bob); + verify(bob).sendMessage("§cThat company invite has expired."); + assertFalse(RequestManager.hasRequest(bob)); + assertNull(guild.getCompany()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/SessionLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/SessionLifecycleCoverageTest.java new file mode 100644 index 00000000..fb56441b --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/SessionLifecycleCoverageTest.java @@ -0,0 +1,401 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.tfminecraft.simplefactions.government.proposal.Proposal; +import net.tfminecraft.simplefactions.government.proposal.TaxLawChange; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.government.session.Session; +import net.tfminecraft.simplefactions.government.session.Vote; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.bukkit.entity.TextDisplay; +import org.bukkit.event.block.Action; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BookMeta; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +@SuppressWarnings("deprecation") +class SessionLifecycleCoverageTest { + private FactionDomainFixture fixture; + private SessionManager manager; + private final List<TextDisplay> holograms = new ArrayList<>(); + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + manager = new SessionManager(); + when(fixture.ui.plugin.getSessionManager()).thenReturn(manager); + when(Bukkit.getPlayer(anyString())).thenAnswer(call -> fixture.online.get(call.getArgument(0))); + when(fixture.ui.world.spawnEntity(any(Location.class), eq(EntityType.TEXT_DISPLAY))) + .thenAnswer( + call -> { + TextDisplay display = mock(TextDisplay.class); + holograms.add(display); + return display; + }); + fixture.lawGroup( + "constitution", + Map.of( + "effects.faction.rules", + List.of("HAS_COUNCIL true", "APPOINTED_COUNCIL true"), + "effects.faction.council-size", + 1)); + } + + @AfterEach + void cleanup() { + if (fixture != null) fixture.close(); + } + + private Player player(String name) { + Player player = fixture.player(name); + when(player.getWorld()).thenReturn(fixture.ui.world); + return player; + } + + private Faction faction(String id, String leader, String councillor) { + Faction faction = fixture.saved(id, leader); + player(leader); + player(councillor); + faction.addMember(councillor); + faction.getGovernment().getCouncil().addMemberForce(councillor); + Proposal proposal = new Proposal(leader, faction.getGovernment()); + proposal.setTaxProposal(new TaxLawChange(TaxTarget.CITIZENS, null, 12)); + faction.getGovernment().propose(proposal); + return faction; + } + + private Block block(Material type, int x) { + Block block = mock(Block.class); + when(block.getType()).thenReturn(type); + when(block.getWorld()).thenReturn(fixture.ui.world); + when(block.getLocation()).thenAnswer(call -> new Location(fixture.ui.world, x, 64, 0)); + return block; + } + + private PlayerInteractEvent click(Player player, Action action, Block block) { + return new PlayerInteractEvent(player, action, null, block, BlockFace.UP, EquipmentSlot.HAND); + } + + private Session started(Faction faction, Block lantern) { + Player leader = fixture.online.get(faction.getLeader()); + manager.newSession(leader, faction); + PlayerInteractEvent click = click(leader, Action.RIGHT_CLICK_BLOCK, lantern); + manager.onPlayerInteract(click); + assertTrue(click.isCancelled()); + Session session = manager.getSession(faction.getGovernment().getCouncil()); + assertTrue(session.isStarted()); + return session; + } + + private AsyncPlayerChatEvent chat(Player player, String message) { + return new AsyncPlayerChatEvent(true, player, message, Set.of()); + } + + @Test + void asyncChatDefersWorldHologramAndVoteChangesToTheCapturedMainThreadCallback() { + Faction faction = faction("home", "Alice", "Bob"); + started(faction, block(Material.LANTERN, 0)); + Player bob = fixture.online.get("Bob"); + clearInvocations(bob, fixture.ui.world); + holograms.forEach(display -> clearInvocations(display)); + AsyncPlayerChatEvent event = chat(bob, " yay "); + + manager.onPlayerChat(event); + + verify(bob, never()).getLocation(); + verify(bob, never()).sendMessage(anyString()); + holograms.forEach(display -> verifyNoInteractions(display)); + assertEquals(1, fixture.ui.tasks.size()); + assertFalse(event.isCancelled()); + fixture.ui.runTasks(); + verify(bob).sendMessage(contains("Yay")); + verify(holograms.get(2)).setText(contains("1 Yay")); + } + + @Test + void quittingTheHostCancelsTheSessionAndItsAlreadyQueuedVoteCount() { + Faction faction = faction("home", "Alice", "Bob"); + Session session = started(faction, block(Material.LANTERN, 0)); + session.recordVote("Alice", Vote.YAY); + session.recordVote("Bob", Vote.YAY); + assertFalse(fixture.ui.tasks.isEmpty()); + + manager.onPlayerQuit(new PlayerQuitEvent(fixture.online.get("Alice"), "quit")); + + assertFalse(manager.hasSession(faction.getGovernment().getCouncil())); + assertFalse(session.isStarted()); + assertFalse(session.recordVote("Bob", Vote.YAY)); + fixture.ui.runTasks(); + fixture.ui.runTasks(); + assertEquals(5, faction.getTaxRate()); + for (TextDisplay display : holograms) verify(display, times(1)).remove(); + verify(fixture.online.get("Alice"), never()).sendMessage(contains("report")); + } + + @Test + void breakingTheLanternCancelsDecidedButUnappliedProposalsAndDoesNotReviveTheSession() { + Faction faction = faction("home", "Alice", "Bob"); + Block lantern = block(Material.LANTERN, 0); + Session session = started(faction, lantern); + session.recordVote("Alice", Vote.YAY); + session.recordVote("Bob", Vote.YAY); + fixture.ui.runTasks(); + assertNull(session.getCurrentProposal()); + + manager.onBlockBreak(new BlockBreakEvent(lantern, fixture.online.get("Bob"))); + + assertFalse(manager.hasSession(faction.getGovernment().getCouncil())); + fixture.ui.runTasks(); + assertEquals(5, faction.getTaxRate()); + assertFalse(session.isStarted()); + for (TextDisplay display : holograms) verify(display, times(1)).remove(); + } + + @Test + void playerInAnotherWorldCannotVoteAndDoesNotCauseADistanceException() { + Faction faction = faction("home", "Alice", "Bob"); + Session session = started(faction, block(Material.LANTERN, 0)); + Player bob = fixture.online.get("Bob"); + when(bob.getLocation()).thenReturn(new Location(mock(World.class), 0, 64, 0)); + assertDoesNotThrow(() -> manager.onPlayerChat(chat(bob, "nay"))); + assertDoesNotThrow(fixture.ui::runTasks); + assertNotNull(session.getCurrentProposal()); + verify(bob, never()).sendMessage(contains("recorded")); + verify(bob).sendMessage(contains("within 10 blocks")); + } + + @Test + void timingOutTwoPendingSessionsDoesNotModifyTheMapBeingIterated() { + Faction first = faction("first", "Alice", "Bob"); + Faction second = faction("second", "Cara", "Drew"); + manager.newSession(fixture.online.get("Alice"), first); + manager.newSession(fixture.online.get("Cara"), second); + manager.start(); + assertEquals(1, fixture.ui.repeatingTasks.size()); + for (int tick = 0; tick < 60; tick++) + assertDoesNotThrow(fixture.ui.repeatingTasks.getFirst()::run); + assertFalse(manager.hasSession(first.getGovernment().getCouncil())); + assertFalse(manager.hasSession(second.getGovernment().getCouncil())); + assertEquals(1, first.getGovernment().getCouncil().getProposalHandler().getProposals().size()); + assertEquals(1, second.getGovernment().getCouncil().getProposalHandler().getProposals().size()); + } + + @Test + void replacingASessionTerminatesItsCallbacksWithoutUnregisteringTheReplacement() { + Faction faction = faction("home", "Alice", "Bob"); + Session old = started(faction, block(Material.LANTERN, 0)); + old.recordVote("Alice", Vote.YAY); + old.recordVote("Bob", Vote.YAY); + manager.newSession(fixture.online.get("Alice"), faction); + Session replacement = manager.getSession(faction.getGovernment().getCouncil()); + assertNotSame(old, replacement); + assertFalse(old.isStarted()); + old.kill(); + old.end(); + old.start(); + old.onLanternClick(block(Material.LANTERN, 9)); + old.tick(); + fixture.ui.runTasks(); + fixture.ui.runTasks(); + assertSame(replacement, manager.getSession(faction.getGovernment().getCouncil())); + assertEquals(5, faction.getTaxRate()); + assertFalse(old.recordVote("Alice", Vote.YAY)); + assertFalse(old.isStarted()); + for (TextDisplay display : holograms) verify(display, times(1)).remove(); + } + + @Test + void shutdownKillsEverySessionAndQueuedVotesCannotChangeTheEndedSessions() { + Faction first = faction("first", "Alice", "Bob"); + Faction second = faction("second", "Cara", "Drew"); + Session one = started(first, block(Material.LANTERN, 0)); + Session two = started(second, block(Material.LANTERN, 3)); + manager.onPlayerChat(chat(fixture.online.get("Bob"), "yay")); + assertDoesNotThrow(manager::end); + assertDoesNotThrow(manager::end); + fixture.ui.runTasks(); + assertFalse(manager.hasSession(first.getGovernment().getCouncil())); + assertFalse(manager.hasSession(second.getGovernment().getCouncil())); + assertFalse(one.isStarted()); + assertFalse(two.isStarted()); + assertEquals(5, first.getTaxRate()); + assertEquals(5, second.getTaxRate()); + for (TextDisplay display : holograms) verify(display, times(1)).remove(); + } + + @Test + void completedSessionCreatesOneReportEvenWhenEndIsCalledAgain() { + Faction faction = faction("home", "Alice", "Bob"); + Session session = started(faction, block(Material.LANTERN, 0)); + session.recordVote("Alice", Vote.YAY); + session.recordVote("Bob", Vote.YAY); + fixture.ui.runTasks(); + fixture.ui.runTasks(); + session.end(); + session.kill(); + assertEquals(12, faction.getTaxRate()); + assertFalse(manager.hasSession(faction.getGovernment().getCouncil())); + long reports = + java.util.Arrays.stream(fixture.online.get("Alice").getInventory().getContents()) + .filter(item -> item != null && item.getType() == Material.WRITTEN_BOOK) + .count(); + assertEquals(1, reports); + verify(fixture.online.get("Alice"), times(1)).sendMessage(contains("Session report added")); + } + + @Test + void emptySessionEndsImmediatelyAndCannotBeRestartedBySelectingAnotherLantern() { + Faction faction = faction("home", "Alice", "Bob"); + faction.getGovernment().getCouncil().getProposalHandler().clearProposals(); + manager.newSession(fixture.online.get("Alice"), faction); + Session session = manager.getSession(faction.getGovernment().getCouncil()); + session.onLanternClick(block(Material.LANTERN, 0)); + assertFalse(session.isStarted()); + assertFalse(manager.hasSession(faction.getGovernment().getCouncil())); + session.start(); + assertFalse(session.isStarted()); + } + + @ParameterizedTest + @EnumSource(Vote.class) + void validChatVotesAcknowledgeTheCorrectVoteAndPlayItsSoundOnlyAfterScheduling(Vote vote) { + Faction faction = faction("home", "Alice", "Bob"); + started(faction, block(Material.LANTERN, 0)); + Player bob = fixture.online.get("Bob"); + when(bob.getLocation()).thenReturn(new Location(fixture.ui.world, 10, 64, 0)); + manager.onPlayerChat(chat(bob, vote.name().toLowerCase(java.util.Locale.ROOT))); + verify(bob, never()).sendMessage(contains("recorded")); + fixture.ui.runTasks(); + verify(bob).sendMessage(contains(vote.getDisplay())); + Sound expected = + switch (vote) { + case YAY -> Sound.BLOCK_NOTE_BLOCK_CHIME; + case NAY -> Sound.BLOCK_NOTE_BLOCK_BASS; + case ABSTAIN -> Sound.ITEM_BOOK_PAGE_TURN; + }; + verify(fixture.ui.world).playSound(bob.getLocation(), expected, 1f, 1f); + verify(bob).swingMainHand(); + } + + @Test + void chatRevalidatesOnlineMembershipSessionAndDistanceBeforeRecording() { + Faction faction = faction("home", "Alice", "Bob"); + Player bob = fixture.online.get("Bob"); + manager.onPlayerChat(chat(bob, "ordinary conversation")); + assertTrue(fixture.ui.tasks.isEmpty()); + Player outsider = player("Stranger"); + manager.onPlayerChat(chat(outsider, "yay")); + fixture.ui.runTasks(); + verify(outsider, never()).sendMessage(anyString()); + manager.onPlayerChat(chat(bob, "yay")); + fixture.ui.runTasks(); + verify(bob, never()).sendMessage(anyString()); + manager.newSession(fixture.online.get("Alice"), faction); + manager.onPlayerChat(chat(bob, "yay")); + fixture.ui.runTasks(); + verify(bob, never()).sendMessage(anyString()); + Session session = manager.getSession(faction.getGovernment().getCouncil()); + session.onLanternClick(block(Material.LANTERN, 0)); + manager.onPlayerChat(chat(bob, "yay")); + when(bob.isOnline()).thenReturn(false); + fixture.ui.runTasks(); + verify(bob, never()).sendMessage(contains("recorded")); + when(bob.isOnline()).thenReturn(true); + when(bob.getLocation()).thenReturn(new Location(fixture.ui.world, 10.01, 64, 0)); + manager.onPlayerChat(chat(bob, "yay")); + fixture.ui.runTasks(); + verify(bob).sendMessage(contains("within 10 blocks")); + faction.addMember("Cara"); + Player cara = player("Cara"); + manager.onPlayerChat(chat(cara, "yay")); + fixture.ui.runTasks(); + verify(cara, never()).sendMessage(contains("recorded")); + assertNotNull(session.getCurrentProposal()); + } + + @Test + void unrelatedClicksCannotStartASessionAndActiveLanternShowsTheCurrentProposal() { + Faction faction = faction("home", "Alice", "Bob"); + Player alice = fixture.online.get("Alice"); + Player bob = fixture.online.get("Bob"); + Block lantern = block(Material.LANTERN, 0); + manager.onPlayerInteract(click(alice, Action.LEFT_CLICK_BLOCK, lantern)); + manager.onPlayerInteract(click(alice, Action.RIGHT_CLICK_BLOCK, null)); + manager.onPlayerInteract(click(alice, Action.RIGHT_CLICK_BLOCK, block(Material.STONE, 0))); + manager.onPlayerInteract(click(player("Stranger"), Action.RIGHT_CLICK_BLOCK, lantern)); + manager.onPlayerInteract(click(alice, Action.RIGHT_CLICK_BLOCK, lantern)); + assertFalse(manager.hasSession(faction.getGovernment().getCouncil())); + manager.newSession(alice, faction); + faction.addMember("Cara"); + Player cara = player("Cara"); + PlayerInteractEvent unauthorized = click(cara, Action.RIGHT_CLICK_BLOCK, lantern); + manager.onPlayerInteract(unauthorized); + assertFalse(unauthorized.isCancelled()); + assertFalse(manager.getSession(faction.getGovernment().getCouncil()).isStarted()); + manager.onPlayerInteract(click(bob, Action.RIGHT_CLICK_BLOCK, lantern)); + Session session = manager.getSession(faction.getGovernment().getCouncil()); + assertTrue(session.isStarted()); + PlayerInteractEvent other = click(alice, Action.RIGHT_CLICK_BLOCK, block(Material.LANTERN, 1)); + manager.onPlayerInteract(other); + assertFalse(other.isCancelled()); + PlayerInteractEvent view = click(bob, Action.RIGHT_CLICK_BLOCK, lantern); + manager.onPlayerInteract(view); + assertTrue(view.isCancelled()); + verify(bob) + .openBook( + argThat( + (ItemStack item) -> + item.getType() == Material.WRITTEN_BOOK + && ((BookMeta) item.getItemMeta()) + .getPages().stream().anyMatch(page -> page.contains("12.0")))); + verify(bob).swingMainHand(); + session.recordVote("Alice", Vote.YAY); + session.recordVote("Bob", Vote.YAY); + fixture.ui.runTasks(); + PlayerInteractEvent betweenProposals = click(bob, Action.RIGHT_CLICK_BLOCK, lantern); + manager.onPlayerInteract(betweenProposals); + assertTrue(betweenProposals.isCancelled()); + verify(bob, times(1)).openBook(any(ItemStack.class)); + } + + @Test + void unrelatedBlockBreaksAndOtherPlayersQuittingLeaveTheSessionRunning() { + Faction faction = faction("home", "Alice", "Bob"); + Player bob = fixture.online.get("Bob"); + manager.newSession(fixture.online.get("Alice"), faction); + manager.onBlockBreak(new BlockBreakEvent(block(Material.STONE, 0), bob)); + manager.onBlockBreak(new BlockBreakEvent(block(Material.LANTERN, 1), bob)); + manager.onPlayerQuit(new PlayerQuitEvent(bob, "quit")); + assertTrue(manager.hasSession(faction.getGovernment().getCouncil())); + Session session = manager.getSession(faction.getGovernment().getCouncil()); + session.onLanternClick(block(Material.LANTERN, 0)); + manager.onBlockBreak(new BlockBreakEvent(block(Material.LANTERN, 1), bob)); + assertTrue(session.isStarted()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/StaleRequestsReviewRegressionTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/StaleRequestsReviewRegressionTest.java new file mode 100644 index 00000000..bf5231eb --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/StaleRequestsReviewRegressionTest.java @@ -0,0 +1,280 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.tfminecraft.simplefactions.diplomacy.RelationType; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.request.RelationRequest; +import net.tfminecraft.simplefactions.objects.request.RelocateRequest; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.PersistenceFilesFixture; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class StaleRequestsReviewRegressionTest { + private FactionDomainFixture domain; + private PersistenceFilesFixture files; + private Field requests; + private Object originalRequests; + private Faction origin; + private Faction target; + private Guild guild; + private Player alice; + private Player bob; + + @BeforeEach + void setUp() throws Exception { + files = new PersistenceFilesFixture(); + domain = new FactionDomainFixture(); + requests = RequestManager.class.getDeclaredField("requests"); + requests.setAccessible(true); + originalRequests = requests.get(null); + requests.set(null, new HashMap<>()); + domain.provincesEnabled(true); + for (int id : List.of(1, 2, 3)) domain.provinceData.put(id, new Province(id, "PLAINS", 50)); + var originData = domain.data("origin", "Alice"); + originData.provinces.add(1); + origin = domain.saved(originData); + var targetData = domain.data("target", "Bob"); + targetData.provinces.add(2); + target = domain.saved(targetData); + alice = domain.player("Alice"); + bob = domain.player("Bob"); + } + + @AfterEach + void tearDown() throws Exception { + try { + if (requests != null) requests.set(null, originalRequests); + } finally { + try { + if (domain != null) domain.close(); + } finally { + if (files != null) files.close(); + } + } + } + + @ParameterizedTest + @CsvSource({ + "relation,false", + "trade,false", + "treaty,false", + "relation,true", + "trade,true", + "treaty,true" + }) + void pendingDiplomacyCannotCreateRelationsToAnUnregisteredOrReplacedFaction( + String kind, boolean replacement) { + RelationType pact = requestPact(kind); + FactionManager.deleteFaction(origin); + Faction current = replacement ? domain.saved(origin.getId(), "Cara") : null; + assertSame(current, FactionManager.getByString(origin.getId())); + + assertDoesNotThrow(() -> RequestManager.accept(bob)); + + assertAll( + () -> assertNotSame(pact, selected(kind, target, origin)), + () -> assertNotSame(pact, selected(kind, origin, target)), + () -> { + if (current != null) assertNotSame(pact, selected(kind, current, target)); + }, + () -> assertFalse(RequestManager.hasRequest(bob))); + } + + @ParameterizedTest + @ValueSource(strings = {"relation", "trade", "treaty"}) + void currentFactionRequestsStillCreateTheMutuallyAcceptedAgreement(String kind) { + RelationType pact = requestPact(kind); + RequestManager.accept(bob); + assertSame(pact, selected(kind, origin, target)); + assertSame(pact, selected(kind, target, origin)); + assertFalse(RequestManager.hasRequest(bob)); + } + + private RelationType requestPact(String kind) { + RelationType pact = + domain.relationType( + "pact", + Map.of( + "name", + "Pact", + "mutual", + true, + "trade-agreement", + kind.equals("trade"), + "treaty", + kind.equals("treaty"))); + switch (kind) { + case "trade" -> RelationManager.setTradeRelation(alice, pact, target, origin, true); + case "treaty" -> RelationManager.setTreatyRelation(alice, pact, target, origin, true); + default -> RelationManager.setRelation(alice, pact, target, origin, true); + } + RelationRequest pending = + assertInstanceOf(RelationRequest.class, RequestManager.getRequest(bob)); + assertSame(origin, pending.getFaction()); + assertSame(pact, pending.getType()); + return pact; + } + + private RelationType selected(String kind, Faction from, Faction to) { + return switch (kind) { + case "trade" -> from.getDiplomacyHandler().getTradeRelation(to.getId()); + case "treaty" -> from.getDiplomacyHandler().getTreatyRelation(to.getId()); + default -> from.getRelation(to.getId()).getType(); + }; + } + + private void requestRelocation() { + guild = domain.guild(origin, "traders", "GuildLeader"); + Player sender = domain.player("GuildLeader"); + guild.setCapital(1); + guild.getBank().setWealth(500.0); + FactionManager.requestRelocation(sender, guild, target, 2, "Destination"); + assertSame( + guild, assertInstanceOf(RelocateRequest.class, RequestManager.getRequest(bob)).getSender()); + assertEquals(100.0, guild.getRelocationCost(2)); + } + + @ParameterizedTest + @ValueSource(strings = {"removed", "replaced", "different-host", "already-arrived", "new-leader"}) + void relocationConsentDoesNotFollowAChangedOrRemovedGuild(String change) { + requestRelocation(); + switch (change) { + case "removed" -> origin.getGuildHandler().removeGuild(guild.getId(), false, false); + case "replaced" -> { + origin.getGuildHandler().removeGuild(guild.getId(), false, false); + domain.guild(origin, guild.getId(), "ReplacementLeader"); + } + case "different-host" -> guild.relocate(domain.saved("third", "Cara"), -1); + case "already-arrived" -> guild.relocate(target, -1); + case "new-leader" -> guild.setLeader("Cara"); + default -> throw new AssertionError(change); + } + Faction currentHost = guild.getFaction(); + Guild registered = FactionManager.getGuildByString(guild.getId()); + int capital = guild.getCapital(); + Bank account = guild.getBank(); + double balance = account.getWealth(); + + assertDoesNotThrow(() -> RequestManager.accept(bob)); + + assertAll( + () -> + assertEquals( + balance, account.getWealth(), "Rejected consent must not charge the old guild"), + () -> assertSame(currentHost, guild.getFaction()), + () -> assertEquals(capital, guild.getCapital()), + () -> assertSame(registered, FactionManager.getGuildByString(guild.getId())), + () -> assertNull(target.getSettlementHandler().getByProvince(2))); + } + + @Test + void aBaseGuildCannotBeChargedForARelocationThatDoesNothing() { + guild = origin.getOrCreateMainGuild(); + guild.getBank().setWealth(500.0); + FactionManager.requestRelocation(alice, guild, target, 2, "Destination"); + assertDoesNotThrow(() -> RequestManager.accept(bob)); + assertEquals(500.0, guild.getBank().getWealth()); + assertSame(origin, guild.getFaction()); + assertNull(target.getGuildHandler().getGuild(guild.getId())); + } + + @Test + void removingTheBankWhileConsentIsPendingDoesNotMoveTheGuildOrThrow() { + requestRelocation(); + Bank originalAccount = guild.getBank(); + guild.setBank(null); + assertAll( + () -> assertDoesNotThrow(() -> RequestManager.accept(bob)), + () -> assertSame(origin, guild.getFaction()), + () -> assertSame(guild, origin.getGuildHandler().getGuild(guild.getId())), + () -> assertNull(target.getGuildHandler().getGuild(guild.getId())), + () -> assertEquals(1, guild.getCapital()), + () -> assertEquals(500.0, originalAccount.getWealth()), + () -> assertNull(target.getSettlementHandler().getByProvince(2))); + } + + @Test + void relocationConsentIsBoundToTheOriginalDestinationWhenItsLeaderChangesFaction() { + requestRelocation(); + target.setLeader("Cara"); + Faction newRealm = domain.saved("new-realm", "Bob"); + assertSame(newRealm, FactionManager.getByLeader(bob.getName())); + + assertDoesNotThrow(() -> RequestManager.accept(bob)); + + assertAll( + () -> assertSame(origin, guild.getFaction()), + () -> assertSame(guild, origin.getGuildHandler().getGuild(guild.getId())), + () -> assertNull(newRealm.getGuildHandler().getGuild(guild.getId())), + () -> assertNull(target.getGuildHandler().getGuild(guild.getId())), + () -> assertEquals(500.0, guild.getBank().getWealth()), + () -> assertEquals(1, guild.getCapital()), + () -> assertFalse(RequestManager.hasRequest(bob))); + } + + @Test + void aCityNameTakenWhileRelocationIsPendingCannotChargeOrMoveTheGuild() { + requestRelocation(); + target.addProvince(3); + assertTrue(target.getSettlementHandler().found("Destination", 3, 0, 0).isSuccess()); + var existingCity = target.getSettlementHandler().getByProvince(3); + + assertDoesNotThrow(() -> RequestManager.accept(bob)); + + assertAll( + () -> assertSame(origin, guild.getFaction()), + () -> assertSame(guild, origin.getGuildHandler().getGuild(guild.getId())), + () -> assertNull(target.getGuildHandler().getGuild(guild.getId())), + () -> assertEquals(500.0, guild.getBank().getWealth()), + () -> assertEquals(1, guild.getCapital()), + () -> assertSame(existingCity, target.getSettlementHandler().getByProvince(3)), + () -> assertNull(target.getSettlementHandler().getByProvince(2))); + } + + @Test + void losingTheDestinationProvinceWhileConsentIsPendingCannotChargeOrMoveTheGuild() { + requestRelocation(); + target.removeProvince(2, false); + assertFalse(target.hasProvince(2)); + + assertDoesNotThrow(() -> RequestManager.accept(bob)); + + assertAll( + () -> assertSame(origin, guild.getFaction()), + () -> assertSame(guild, origin.getGuildHandler().getGuild(guild.getId())), + () -> assertNull(target.getGuildHandler().getGuild(guild.getId())), + () -> assertEquals(500.0, guild.getBank().getWealth()), + () -> assertEquals(1, guild.getCapital()), + () -> assertNull(target.getSettlementHandler().getByProvince(2))); + } + + @Test + void unchangedConsentMovesExactlyTheRequestedGuildAndChargesOnce() { + requestRelocation(); + RequestManager.accept(bob); + assertAll( + () -> assertSame(target, guild.getFaction()), + () -> assertSame(guild, target.getGuildHandler().getGuild(guild.getId())), + () -> assertNull(origin.getGuildHandler().getGuild(guild.getId())), + () -> assertEquals(400.0, guild.getBank().getWealth()), + () -> assertEquals(2, guild.getCapital()), + () -> assertNotNull(target.getSettlementHandler().getByProvince(2)), + () -> assertFalse(RequestManager.hasRequest(bob))); + RequestManager.accept(bob); + assertEquals(400.0, guild.getBank().getWealth()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/TabCompletionCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/TabCompletionCoverageTest.java new file mode 100644 index 00000000..7f1cc6b7 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/TabCompletionCoverageTest.java @@ -0,0 +1,687 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.army.Military; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.diplomacy.RelationType; +import net.tfminecraft.simplefactions.espionage.CharacterNames; +import net.tfminecraft.simplefactions.espionage.EspionageCommands; +import net.tfminecraft.simplefactions.espionage.EspionageConfig; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.installation.Installation; +import net.tfminecraft.simplefactions.installation.InstallationConstruction; +import net.tfminecraft.simplefactions.installation.handler.InstallationHandler; +import net.tfminecraft.simplefactions.laws.Law; +import net.tfminecraft.simplefactions.laws.LawGroup; +import net.tfminecraft.simplefactions.loaders.LawLoader; +import net.tfminecraft.simplefactions.loaders.RelationLoader; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.mercenary.contract.MercenaryMarket; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.handler.GuildHandler; +import net.tfminecraft.simplefactions.objects.request.MercenaryInviteRequest; +import net.tfminecraft.simplefactions.settlement.Settlement; +import net.tfminecraft.simplefactions.settlement.handler.SettlementHandler; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import net.tfminecraft.simplefactions.tiers.Title; +import net.tfminecraft.simplefactions.tiers.admin.TitleAdminCommand; +import net.tfminecraft.simplefactions.utils.Permissions; +import net.tfminecraft.simplefactions.utils.TabCompletion; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +class TabCompletionCoverageTest { + private GuiTestFixture gui; + private MockedStatic<FactionManager> factions; + private TabCompletion completion; + private Player player; + private Faction realm; + private Faction other; + private Guild guild; + private List<Faction> previousFactions; + private boolean previousProvinces; + + @BeforeEach + void setUp() { + previousFactions = FactionManager.factions; + previousProvinces = Cache.provincesEnabled; + Cache.provincesEnabled = true; + gui = new GuiTestFixture(); + player = gui.player("Leader"); + realm = realm("realm", "Realm"); + other = realm("other", "Other Realm"); + guild = mock(Guild.class); + when(guild.getId()).thenReturn("merchants"); + when(guild.getFaction()).thenReturn(realm); + when(guild.getLeader()).thenReturn("Leader"); + when(guild.getMembers()).thenReturn(new ArrayList<>(List.of("Leader", "Alice", "Bob"))); + FactionManager.factions = new ArrayList<>(List.of(realm, other)); + factions = mockStatic(FactionManager.class); + factions.when(() -> FactionManager.getByString("realm")).thenReturn(realm); + factions.when(() -> FactionManager.getByString("other")).thenReturn(other); + completion = new TabCompletion(); + } + + @AfterEach + void close() { + try { + if (factions != null) factions.close(); + } finally { + try { + if (gui != null) gui.close(); + } finally { + FactionManager.factions = previousFactions; + Cache.provincesEnabled = previousProvinces; + } + } + } + + @ParameterizedTest + @ValueSource(strings = {"guild", "unrelated"}) + void factionForceJoinSuggestionsAreNotReturnedForOtherCommands(String command) { + admin(); + assertTrue(complete(command, "forcejoin", "").isEmpty()); + when(Bukkit.getOnlinePlayers()).thenAnswer(call -> List.of(gui.player("Alice"))); + assertTrue(complete(command, "forcejoin", "realm", "").isEmpty()); + } + + @Test + void removedWealthModifierCommandHasNoMisleadingArgumentSuggestions() { + admin(); + assertTrue(complete("faction", "addwealthmodifier", "").isEmpty()); + assertTrue(complete("faction", "addwealthmodifier", "type", "").isEmpty()); + } + + @Test + void rootSuggestionsFollowMembershipLeadershipAndProvinceAvailability() { + List<String> outsider = complete("faction", ""); + assertTrue(outsider.containsAll(List.of("list", "create", "join", "vehicle"))); + assertFalse(outsider.contains("menu")); + assertFalse(outsider.contains("claim")); + assertFalse(outsider.contains("forceleader")); + factions.when(() -> FactionManager.getByMember("Leader")).thenReturn(realm); + factions.when(() -> FactionManager.getByLeader("Leader")).thenReturn(realm); + List<String> leader = complete("faction"); + assertTrue( + leader.containsAll( + List.of( + "menu", + "positions", + "spymaster", + "espionage", + "claim", + "construct", + "deconstruct", + "upgrade", + "unclaim", + "setcapital", + "setbank", + "withdraw", + "invite", + "kick", + "setleader", + "setculture", + "setreligion", + "setrulingsystem", + "setrulertitle", + "setbanner", + "setcolour"))); + Cache.provincesEnabled = false; + List<String> noProvinces = complete("faction", ""); + assertFalse(noProvinces.contains("construct")); + assertFalse(noProvinces.contains("setcapital")); + assertTrue(noProvinces.contains("transfervehicle")); + assertTrue(noProvinces.contains("setbank")); + } + + @Test + void administratorRootSuggestionsIncludeOnlyEnabledProvinceTools() { + admin(); + List<String> enabled = complete("faction", ""); + assertTrue( + enabled.containsAll( + List.of( + "addguildbank", + "forceleader", + "forcejoin", + "forcewithdraw", + "forceregiment", + "addprestigemodifier", + "getglobalwealth", + "forceconstruct", + "forceupgrade", + "queueallnations", + "fullregen", + "reloadtitles", + "destroytitle", + "granttitle", + "usurp", + TitleAdminCommand.SUBCOMMAND, + "tradegraph", + "reloadconfigs", + "transfersubject", + "setrelation", + "settreaty", + "setpower", + "setlaw", + "setstance", + "startelection", + "endelection"))); + Cache.provincesEnabled = false; + List<String> disabled = complete("faction", ""); + assertFalse(disabled.contains("forceconstruct")); + assertFalse(disabled.contains("tradegraph")); + assertFalse(disabled.contains(TitleAdminCommand.SUBCOMMAND)); + assertTrue(disabled.contains("setpower")); + } + + @Test + void guildRootSuggestionsAddLeaderAndStaffActionsWithoutProvinceOnlyActionsWhenDisabled() { + List<String> outsider = complete("guild"); + assertTrue( + outsider.containsAll( + List.of("create", "join", "menu", "setbank", "deposit", "withdraw", "setcapital"))); + assertFalse(outsider.contains("invite")); + factions.when(() -> FactionManager.getGuildByLeader("Leader")).thenReturn(guild); + admin(); + Cache.provincesEnabled = false; + List<String> leader = complete("guild", ""); + assertTrue( + leader.containsAll( + List.of( + "invite", + "kick", + "setleader", + "rename", + "setbanner", + "setcolour", + "dummify", + "dummyleader"))); + assertFalse(leader.contains("setcapital")); + } + + @Test + void espionageReloadShortcutRequiresItsPermissionAndMatchesCaseInsensitivePrefix() { + when(player.hasPermission(EspionageConfig.reloadPermission())).thenReturn(true); + assertEquals(List.of("reloadespionage"), complete("faction", "RELOADESP")); + assertTrue(complete("faction", "").contains("reloadespionage")); + CommandSender console = mock(CommandSender.class); + when(console.hasPermission(EspionageConfig.reloadPermission())).thenReturn(true); + assertEquals(List.of("reloadespionage"), complete(console, "faction", "reloadesp")); + assertTrue(complete(console, "faction", "spymaster", "").isEmpty()); + try (MockedStatic<EspionageCommands> espionage = mockStatic(EspionageCommands.class)) { + String[] args = {"spymaster", "Al"}; + espionage.when(() -> EspionageCommands.matches("spymaster")).thenReturn(true); + espionage.when(() -> EspionageCommands.complete(player, args)).thenReturn(List.of("Alice")); + assertEquals(List.of("Alice"), complete("faction", args)); + espionage.verify(() -> EspionageCommands.complete(player, args)); + } + } + + @Test + void addGuildBankCompletionWorksForStaffConsoleAndSkipsMissingGuilds() { + CommandSender console = mock(CommandSender.class); + when(console.hasPermission(Permissions.Permission_Admin)).thenReturn(true); + Guild otherGuild = mock(Guild.class); + when(otherGuild.getId()).thenReturn("miners"); + factions.when(FactionManager::getAllGuilds).thenReturn(Arrays.asList(guild, null, otherGuild)); + assertEquals(List.of("merchants"), complete(console, "faction", "addguildbank", "MER")); + assertTrue(complete("faction", "addguildbank", "").isEmpty()); + } + + @Test + void titleEditingCompletionRequiresStaffAndEnabledProvinces() { + String[] args = {TitleAdminCommand.SUBCOMMAND, ""}; + assertTrue(complete("faction", args).isEmpty()); + admin(); + Cache.provincesEnabled = false; + assertTrue(complete("faction", args).isEmpty()); + Cache.provincesEnabled = true; + try (MockedStatic<TitleAdminCommand> titles = mockStatic(TitleAdminCommand.class)) { + titles.when(() -> TitleAdminCommand.complete(args)).thenReturn(List.of("create", "edit")); + assertEquals(List.of("create", "edit"), complete("faction", args)); + } + } + + @Test + void companyRootSuggestionsFollowCompanyOwnershipInvitesAndStaffPermission() { + try (MockedStatic<RequestManager> requests = mockStatic(RequestManager.class)) { + assertTrue(complete("company", "").isEmpty()); + factions.when(() -> FactionManager.getGuildByLeader("Leader")).thenReturn(guild); + assertEquals(List.of("found"), complete("company")); + MercenaryCompany company = mock(MercenaryCompany.class); + when(guild.getCompany()).thenReturn(company); + when(guild.hasCompany()).thenReturn(true); + when(company.getGuild()).thenReturn(guild); + MercenaryInviteRequest invitation = new MercenaryInviteRequest(company); + requests.when(() -> RequestManager.getRequest(player)).thenReturn(invitation); + admin(); + assertEquals( + List.of( + "invite", + "kick", + "expand", + "draft", + "offer", + "contracts", + "accept", + "decline", + "admin"), + complete("company", "")); + assertEquals(List.of("accept", "admin"), complete("company", "A")); + assertTrue(complete(mock(CommandSender.class), "company", "").isEmpty()); + } + } + + @Test + void companyArgumentsSuggestMatchingEnlistedPlayersOnlinePlayersAndFactionNames() { + factions.when(() -> FactionManager.getGuildByLeader("Leader")).thenReturn(guild); + MercenaryCompany company = mock(MercenaryCompany.class); + when(company.getEnlisted()).thenReturn(List.of("Alice", "Bob")); + when(guild.getCompany()).thenReturn(company); + when(Bukkit.getOnlinePlayers()) + .thenAnswer(call -> List.of(gui.player("Alice"), gui.player("Bob"))); + assertEquals(List.of("Alice"), complete("company", "kick", "al")); + assertEquals(List.of("Bob"), complete("company", "invite", "B")); + assertEquals(List.of("Realm"), complete("company", "offer", "R")); + when(other.getName()).thenReturn(null); + assertEquals(List.of("Realm"), complete("company", "offer", "")); + assertTrue(complete("company", "unknown", "").isEmpty()); + when(guild.getCompany()).thenReturn(null); + assertTrue(complete("company", "kick", "").isEmpty()); + } + + @Test + void companyAdminCompletesOnlyExistingCompaniesAndRecognizedOperations() { + admin(); + MercenaryCompany company = mock(MercenaryCompany.class); + when(company.getName()).thenReturn("Silver Shields"); + when(guild.getCompany()).thenReturn(company); + Guild unnamed = mock(Guild.class); + when(unnamed.getId()).thenReturn("unnamed"); + when(unnamed.getCompany()).thenReturn(mock(MercenaryCompany.class)); + factions + .when(FactionManager::getAllGuilds) + .thenReturn(Arrays.asList(null, mock(Guild.class), guild, unnamed)); + assertEquals(List.of("give", "take"), complete("company", "admin", "")); + assertEquals( + List.of("Silver Shields", "merchants", "unnamed"), + complete("company", "admin", "give", "")); + assertEquals(List.of("merchants"), complete("company", "admin", "take", "mer")); + assertEquals(List.of("1"), complete("company", "admin", "give", "merchants", "")); + when(player.hasPermission(Permissions.Permission_Admin)).thenReturn(false); + assertTrue(complete("company", "admin", "").isEmpty()); + } + + @Test + void mercenaryMarketSuggestionsOnlyContainNamedMatchingListings() { + assertEquals(List.of("list", "hire"), complete("mercenaries")); + assertEquals(List.of("hire"), complete("mercenaries", "H")); + MercenaryCompany company = mock(MercenaryCompany.class); + when(company.getName()).thenReturn("Silver Shields"); + try (MockedStatic<MercenaryMarket> market = mockStatic(MercenaryMarket.class)) { + market + .when(MercenaryMarket::listing) + .thenReturn(List.of(company, mock(MercenaryCompany.class))); + assertEquals(List.of("Silver Shields"), complete("mercenaries", "hire", "sil")); + assertTrue(complete("mercenaries", "hire", "absent").isEmpty()); + assertTrue(complete("mercenaries", "unknown", "").isEmpty()); + } + } + + @Test + void constructionKindsAreFilteredAndRequireAnEligibleLeader() { + assertTrue(complete("faction", "construct", "").isEmpty()); + factions.when(() -> FactionManager.getByLeader("Leader")).thenReturn(realm); + assertEquals( + List.of("fort", "port", "airport", "train_station"), complete("faction", "construct")); + assertEquals(List.of("train_station"), complete("faction", "construct", "TR")); + assertEquals(List.of("<name>"), complete("faction", "construct", "fort", "")); + Cache.provincesEnabled = false; + assertTrue(complete("faction", "construct", "").isEmpty()); + assertTrue(complete(mock(CommandSender.class), "faction", "construct", "").isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = {"upgrade", "deconstruct", "transfervehicle", "findvehicles"}) + void installationCompletionsUseTheLeadersCurrentInstallations(String action) { + assertTrue(complete("faction", action, "").isEmpty()); + installationChoices(); + List<String> all = complete("faction", action); + assertTrue(all.containsAll(List.of("fort", "port"))); + assertEquals(action.equals("deconstruct"), all.contains("fort_building")); + assertEquals(List.of("port"), complete("faction", action, "P")); + if (action.equals("upgrade") || action.equals("deconstruct")) { + Cache.provincesEnabled = false; + assertTrue(complete("faction", action, "").isEmpty()); + } + } + + @Test + void vehicleArgumentsSuggestPlayersPoolInstallationsAndBankPaymentOnlyAtTheirPositions() { + installationChoices(); + when(Bukkit.getOnlinePlayers()) + .thenAnswer(call -> List.of(gui.player("Alice"), gui.player("Bob"))); + assertTrue( + complete("faction", "vehicle") + .containsAll(List.of("give", "handover", "transfer", "maintenance"))); + assertEquals(List.of("transfer"), complete("faction", "vehicle", "tr")); + assertEquals(List.of("Alice"), complete("faction", "vehicle", "give", "A")); + assertEquals(List.of("Bob"), complete("faction", "vehicle", "handover", "B")); + assertEquals(List.of("pool", "port"), complete("faction", "vehicle", "transfer", "p")); + assertEquals(List.of("fort"), complete("faction", "vehicle", "transfer", "f")); + assertEquals(List.of("pay"), complete("faction", "vehicle", "maintenance", "p")); + assertEquals(List.of("bank"), complete("faction", "vehicle", "maintenance", "pay", "b")); + assertTrue(complete("faction", "vehicle", "unknown", "").isEmpty()); + assertTrue(complete(mock(CommandSender.class), "faction", "vehicle", "").isEmpty()); + } + + @ParameterizedTest + @CsvSource({ + "faction,create,<id>", + "guild,create,<id>", + "faction,rename,<id>", + "faction,setculture,<culture>", + "faction,setreligion,<religion>", + "faction,setrulingsystem,<ruling system>", + "faction,setrulertitle,<ruler title>", + "faction,setcolour,'R,G,B'", + "guild,setcolour,'R,G,B'" + }) + void scalarArgumentsHaveSpecificPlaceholders(String command, String action, String placeholder) { + assertEquals(List.of(placeholder), complete(command, action, "")); + } + + @ParameterizedTest + @CsvSource({ + "faction,setbanner", + "guild,setbanner", + "faction,claim", + "faction,unclaim", + "faction,accept", + "guild,accept" + }) + void CommandsWithoutExtraArgumentsOfferNoPlaceholder(String command, String action) { + assertTrue(complete(command, action, "").isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = {"faction", "guild"}) + void capitalNamesComeFromTheLeadersSettlements(String command) { + assertTrue(complete(command, "setcapital", "").isEmpty()); + factions.when(() -> FactionManager.getByLeader("Leader")).thenReturn(realm); + factions.when(() -> FactionManager.getGuildByLeader("Leader")).thenReturn(guild); + SettlementHandler settlements = mock(SettlementHandler.class); + Settlement settlement = mock(Settlement.class); + when(settlement.getId()).thenReturn("capital"); + when(settlements.getAll()).thenReturn(List.of(settlement)); + when(realm.getSettlementHandler()).thenReturn(settlements); + assertEquals(List.of("capital"), complete(command, "setcapital", "")); + Cache.provincesEnabled = false; + assertTrue(complete(command, "setcapital", "").isEmpty()); + } + + @Test + void deletionSuggestionsNeverIncludeTheBaseGuildAndDoNotRepeatThePlayersGuild() { + assertTrue(complete("faction", "delete", "").isEmpty()); + assertTrue(complete("guild", "delete", "").isEmpty()); + factions.when(() -> FactionManager.getByMember("Leader")).thenReturn(realm); + factions.when(() -> FactionManager.getGuildByMember("Leader")).thenReturn(guild); + assertEquals(List.of("realm"), complete("faction", "delete", "")); + assertEquals(List.of("merchants"), complete("guild", "delete", "")); + Guild base = mock(Guild.class); + when(base.getId()).thenReturn("base"); + when(base.isBase()).thenReturn(true); + Guild second = mock(Guild.class); + when(second.getId()).thenReturn("miners"); + GuildHandler handler = mock(GuildHandler.class); + when(handler.getGuilds()).thenReturn(List.of(base, guild, second)); + when(realm.getGuildHandler()).thenReturn(handler); + admin(); + assertEquals(List.of("merchants", "miners"), complete("guild", "delete", "")); + when(guild.isBase()).thenReturn(true); + assertEquals(List.of("miners"), complete("guild", "delete", "")); + } + + @Test + void memberAndLeadershipSuggestionsExcludeTheCurrentLeaderAndIneligibleSuccessors() { + assertTrue(complete("faction", "kick", "").isEmpty()); + assertTrue(complete("guild", "kick", "").isEmpty()); + assertTrue(complete("faction", "setleader", "").isEmpty()); + factions.when(() -> FactionManager.getByMember("Leader")).thenReturn(realm); + factions.when(() -> FactionManager.getGuildByLeader("Leader")).thenReturn(guild); + when(realm.canBecomeLeader("Alice")).thenReturn(true); + assertEquals(List.of("Alice", "Bob"), complete("guild", "kick", "")); + assertEquals(List.of("Alice", "Bob"), complete("faction", "kick", "")); + assertEquals(List.of("Alice"), complete("faction", "setleader", "")); + when(guild.isBase()).thenReturn(true); + assertTrue(complete("guild", "kick", "").isEmpty()); + } + + @Test + void invitationCompletionsKeepOnlyTheUnfinishedSuffixOfACharacterName() { + Player alice = gui.player("Alice"); + when(Bukkit.getOnlinePlayers()).thenAnswer(call -> List.of(alice)); + try (MockedStatic<CharacterNames> characters = + mockStatic(CharacterNames.class, CALLS_REAL_METHODS)) { + characters.when(() -> CharacterNames.of("Alice")).thenReturn("Lady Alice"); + assertEquals(List.of("Alice"), complete("faction", "invite", "Lady", "A")); + assertEquals(List.of("Alice"), complete("guild", "invite", "Al")); + } + } + + @ParameterizedTest + @ValueSource( + strings = { + "forceleader", + "forceupgrade", + "forceconstruct", + "forceregiment", + "forcewithdraw", + "addprestigemodifier", + "forcedelete", + "granttitle", + "setrelation", + "settreaty", + "setpower", + "setlaw", + "usurp" + }) + void administrativeFactionArgumentsRequirePermissionAndListKnownIds(String action) { + assertTrue(complete("faction", action, "").isEmpty()); + admin(); + assertEquals(List.of("realm", "other"), complete("faction", action, "")); + } + + @Test + void administrativeLeaderSuggestionsRespectTheSelectedFactionsEligibility() { + admin(); + when(realm.canBecomeLeader("Alice")).thenReturn(true); + assertEquals(List.of("Alice"), complete("faction", "forceleader", "realm", "")); + assertTrue(complete("faction", "forceleader", "missing", "").isEmpty()); + } + + @Test + void administrativeInstallationSuggestionsUseSelectedFactionKindAndPrefix() { + admin(); + installationChoices(); + assertEquals(List.of("fort"), complete("faction", "forceupgrade", "realm", "F")); + assertTrue(complete("faction", "forceupgrade", "missing", "").isEmpty()); + assertEquals(List.of("airport"), complete("faction", "forceconstruct", "realm", "air")); + assertEquals(List.of("<name>"), complete("faction", "forceconstruct", "realm", "fort", "")); + Cache.provincesEnabled = false; + assertTrue(complete("faction", "forceconstruct", "realm", "").isEmpty()); + assertTrue(complete("faction", "forceupgrade", "realm", "").isEmpty()); + } + + @Test + void administrativeRegimentChoicesExcludeLeviesAndOfferValidActionsAndAmount() { + admin(); + Military military = mock(Military.class); + Regiment professional = mock(Regiment.class); + when(professional.getId()).thenReturn("archers"); + Regiment levy = mock(Regiment.class); + when(levy.getId()).thenReturn("levy"); + when(levy.isLevy()).thenReturn(true); + when(military.getRegiments()).thenReturn(List.of(professional, levy)); + when(realm.getMilitary()).thenReturn(military); + assertEquals(List.of("give", "take"), complete("faction", "forceregiment", "realm", "")); + assertEquals(List.of("archers"), complete("faction", "forceregiment", "realm", "give", "")); + assertTrue(complete("faction", "forceregiment", "missing", "give", "").isEmpty()); + assertEquals( + List.of("1"), complete("faction", "forceregiment", "realm", "give", "archers", "")); + } + + @Test + void forceJoinSuggestsOnlyMatchingFactionIdsAndPlayersWithoutMembership() { + admin(); + Player free = gui.player("Free"); + Player member = gui.player("Alice"); + Player elsewhere = gui.player("Joined"); + when(Bukkit.getOnlinePlayers()).thenAnswer(call -> List.of(free, member, elsewhere)); + factions.when(() -> FactionManager.getByMember("Joined")).thenReturn(other); + assertEquals(List.of("realm"), complete("faction", "forcejoin", "RE")); + assertEquals(List.of("Free"), complete("faction", "forcejoin", "realm", "")); + assertEquals(List.of("Free"), complete("faction", "forcejoin", "realm", "f")); + assertTrue(complete("faction", "forcejoin", "missing", "").isEmpty()); + } + + @Test + void administrativeNumbersAndModifiersHaveClearArgumentPlaceholders() { + admin(); + assertEquals(List.of("1.0"), complete("faction", "forcewithdraw", "realm", "")); + assertEquals(List.of("<type>"), complete("faction", "addprestigemodifier", "realm", "")); + assertEquals( + List.of("1.0"), complete("faction", "addprestigemodifier", "realm", "festival", "")); + } + + @Test + void titleSuggestionsDistinguishOwnedAndUnownedTitles() { + admin(); + Title owned = mock(Title.class); + Title unowned = mock(Title.class); + when(owned.getId()).thenReturn("owned_title"); + when(unowned.getId()).thenReturn("unowned_title"); + try (MockedStatic<TitleManager> titles = mockStatic(TitleManager.class)) { + titles.when(TitleManager::getAllOwnedTitles).thenReturn(List.of(owned)); + titles.when(TitleManager::getAllUnownedTitles).thenReturn(List.of(unowned)); + assertEquals(List.of("owned_title"), complete("faction", "destroytitle", "")); + assertEquals(List.of("unowned_title"), complete("faction", "granttitle", "realm", "")); + } + when(other.getTitles()).thenReturn(List.of(owned)); + assertEquals(List.of("other"), complete("faction", "usurp", "realm", "")); + when(other.getTitles()).thenReturn(List.of()); + assertTrue(complete("faction", "usurp", "realm", "").isEmpty()); + } + + @Test + void diplomacySuggestionsFilterSubjectsExcludeTheSourceAndSeparateRelationTypes() { + admin(); + try (MockedStatic<RelationManager> relations = mockStatic(RelationManager.class); + MockedStatic<RelationLoader> types = mockStatic(RelationLoader.class)) { + relations.when(() -> RelationManager.getOverlord(realm)).thenReturn("other"); + assertEquals(List.of("realm"), complete("faction", "transfersubject", "")); + assertEquals(List.of("other"), complete("faction", "transfersubject", "realm", "")); + assertEquals(List.of("other"), complete("faction", "setrelation", "realm", "")); + assertEquals(List.of("other"), complete("faction", "settreaty", "realm", "")); + RelationType diplomatic = mock(RelationType.class); + RelationType trade = mock(RelationType.class); + RelationType political = mock(RelationType.class); + when(diplomatic.getId()).thenReturn("alliance"); + when(trade.getId()).thenReturn("trade"); + when(political.getId()).thenReturn("non_aggression"); + types.when(RelationLoader::getDiplomaticTypes).thenReturn(List.of(diplomatic)); + types.when(RelationLoader::getTreatyTypes).thenReturn(List.of(trade)); + types.when(RelationLoader::getPoliticalTreatyTypes).thenReturn(List.of(political)); + assertEquals(List.of("alliance"), complete("faction", "setrelation", "realm", "other", "")); + assertEquals( + List.of("trade", "non_aggression"), + complete("faction", "settreaty", "realm", "other", "")); + } + } + + @Test + void lawSuggestionsUseTheSelectedGroupAndStanceOwnersAreDeduplicated() { + admin(); + LawGroup group = mock(LawGroup.class); + when(group.getId()).thenReturn("tax"); + when(group.getLaws()).thenReturn(Map.of("low", mock(Law.class))); + try (MockedStatic<LawLoader> laws = mockStatic(LawLoader.class)) { + laws.when(LawLoader::getList).thenReturn(List.of(group)); + laws.when(() -> LawLoader.getByString("tax")).thenReturn(group); + assertEquals(List.of("tax"), complete("faction", "setlaw", "realm", "")); + assertEquals(List.of("low"), complete("faction", "setlaw", "realm", "tax", "")); + assertTrue(complete("faction", "setlaw", "realm", "missing", "").isEmpty()); + } + Guild base = mock(Guild.class); + when(base.getId()).thenReturn("realm"); + factions.when(FactionManager::getAllGuilds).thenReturn(List.of(base, guild)); + assertEquals(List.of("realm", "merchants", "other"), complete("faction", "setstance", "")); + assertEquals( + List.of("oppose", "neutral", "support"), complete("faction", "setstance", "realm", "")); + } + + @Test + void playerOnlyAndUnknownCommandsLeaveConsoleCompletionEmpty() { + CommandSender console = mock(CommandSender.class); + for (String command : List.of("guild", "faction", "company", "unrelated")) + assertTrue(complete(console, command, "").isEmpty()); + assertTrue(complete("unrelated", "unknown").isEmpty()); + } + + private void installationChoices() { + InstallationHandler installations = mock(InstallationHandler.class); + Installation fort = mock(Installation.class); + Installation port = mock(Installation.class); + when(fort.getId()).thenReturn("fort"); + when(port.getId()).thenReturn("port"); + when(installations.getAll()).thenReturn(List.of(fort, port)); + InstallationConstruction pending = mock(InstallationConstruction.class); + when(pending.getId()).thenReturn("fort_building"); + when(installations.getPendingConstruction()).thenReturn(pending); + when(realm.getInstallationHandler()).thenReturn(installations); + factions.when(() -> FactionManager.getByLeader("Leader")).thenReturn(realm); + } + + private void admin() { + when(player.hasPermission(Permissions.Permission_Admin)).thenReturn(true); + } + + private Faction realm(String id, String name) { + Faction faction = mock(Faction.class); + when(faction.getId()).thenReturn(id); + when(faction.getName()).thenReturn(name); + when(faction.getLeader()).thenReturn("Leader"); + when(faction.getMembers()).thenReturn(new ArrayList<>(List.of("Leader", "Alice", "Bob"))); + when(faction.getGuildHandler()).thenReturn(new GuildHandler(faction)); + return faction; + } + + private List<String> complete(String commandName, String... args) { + return complete(player, commandName, args); + } + + private List<String> complete(CommandSender sender, String commandName, String... args) { + Command command = mock(Command.class); + when(command.getName()).thenReturn(commandName); + List<String> result = completion.onTabComplete(sender, command, commandName, args); + return result == null ? List.of() : result; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/TitleManagementCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/TitleManagementCoverageTest.java new file mode 100644 index 00000000..2800d426 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/TitleManagementCoverageTest.java @@ -0,0 +1,646 @@ +package net.tfminecraft.simplefactions.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.government.stability.StabilityReport; +import net.tfminecraft.simplefactions.government.stability.StabilityStatus; +import net.tfminecraft.simplefactions.government.stability.StateStability; +import net.tfminecraft.simplefactions.loaders.TierLoader; +import net.tfminecraft.simplefactions.loaders.TitleLoader; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.tiers.Tier; +import net.tfminecraft.simplefactions.tiers.Title; +import net.tfminecraft.simplefactions.tiers.admin.TitleAdminCommand; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +/** Real realm/title state with only Paper scheduling and title-file persistence isolated. */ +class TitleManagementCoverageTest { + private FactionDomainFixture fixture; + private Faction faction; + private Player player; + private Map<Player, Tier> previousPending; + private MockedStatic<TitleLoader> persistence; + private final List<Title> created = new ArrayList<>(); + private final TitleManager manager = new TitleManager(); + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + faction = fixture.saved("realm", "Leader"); + faction.getOrCreateMainGuild(); + faction.addProvince(10); + player = fixture.player("Leader"); + previousPending = TitleManager.isFormingTitle; + TitleManager.isFormingTitle = new HashMap<>(); + List<Title> registry = TitleLoader.getTitles(); + persistence = mockStatic(TitleLoader.class); + persistence.when(TitleLoader::getTitles).thenReturn(registry); + persistence.when(() -> TitleLoader.getById(any())).thenCallRealMethod(); + persistence.when(() -> TitleLoader.getByProvince(anyInt())).thenCallRealMethod(); + persistence.when(() -> TitleLoader.getByTitle(any())).thenCallRealMethod(); + persistence.when(() -> TitleLoader.getByTier(any())).thenCallRealMethod(); + persistence.when(() -> TitleLoader.saveTitle(any())).thenReturn(true); + persistence + .when( + () -> + TitleLoader.createNewTitle( + any(), + anyString(), + anyString(), + anyString(), + anyList(), + anyList(), + anyBoolean())) + .thenAnswer( + call -> { + Tier tier = call.getArgument(0); + JsonObject json = new JsonObject(); + json.addProperty("name", (String) call.getArgument(2)); + json.addProperty("rgb", (String) call.getArgument(3)); + List<Integer> provinces = call.getArgument(4); + List<String> children = call.getArgument(5); + JsonArray parts = new JsonArray(); + if (tier.getTier() == 2) { + provinces.forEach(parts::add); + json.add("provinces", parts); + } else { + children.forEach(parts::add); + json.add("titles", parts); + } + Title title = new Title(tier, call.getArgument(1), json); + registry.add(title); + created.add(title); + return title; + }); + } + + @AfterEach + void close() { + try { + persistence.close(); + TitleManager.isFormingTitle = previousPending; + } finally { + fixture.close(); + } + } + + @Test + void asynchronousTitleChatDefersAllGameStateChangesUntilTheMainThread() { + Tier county = tier("county", 2, 1); + TitleManager.isFormingTitle.put(player, county); + AsyncPlayerChatEvent event = chat("North Vale"); + manager.formTitle(event); + assertTrue(event.isCancelled()); + assertTrue(created.isEmpty(), "Async chat must not persist or claim a title"); + assertTrue(faction.getTitles().isEmpty()); + fixture.ui.runTasks(); + assertEquals(1, created.size()); + Title title = created.getFirst(); + assertEquals("North_Vale", title.getId()); + assertEquals("North Vale", title.getName()); + assertEquals(List.of(10), title.getProvinces()); + assertTrue(faction.hasTitle(title)); + verify(fixture.map).enqueue("county", title.getRgb()); + assertFalse(TitleManager.isFormingTitle.containsKey(player)); + } + + @ParameterizedTest + @ValueSource(strings = {"leader", "land", "liege", "members", "disabled", "removed_tier"}) + void titleRequirementsAreRevalidatedWhenTheNameArrives(String change) { + Tier county = tier("county", 2, 1); + TitleManager.isFormingTitle.put(player, county); + switch (change) { + case "leader" -> { + faction.addMember("Successor"); + faction.setLeader("Successor"); + } + case "land" -> faction.removeProvince(10, false); + case "liege" -> { + Faction liege = fixture.saved("liege", "Liege"); + liege.addProvince(20); + fixture.subject(liege, faction); + } + case "members" -> { + faction.addTitle(fixture.title("existing", "county", 10)); + faction.addProvince(11); + } + case "disabled" -> tier("county", 2, 0); + case "removed_tier" -> TierLoader.oList.remove(county); + } + AsyncPlayerChatEvent event = chat("Stale Title"); + manager.formTitle(event); + fixture.ui.runTasks(); + assertTrue(event.isCancelled()); + assertTrue(created.isEmpty(), "Changed " + change + " must prevent creation"); + assertFalse(TitleManager.isFormingTitle.containsKey(player)); + verify(fixture.map, never()).enqueue(eq("county"), anyString()); + } + + @Test + void aGovernmentThatCollapsesBeforeTheNameArrivesCannotCreateATitle() { + TitleManager.isFormingTitle.put(player, tier("county", 2, 1)); + StabilityReport report = new StabilityReport(); + report.status = StabilityStatus.COLLAPSING; + report.stability = 5; + try (MockedStatic<StateStability> state = mockStatic(StateStability.class)) { + state.when(() -> StateStability.of(faction)).thenReturn(report); + manager.formTitle(chat("Collapsing County")); + fixture.ui.runTasks(); + assertTrue(created.isEmpty()); + assertTrue(faction.getTitles().isEmpty()); + assertFalse(TitleManager.isFormingTitle.containsKey(player)); + } + } + + @Test + void realmUntitledProvincesCountEverySubjectProvinceOnce() { + Faction subject = fixture.saved("subject", "Subject"); + Faction nested = fixture.saved("nested", "Nested"); + subject.addProvince(20); + nested.addProvince(30); + fixture.subject(faction, subject); + fixture.subject(subject, nested); + assertEquals(List.of(10, 20, 30), TitleManager.getAllUntitledProvinces(faction)); + } + + @Test + void findingAParentSkipsMissingChildrenInPersistedTitleDefinitions() { + Title child = fixture.title("child", "county", 10); + Title parent = composite("parent", "duchy", "deleted_child", "child"); + assertSame(parent, TitleManager.getParent(child)); + assertNull(TitleManager.getParent(parent)); + } + + @Test + void chatWithoutAPendingTitleRemainsPublicAndDoesNotScheduleWork() { + AsyncPlayerChatEvent event = chat("hello everyone"); + manager.formTitle(event); + assertFalse(event.isCancelled()); + assertTrue(fixture.ui.tasks.isEmpty()); + assertTrue(created.isEmpty()); + } + + @Test + void duplicateIdsAreRejectedWithoutChangingTheExistingTitle() { + Title existing = fixture.title("north_vale", "county", 90); + TitleManager.isFormingTitle.put(player, tier("county", 2, 1)); + manager.formTitle(chat("North Vale")); + fixture.ui.runTasks(); + assertTrue(created.isEmpty()); + assertEquals(List.of(90), existing.getProvinces()); + assertTrue(faction.getTitles().isEmpty()); + verify(player).sendMessage("§cA title with that ID already exists!"); + assertFalse(TitleManager.isFormingTitle.containsKey(player)); + } + + @Test + void failureToPersistTheTitleDoesNotClaimItOrQueueTheMap() { + persistence + .when( + () -> + TitleLoader.createNewTitle( + any(), + anyString(), + anyString(), + anyString(), + anyList(), + anyList(), + anyBoolean())) + .thenReturn(null); + TitleManager.isFormingTitle.put(player, tier("county", 2, 1)); + manager.formTitle(chat("North Vale")); + fixture.ui.runTasks(); + assertTrue(faction.getTitles().isEmpty()); + assertTrue(created.isEmpty()); + assertFalse(TitleManager.isFormingTitle.containsKey(player)); + verify(fixture.map, never()).enqueue(eq("county"), anyString()); + } + + @Test + void higherTitleFormationUsesTheRealFreeLowerTitles() { + faction.addMember("Member"); + faction.addProvince(11); + Title north = fixture.title("north", "county", 10); + Title south = fixture.title("south", "county", 11); + faction.addTitle(north); + faction.addTitle(south); + TitleManager.isFormingTitle.put(player, tier("duchy", 3, 2)); + manager.formTitle(chat("River Duchy")); + fixture.ui.runTasks(); + assertEquals(1, created.size()); + Title duchy = created.getFirst(); + assertEquals(List.of("north", "south"), duchy.getTitles()); + assertTrue(faction.hasTitle(duchy)); + assertEquals(List.of(10, 11), TitleManager.getProvinces(duchy)); + assertFalse(TitleManager.isFormingTitle.containsKey(player)); + } + + @Test + void realmQueriesIncludeNestedSubjectsAndKeepTitleAndProvinceOwnershipSeparate() { + Faction subject = fixture.saved("subject", "Subject"); + Faction nested = fixture.saved("nested", "Nested"); + subject.addProvince(20); + nested.addProvince(30); + fixture.subject(faction, subject); + fixture.subject(subject, nested); + Title home = fixture.title("home", "county", 10); + Title vassal = fixture.title("vassal", "county", 20); + faction.addTitle(home); + subject.addTitle(vassal); + Title vacant = fixture.title("vacant", "county", 40); + assertEquals(3, TitleManager.getRealmSize(faction)); + assertEquals(List.of(10, 20, 30), TitleManager.getProvinces(faction)); + assertEquals(List.of(home, vassal), TitleManager.getTitles(faction)); + assertEquals(List.of(30), TitleManager.getAllUntitledProvinces(faction)); + assertSame(subject, TitleManager.getByProvince(20)); + assertNull(TitleManager.getByProvince(99)); + assertTrue(TitleManager.titleIsInRealm(faction, "VASSAL")); + assertFalse(TitleManager.titleIsInRealm(faction, "vacant")); + assertSame(faction, TitleManager.getOwner(home)); + assertNull(TitleManager.getOwner(vacant)); + assertEquals(List.of(home, vassal), TitleManager.getAllOwnedTitles()); + assertEquals(List.of(vacant), TitleManager.getAllUnownedTitles()); + int oldCost = Cache.provinceCost; + try { + Cache.provinceCost = 1000000; + assertEquals(1000000, TitleManager.getClaimCost(faction)); + assertFalse(TitleManager.overProvinceCap(faction)); + faction.addProvince(11); + assertTrue(TitleManager.overProvinceCap(faction)); + assertEquals(2000000, TitleManager.getClaimCost(faction)); + } finally { + Cache.provinceCost = oldCost; + } + } + + @Test + void grantableTitlesRequireSpareTitlesOfTheRequestedTierAndReceiverLand() { + Faction receiver = fixture.saved("receiver", "Receiver"); + receiver.addProvince(20); + Title home = fixture.title("home", "county", 10); + Title spare = fixture.title("spare", "county", 20); + faction.addTitle(home); + faction.addMember("Member"); + faction.addTitle(spare); + Tier county = TierLoader.getByString("county"); + assertEquals(List.of(spare), TitleManager.getGrantableTitles(faction, receiver, county)); + assertTrue( + TitleManager.getGrantableTitles(faction, receiver, TierLoader.getByString("duchy")) + .isEmpty()); + faction.removeTitle(home); + assertTrue(TitleManager.getGrantableTitles(faction, receiver, county).isEmpty()); + } + + @Test + void administratorsMustHavePermissionAndTheProvinceFeatureEnabled() { + CommandSender sender = mock(CommandSender.class); + assertTrue(TitleAdminCommand.handle(sender, new String[] {"title", "list"})); + verify(sender).sendMessage("§a[SimpleFactions]§c You do not have access to this command"); + when(sender.hasPermission("simplefactions.admin")).thenReturn(true); + clearInvocations(sender); + assertTrue(TitleAdminCommand.handle(sender, new String[] {"title", "list"})); + assertTrue(messages(sender).stream().anyMatch(m -> m.toLowerCase().contains("disabled"))); + assertTrue(TitleLoader.getTitles().isEmpty()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "info", + "where", + "rename", + "setcolour", + "addprovince", + "removeprovince", + "addtitle", + "removetitle", + "setcomplete" + }) + void incompleteAdminCommandsPrintTheirSpecificUsageWithoutChangingTitles(String sub) { + CommandSender sender = admin(); + Title title = fixture.title("home", "county", 10); + assertTrue(TitleAdminCommand.handle(sender, new String[] {"title", sub})); + assertTrue( + messages(sender).stream().anyMatch(m -> m.startsWith("§6/faction title " + sub + " "))); + assertEquals("home", title.getName()); + persistence.verify(() -> TitleLoader.saveTitle(any()), never()); + } + + @ParameterizedTest + @ValueSource(strings = {"short", "unknown"}) + void generalAdminUsageListsAllSupportedCommands(String shape) { + CommandSender sender = admin(); + String[] args = + shape.equals("short") ? new String[] {"title"} : new String[] {"title", "unsupported"}; + assertTrue(TitleAdminCommand.handle(sender, args)); + ArgumentCaptor<String[]> usage = ArgumentCaptor.forClass(String[].class); + verify(sender).sendMessage(usage.capture()); + assertEquals(TitleAdminCommand.SUBCOMMANDS.size(), usage.getValue().length); + assertTrue(usage.getValue()[0].contains("title list")); + } + + @Test + void adminListAndInfoDescribePartsParentsHoldersAndMissingDefinitions() { + CommandSender sender = admin(); + Title county = fixture.title("north", "county", 10); + faction.addTitle(county); + Title duchy = composite("river", "duchy", "north", "missing"); + command(sender, "list"); + assertTrue( + messages(sender).stream() + .anyMatch( + m -> + m.contains("north") + && m.contains("1 provinces") + && m.contains("held by realm"))); + assertTrue( + messages(sender).stream().anyMatch(m -> m.contains("river") && m.contains("2 titles"))); + clearInvocations(sender); + command(sender, "list", "county"); + assertTrue(messages(sender).stream().anyMatch(m -> m.contains("north"))); + assertFalse(messages(sender).stream().anyMatch(m -> m.contains("river"))); + clearInvocations(sender); + command(sender, "list", "missing"); + verify(sender).sendMessage("§a[SimpleFactions]§c No tier with the id missing"); + command(sender, "info", "missing"); + verify(sender).sendMessage("§a[SimpleFactions]§c No title with the id missing"); + command(sender, "info", county.getId()); + verify(sender).sendMessage("§7Provinces: §f[10]"); + verify(sender).sendMessage("§7Part of: §friver (river)"); + verify(sender).sendMessage("§7Holder: §frealm"); + command(sender, "info", duchy.getId()); + verify(sender).sendMessage(contains("missing (missing)")); + verify(sender).sendMessage("§7All provinces: §f[10]"); + verify(sender).sendMessage("§7Part of: §fnone"); + verify(sender).sendMessage("§7Holder: §fnone"); + } + + @Test + void adminWhereExplainsTheProvinceChainAndRejectsUnknownInputs() { + CommandSender sender = admin(); + when(fixture.provinces.contains(10)).thenReturn(true); + when(fixture.provinces.contains(90)).thenReturn(true); + fixture.title("north", "county", 10); + composite("river", "duchy", "north"); + command(sender, "where", "ten"); + verify(sender).sendMessage("§a[SimpleFactions]§c Province ids must be numbers"); + command(sender, "where", "99"); + verify(sender).sendMessage("§a[SimpleFactions]§c No province with the id 99"); + command(sender, "where", "10"); + verify(sender).sendMessage("§7Province §f10§7: §fnorth (north) §7→ §friver (river)"); + verify(sender).sendMessage("§7Controlled by: §frealm"); + command(sender, "where", "90"); + verify(sender).sendMessage("§7Province §f90§7: §funtitled"); + verify(sender).sendMessage("§7Controlled by: §fnobody"); + } + + @Test + void adminRenameAndColourChangesPersistAndScheduleTheAffectedMapColours() { + CommandSender sender = admin(); + Title title = fixture.title("north", "county", 10); + faction.addTitle(title); + command(sender, "rename", "north", "North", "Vale"); + assertEquals("North Vale", title.getName()); + persistence.verify(() -> TitleLoader.saveTitle(title)); + verify(sender).sendMessage(contains("is held by realm")); + command(sender, "setcolor", "north", "7,", "8,", "9"); + assertEquals("7,8,9", title.getRgb()); + verify(fixture.map, atLeastOnce()).enqueue("county", "4,5,6"); + verify(fixture.map, atLeastOnce()).enqueue("county", "7,8,9"); + verify(sender, atLeastOnce()).sendMessage("§7The web map updates on its next cycle."); + command(sender, "setcolour", "north", "10,11,12"); + assertEquals("10,11,12", title.getRgb()); + } + + @Test + void adminProvinceEditsPreserveTitleOwnershipAndExplainMissingLand() { + CommandSender sender = admin(); + Title title = fixture.title("north", "county", 10); + faction.addTitle(title); + when(fixture.provinces.contains(11)).thenReturn(true); + command(sender, "addprovince", "north", "11"); + assertEquals(List.of(10, 11), title.getProvinces()); + assertTrue(faction.hasTitle(title)); + command(sender, "removeprovince", "north", "10"); + assertEquals(List.of(11), title.getProvinces()); + assertTrue(faction.hasTitle(title)); + verify(sender).sendMessage(startsWith("§enorth is held by realm")); + command(sender, "setcomplete", "north", "true"); + assertTrue(title.isTitleComplete()); + command(sender, "setcomplete", "north", "false"); + assertFalse(title.isTitleComplete()); + } + + @Test + void adminCompositeEditsUpdateChildrenWithoutGrantingUnownedTitles() { + CommandSender sender = admin(); + Title north = fixture.title("north", "county", 10); + Title south = fixture.title("south", "county", 11); + Title river = composite("river", "duchy", "north"); + faction.addTitle(north); + faction.addTitle(river); + command(sender, "addtitle", "river", "south"); + assertEquals(List.of("north", "south"), river.getTitles()); + verify(sender).sendMessage(contains("of its titles")); + command(sender, "removetitle", "river", "south"); + assertEquals(List.of("north"), river.getTitles()); + assertFalse(faction.hasTitle(south)); + } + + @Test + void failedAdminEditsDoNotPersistAndSaveFailuresAreReported() { + CommandSender sender = admin(); + Title title = fixture.title("north", "county", 10); + command(sender, "setcolour", "north", "invalid"); + assertEquals("4,5,6", title.getRgb()); + persistence.verify(() -> TitleLoader.saveTitle(any()), never()); + persistence.when(() -> TitleLoader.saveTitle(title)).thenReturn(false); + command(sender, "rename", "north", "Renamed"); + assertEquals("Renamed", title.getName()); + verify(sender).sendMessage(contains("The change is live but will be lost on restart")); + } + + @Test + void adminCompletionsOfferOnlyRelevantTiersTitlesPartsAndValues() { + fixture.title("north", "county", 10, 11); + fixture.title("south", "county", 20); + composite("river", "duchy", "north"); + assertEquals(List.of("setcolour", "setcomplete"), complete("set")); + assertEquals(List.of("county", "duchy"), complete("list", "")); + assertEquals(List.of("<province>"), complete("where", "")); + assertEquals(List.of("north", "south"), complete("addprovince", "")); + assertEquals(List.of("river"), complete("addtitle", "")); + assertEquals(List.of("river"), complete("removetitle", "")); + assertEquals(List.of("north"), complete("info", "N")); + assertEquals(List.of("north", "south", "river"), complete("setcolor", "")); + assertTrue(complete("unknown", "").isEmpty()); + assertEquals(List.of("<name>"), complete("rename", "north", "")); + assertEquals(List.of("4,5,6"), complete("setcolour", "north", "")); + assertEquals(List.of("R,G,B"), complete("setcolor", "missing", "")); + assertEquals(List.of("<province>"), complete("addprovince", "north", "")); + assertEquals(List.of("10", "11"), complete("removeprovince", "north", "")); + assertEquals(List.of("south"), complete("addtitle", "river", "")); + assertEquals(List.of("north"), complete("removetitle", "river", "")); + assertEquals(List.of("true", "false"), complete("setcomplete", "north", "")); + assertTrue(complete("unknown", "north", "").isEmpty()); + assertTrue(complete("removeprovince", "missing", "").isEmpty()); + assertTrue(complete("rename", "north", "New", "Name").isEmpty()); + } + + private CommandSender admin() { + fixture.provincesEnabled(true); + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("simplefactions.admin")).thenReturn(true); + when(sender.getName()).thenReturn("Admin"); + return sender; + } + + private void command(CommandSender sender, String... args) { + List<String> full = new ArrayList<>(List.of("title")); + full.addAll(List.of(args)); + assertTrue(TitleAdminCommand.handle(sender, full.toArray(String[]::new))); + } + + private List<String> complete(String... args) { + List<String> full = new ArrayList<>(List.of("title")); + full.addAll(List.of(args)); + return TitleAdminCommand.complete(full.toArray(String[]::new)); + } + + private List<String> messages(CommandSender sender) { + return mockingDetails(sender).getInvocations().stream() + .filter( + call -> + call.getMethod().getName().equals("sendMessage") + && call.getArguments().length == 1 + && call.getArgument(0) instanceof String) + .map(call -> (String) call.getArgument(0)) + .toList(); + } + + @Test + void aNameThatNormalizesToAnEmptyIdDoesNotCreateAnUnaddressableTitle() { + TitleManager.isFormingTitle.put(player, tier("county", 2, 1)); + manager.formTitle(chat("§c!!!")); + fixture.ui.runTasks(); + assertTrue(created.isEmpty()); + assertTrue(faction.getTitles().isEmpty()); + assertFalse(TitleManager.isFormingTitle.containsKey(player)); + } + + @ParameterizedTest + @ValueSource(strings = {"leadership", "land", "offline", "cancelled"}) + void delayedTitleCreationRechecksTheStateAtTaskExecution(String change) { + TitleManager.isFormingTitle.put(player, tier("county", 2, 1)); + manager.formTitle(chat("North Vale")); + assertTrue(created.isEmpty()); + switch (change) { + case "leadership" -> { + faction.addMember("Successor"); + faction.setLeader("Successor"); + } + case "land" -> faction.removeProvince(10, false); + case "offline" -> when(player.isOnline()).thenReturn(false); + case "cancelled" -> TitleManager.isFormingTitle.remove(player); + } + fixture.ui.runTasks(); + assertTrue(created.isEmpty()); + assertTrue(faction.getTitles().isEmpty()); + assertFalse(TitleManager.isFormingTitle.containsKey(player)); + } + + @Test + void severalQueuedNamesCanConsumeOnePendingFormationOnlyOnce() { + TitleManager.isFormingTitle.put(player, tier("county", 2, 1)); + AsyncPlayerChatEvent first = chat("First Name"), second = chat("Second Name"); + manager.formTitle(first); + manager.formTitle(second); + assertTrue(first.isCancelled()); + assertTrue(second.isCancelled()); + assertTrue(created.isEmpty()); + fixture.ui.runTasks(); + assertEquals(1, created.size()); + assertEquals("First_Name", created.getFirst().getId()); + assertEquals(1, faction.getTitles().size()); + } + + @Test + void destroyingASubjectsTitleTargetsItsDirectHolderRatherThanAnEarlierRegisteredLiege() { + Faction subject = fixture.saved("subject", "Subject"); + subject.addProvince(20); + fixture.subject(faction, subject); + Title title = fixture.title("subject_county", "county", 20); + subject.addTitle(title); + fixture.provincesEnabled(true); + when(player.hasPermission("simplefactions.admin")).thenReturn(true); + Command command = mock(Command.class); + when(command.getName()).thenReturn("faction"); + new CommandManager() + .onCommand(player, command, "faction", new String[] {"destroytitle", title.getId()}); + assertFalse( + subject.hasTitle(title), "The admin command must remove the title from its actual holder"); + assertTrue(faction.getTitles().isEmpty()); + verify(player).sendMessage("§aDestroyed title subject_county §7(subject_county)"); + } + + @Test + void adminInfoNamesTheSubjectThatDirectlyHoldsATitle() { + Faction subject = fixture.saved("subject", "Subject"); + subject.addProvince(20); + fixture.subject(faction, subject); + Title title = fixture.title("subject_county", "county", 20); + subject.addTitle(title); + CommandSender sender = admin(); + command(sender, "info", title.getId()); + verify(sender).sendMessage("§7Holder: §fsubject"); + assertSame(subject, TitleManager.getOwner(title)); + } + + private AsyncPlayerChatEvent chat(String message) { + return new AsyncPlayerChatEvent(true, player, message, new HashSet<>()); + } + + private Tier tier(String id, int level, int cost) { + TierLoader.oList.removeIf(t -> t.getId().equals(id)); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("tier", level); + yaml.set("form-cost", cost); + Tier tier = new Tier(id, yaml); + TierLoader.oList.add(tier); + return tier; + } + + private Title composite(String id, String tier, String... children) { + JsonObject json = new JsonObject(); + json.addProperty("name", id); + json.addProperty("rgb", "4,5,6"); + JsonArray parts = new JsonArray(); + for (String child : children) parts.add(child); + json.add("titles", parts); + Title title = new Title(TierLoader.getByString(tier), id, json); + TitleLoader.getTitles().add(title); + return title; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/TradeRelationMigrationTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/TradeRelationMigrationTest.java index 2e8864d2..59ce4ce4 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/TradeRelationMigrationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/TradeRelationMigrationTest.java @@ -132,6 +132,7 @@ void acceptTradeRequestWritesTheTradeMap() { MockedStatic<RequestManager> requests = mockStatic(RequestManager.class); MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) { factions.when(() -> FactionManager.getByLeader("receiver-leader")).thenReturn(receiverFaction); + factions.when(() -> FactionManager.getByString("sender")).thenReturn(senderFaction); requests.when(() -> RequestManager.getRequest(player)) .thenReturn(new RelationRequest(guild, trade, true)); bukkit.when(() -> Bukkit.getPlayerExact("sender-leader")).thenReturn(null); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/WarManagerDeclareTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/WarManagerDeclareTest.java index 9c25eed8..46fc9f5f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/WarManagerDeclareTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/WarManagerDeclareTest.java @@ -41,7 +41,9 @@ void tearDown() { @Test void declareWar_doesNotCallEndVassalage() { Faction attacker = mock(Faction.class); + when(attacker.getDiplomacyHandler()).thenReturn(new net.tfminecraft.simplefactions.diplomacy.DiplomacyHandler(attacker)); Faction defender = mock(Faction.class); + when(defender.getDiplomacyHandler()).thenReturn(new net.tfminecraft.simplefactions.diplomacy.DiplomacyHandler(defender)); when(attacker.getId()).thenReturn("atk"); when(defender.getId()).thenReturn("def"); when(attacker.getRelations()).thenReturn(new HashMap<>()); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ArmyMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ArmyMenusCoverageTest.java new file mode 100644 index 00000000..7af1e2df --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ArmyMenusCoverageTest.java @@ -0,0 +1,846 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.army.LevyEntry; +import net.tfminecraft.simplefactions.army.MilitaryExpansion; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.diplomacy.Relation; +import net.tfminecraft.simplefactions.enums.FactionModifiers; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.enums.Scope; +import net.tfminecraft.simplefactions.espionage.EspionageService; +import net.tfminecraft.simplefactions.government.stability.StabilityReport; +import net.tfminecraft.simplefactions.government.stability.StabilityStatus; +import net.tfminecraft.simplefactions.government.stability.StateStability; +import net.tfminecraft.simplefactions.keys.Keys; +import net.tfminecraft.simplefactions.laws.LawEffect; +import net.tfminecraft.simplefactions.loaders.RelationLoader; +import net.tfminecraft.simplefactions.loaders.TierLoader; +import net.tfminecraft.simplefactions.loaders.TitleLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.managers.TitleManager; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.FactionModifier; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.tiers.Tier; +import net.tfminecraft.simplefactions.tiers.Title; +import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenanceStore; +import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; +import net.tfminecraft.simplefactions.war.freeze.PreparationFreeze; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemCreator; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +class ArmyMenusCoverageTest { + private FactionDomainFixture fixture; + private Faction faction; + private Player player; + private InventoryManager navigation; + private TierTitleView titles; + private MilitaryView military; + private java.util.Map<Player, Tier> oldForming; + private final List<MockedStatic<?>> scopes = new ArrayList<>(); + + private <T> MockedStatic<T> scoped(Class<T> type) { + MockedStatic<T> s = mockStatic(type); + scopes.add(s); + return s; + } + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + fixture.lawGroup("military", Map.of()); + faction = fixture.saved("realm", "Leader"); + faction.getOrCreateMainGuild(); + player = fixture.player("Leader"); + faction.addProvince(10); + navigation = mock(InventoryManager.class); + navigation.confirming = new HashMap<>(); + when(navigation.createBackButton(any())).thenAnswer(c -> new ItemStack(Material.BARRIER)); + titles = new TierTitleView(navigation); + military = new MilitaryView(navigation); + oldForming = TitleManager.isFormingTitle; + TitleManager.isFormingTitle = new HashMap<>(); + scoped(IconGetter.class); + ItemAPI api = mock(ItemAPI.class); + ItemCreator items = mock(ItemCreator.class); + when(api.getCreator()).thenReturn(items); + when(items.getItemsAdderItem(anyString())).thenAnswer(c -> new ItemStack(Material.PAPER)); + scoped(TLibs.class).when(TLibs::getItemAPI).thenReturn(api); + } + + @AfterEach + void close() { + try { + for (int n = scopes.size() - 1; n >= 0; n--) scopes.get(n).close(); + } finally { + TitleManager.isFormingTitle = oldForming; + fixture.close(); + } + } + + @ParameterizedTest + @ValueSource(strings = {"leadership", "removed"}) + void anOpenTierAliasButtonCannotMutateAFormerlyLedOrRemovedFaction(String change) { + faction.getTier().getAliases().add("March"); + titles.tierView(null, player, faction, true); + Inventory inventory = top(); + int slot = -1; + for (int n = 0; n < inventory.getSize(); n++) + if (inventory.getItem(n) != null + && inventory.getItem(n).getType() == Material.YELLOW_CONCRETE) { + slot = n; + break; + } + assertTrue(slot >= 0); + assertEquals(-1, faction.getTier().getIndex()); + if (change.equals("leadership")) { + faction.addMember("Successor"); + faction.setLeader("Successor"); + } else FactionManager.factions.clear(); + InventoryClickEvent event = fixture.ui.click(player, slot); + assertDoesNotThrow(() -> titles.click(event, inventory, player)); + assertEquals(-1, faction.getTier().getIndex()); + assertTrue(event.isCancelled()); + } + + @ParameterizedTest + @ValueSource(strings = {"leadership", "requirements", "owner"}) + void titleClaimClicksRecheckTheCurrentLeaderEligibilityAndOwner(String change) { + Title title = fixture.title("North County", "county", 10); + titles.titleTypeView(null, player, faction, title.getTier(), true, 0); + Inventory inventory = top(); + assertEquals(Material.YELLOW_CONCRETE, inventory.getItem(0).getType()); + if (change.equals("leadership")) { + faction.addMember("Successor"); + faction.setLeader("Successor"); + } + if (change.equals("requirements")) faction.getProvinceHandler().removeProvince(10, false); + Faction liege = null; + if (change.equals("owner")) { + liege = fixture.saved("liege", "Liege"); + liege.getOrCreateMainGuild(); + fixture.subject(liege, faction); + liege.addTitle(title); + } + InventoryClickEvent event = fixture.ui.click(player, 0); + titles.click(event, inventory, player); + assertFalse(faction.hasTitle(title), "A stale yellow claim button must not grant a title"); + if (liege != null) assertTrue(liege.hasTitle(title)); + assertTrue(event.isCancelled()); + } + + @ParameterizedTest + @ValueSource(strings = {"independent", "ownership", "last_title"}) + void aStaleGrantButtonCannotTransferANoLongerGrantableTitle(String change) { + Faction subject = fixture.saved("subject", "Subject"); + subject.getOrCreateMainGuild(); + subject.addProvince(20); + fixture.subject(faction, subject); + Title title = fixture.title("Subject County", "county", 20); + Title retained = fixture.title("Home County", "county", 10); + faction.addTitle(title); + faction.addTitle(retained); + assertTrue(TitleManager.getGrantableTitles(faction, subject, title.getTier()).contains(title)); + titles.titleTypeView(null, player, subject, title.getTier(), true, 0); + Inventory inventory = top(); + assertEquals(Material.GREEN_CONCRETE, inventory.getItem(0).getType()); + if (change.equals("independent")) { + faction.setRelation( + subject, + new Relation(RelationLoader.getDefaultType(), RelationLoader.getDefaultAttitude())); + subject.setRelation( + faction, + new Relation(RelationLoader.getDefaultType(), RelationLoader.getDefaultAttitude())); + } + if (change.equals("ownership")) faction.stripTitle(title); + if (change.equals("last_title")) faction.stripTitle(retained); + boolean previouslyOwned = faction.hasTitle(title); + InventoryClickEvent event = fixture.ui.click(player, 0); + titles.click(event, inventory, player); + assertFalse(subject.hasTitle(title), "A stale grant must not confer the title"); + assertEquals(previouslyOwned, faction.hasTitle(title)); + assertTrue(event.isCancelled()); + } + + @Test + void tierAliasesKeepTheConfiguredNameAndOnlyTheCurrentTierCanBeChanged() { + faction.getTier().getAliases().add("March"); + titles.tierView(null, player, faction, true); + Inventory inventory = top(); + ItemStack base = inventory.getItem(9), alias = inventory.getItem(10); + assertEquals(Material.GREEN_CONCRETE, base.getType()); + assertEquals("province", name(base)); + assertEquals(Material.YELLOW_CONCRETE, alias.getType()); + assertEquals("March (province)", name(alias)); + assertEquals(Material.RED_CONCRETE, inventory.getItem(18).getType()); + titles.click(fixture.ui.click(player, 10), inventory, player); + assertEquals(0, faction.getTier().getIndex()); + assertEquals(Material.GREEN_CONCRETE, inventory.getItem(10).getType()); + titles.click(fixture.ui.click(player, 9), inventory, player); + assertEquals(-1, faction.getTier().getIndex()); + titles.click(fixture.ui.click(player, 18), inventory, player); + assertEquals(-1, faction.getTier().getIndex()); + Player visitor = fixture.player("Visitor"); + titles.tierView(null, visitor, faction, true); + Inventory publicMenu = visitor.getOpenInventory().getTopInventory(); + assertNull(publicMenu.getItem(9)); + assertEquals(Material.BARRIER, publicMenu.getItem(53).getType()); + } + + @Test + void titleItemsDistinguishOwnedAvailableUnreachableAndAnotherOwnersLand() { + Title own = fixture.title("Home", "county", 10); + Title available = fixture.title("East", "county", 11); + Title distant = fixture.title("West", "county", 30); + Title theirs = fixture.title("Foreign", "county", 40); + faction.addMember("Citizen"); + faction.addProvince(11); + faction.addTitle(own); + Faction other = fixture.saved("other", "Other"); + other.addProvince(40); + other.addTitle(theirs); + List<ItemStack> rendered = + titles.creator.getSortedTitleItems( + player, faction, List.of(theirs, distant, own, available)); + assertEquals( + List.of(own.getId(), available.getId(), distant.getId(), theirs.getId()), + rendered.stream().map(this::id).toList()); + assertEquals( + List.of( + Material.GREEN_CONCRETE, + Material.YELLOW_CONCRETE, + Material.RED_CONCRETE, + Material.GRAY_CONCRETE), + rendered.stream().map(ItemStack::getType).toList()); + assertTrue(lore(rendered.get(0)).contains("Current")); + assertTrue(lore(rendered.get(0)).contains("Prestige: +50")); + assertTrue(lore(rendered.get(1)).contains("Click to claim")); + assertTrue(lore(rendered.get(3)).contains("Owned by: other")); + assertEquals("claim", string(rendered.get(2), "type")); + } + + @Test + void claimingAnAvailableTitleChangesOwnershipAndRefreshesItsButton() { + Title title = fixture.title("North", "county", 10); + titles.titleView(null, player, faction, true); + assertEquals("county", id(top().getItem(10))); + titles.click(fixture.ui.click(player, 10), top(), player); + Inventory inventory = top(); + assertEquals(SFGUI.TITLE_TYPE_VIEW, ((SFInventoryHolder) inventory.getHolder()).getType()); + titles.click(fixture.ui.click(player, 0), inventory, player); + assertTrue(faction.hasTitle(title)); + assertSame(faction, FactionManager.getTitleOwner(title)); + assertEquals(Material.GREEN_CONCRETE, inventory.getItem(0).getType()); + verify(player).sendMessage("§aClaimed the title North"); + titles.click(fixture.ui.click(player, 0), inventory, player); + assertEquals(1, faction.getTitles().size()); + } + + @Test + void anAuthorizedLiegeCanGrantASecondCountyAndKeepsItsOwnTitle() { + Faction subject = fixture.saved("subject", "Subject"); + subject.addProvince(20); + fixture.subject(faction, subject); + Title theirs = fixture.title("Subject County", "county", 20), + home = fixture.title("Home County", "county", 10); + faction.addTitle(theirs); + faction.addTitle(home); + titles.titleView(null, player, subject, true); + assertEquals("county", id(top().getItem(10))); + titles.titleTypeView(null, player, subject, theirs.getTier(), true, 0); + assertTrue(lore(top().getItem(0)).contains("Click to grant this title to subject")); + titles.click(fixture.ui.click(player, 0), top(), player); + assertTrue(subject.hasTitle(theirs)); + assertFalse(faction.hasTitle(theirs)); + assertTrue(faction.hasTitle(home)); + verify(player).sendMessage("§aGranted subject the title Subject County"); + assertEquals("No titles to give away", name(top().getItem(10))); + } + + @Test + void titlePaginationClearsPreviousContentsAndCanNavigateBothDirections() { + for (int n = 0; n < 47; n++) fixture.title("Title " + n, "county", 100 + n); + Tier county = TierLoader.getByString("county"); + titles.titleTypeView(null, player, faction, county, true, -4); + Inventory inventory = top(); + assertEquals(0, ((SFInventoryHolder) inventory.getHolder()).getPage()); + assertNotNull(inventory.getItem(44)); + assertNotNull(inventory.getItem(51)); + titles.click(fixture.ui.click(player, 51), inventory, player); + assertEquals(1, ((SFInventoryHolder) inventory.getHolder()).getPage()); + assertNotNull(inventory.getItem(1)); + assertNull(inventory.getItem(2)); + assertNull(inventory.getItem(51)); + titles.click(fixture.ui.click(player, 50), inventory, player); + assertEquals(0, ((SFInventoryHolder) inventory.getHolder()).getPage()); + assertNull(inventory.getItem(50)); + assertNotNull(inventory.getItem(44)); + } + + @Test + void anEmptyCurrentPageCanStillNavigateBackAfterTitlesAreRemoved() { + for (int n = 0; n < 46; n++) fixture.title("Title " + n, "county", 100 + n); + Tier county = TierLoader.getByString("county"); + titles.titleTypeView(null, player, faction, county, true, 1); + Inventory inventory = top(); + TitleLoader.getTitles().clear(); + titles.titleTypeView(inventory, player, faction, county, false, 1); + assertNull(inventory.getItem(0)); + assertNotNull(inventory.getItem(50)); + assertDoesNotThrow(() -> titles.click(fixture.ui.click(player, 50), inventory, player)); + assertEquals(0, ((SFInventoryHolder) inventory.getHolder()).getPage()); + } + + @ParameterizedTest + @ValueSource(strings = {"former_leader", "disabled"}) + void newTitlePromptRejectsFormerLeadersAndTiersThatCannotBeFormed(String reason) { + Tier county = configureTier("county", 2, reason.equals("disabled") ? 0 : 1); + titles.titleTypeView(null, player, faction, county, true, 0); + Inventory inventory = top(); + if (reason.equals("former_leader")) { + faction.addMember("Successor"); + faction.setLeader("Successor"); + } + titles.click(fixture.ui.click(player, 52), inventory, player); + assertFalse(TitleManager.isFormingTitle.containsKey(player)); + verify(player, never()).sendMessage("§eType the name of the new county §ein chat."); + } + + @Test + void enabledCountyCreationPromptsForANameOnlyWithEnoughUnclaimedLand() { + Tier county = configureTier("county", 2, 2); + titles.titleTypeView(null, player, faction, county, true, 0); + assertTrue(lore(top().getItem(52)).contains("Currently: 1 [50%]")); + titles.click(fixture.ui.click(player, 52), top(), player); + assertFalse(TitleManager.isFormingTitle.containsKey(player)); + faction.addProvince(11); + titles.titleTypeView(null, player, faction, county, true, 0); + assertTrue(lore(top().getItem(52)).contains("Click to create")); + titles.click(fixture.ui.click(player, 52), top(), player); + assertSame(county, TitleManager.isFormingTitle.get(player)); + verify(player).sendMessage("§eType the name of the new county §ein chat."); + verify(player).closeInventory(); + } + + @Test + void newCountiesAreLimitedByMembersAndNewHigherTitlesByTheLiege() { + Tier county = configureTier("county", 2, 1); + Title home = fixture.title("Home", "county", 10); + faction.addTitle(home); + faction.addProvince(11); + titles.titleTypeView(null, player, faction, county, true, 0); + titles.click(fixture.ui.click(player, 52), top(), player); + verify(player).sendMessage("§cYou need more members to form more counties!"); + assertFalse(TitleManager.isFormingTitle.containsKey(player)); + faction.addMember("Citizen"); + Faction liege = fixture.saved("liege", "Liege"); + liege.addProvince(20); + fixture.subject(liege, faction); + titles.titleTypeView(null, player, faction, county, true, 0); + titles.click(fixture.ui.click(player, 52), top(), player); + verify(player) + .sendMessage("§cForming a new county title would make you a higher tier than your liege!"); + assertFalse(TitleManager.isFormingTitle.containsKey(player)); + } + + @Test + void militaryRendersLiveCountsAndOnlyTheCurrentLeaderGetsAdjustmentButtons() { + Regiment infantry = regiment("infantry", false, false, 3, 2), + levy = regiment("levy", true, false, 0, 0); + infantry.setFreeSlots(1); + infantry.getDescription().add("Citizen militia"); + infantry.setSentToOverlord(1); + military.militaryView(null, player, faction, true); + Inventory inventory = top(); + assertEquals("realm Military", name(inventory.getItem(10))); + assertTrue(lore(inventory.getItem(10)).contains("Total Soldiers: 0/3")); + assertTrue(lore(inventory.getItem(10)).contains("Total Upkeep: 4")); + assertTrue(lore(inventory.getItem(12)).contains("3 (1 free)")); + assertTrue(lore(inventory.getItem(12)).contains("sent as levies")); + assertTrue(lore(inventory.getItem(12)).contains("Citizen militia")); + assertEquals("increase", string(inventory.getItem(3), "type")); + assertEquals("decrease", string(inventory.getItem(21), "type")); + assertEquals(Material.AIR, inventory.getItem(4).getType()); + assertEquals(Material.AIR, inventory.getItem(22).getType()); + assertEquals(Material.MINECART, inventory.getItem(49).getType()); + Player citizen = fixture.player("Citizen"); + faction.addMember("Citizen"); + military.militaryView(inventory, citizen, faction, false); + assertEquals(Material.AIR, inventory.getItem(3).getType()); + assertEquals(Material.AIR, inventory.getItem(21).getType()); + assertTrue(lore(inventory.getItem(13)).contains("Total Levies: 0")); + } + + @Test + void militaryQueueClicksEnqueueConfirmCancellationAndConfirmDecreases() { + Regiment infantry = regiment("infantry", false, false, 2, 1); + military.militaryView(null, player, faction, true); + Inventory inventory = top(); + military.click(fixture.ui.click(player, 3), inventory, player); + assertEquals(1, faction.getMilitary().getQueue().size()); + assertSame(infantry, faction.getMilitary().getQueue().get(0).getRegiment()); + assertEquals("Expanding infantry", name(inventory.getItem(39))); + verify(player).sendMessage("§eQueued infantry"); + military.click(fixture.ui.click(player, 39), inventory, player); + verify(navigation) + .openQueueCancelConfirm( + player, + faction, + QueueCancelPayload.military("realm", 0), + "§eCancel queued regiment expansion?"); + assertEquals(1, faction.getMilitary().getQueue().size()); + military.click(fixture.ui.click(player, 21), inventory, player); + verify(navigation).confirmView(player, faction, "regiment", "infantry"); + assertSame(faction, navigation.confirming.get(player)); + assertEquals(2, infantry.getCurrentSlots()); + } + + @Test + void professionalRecruitmentAndFullQueuesAreRejectedWithoutChangingTheQueue() { + var group = faction.getLawHandler().getGroup("military"); + var law = group.getLaw("current"); + group.setCurrent(law); + YamlConfiguration rules = new YamlConfiguration(); + rules.set("rules", List.of("CAN_RECRUIT_PROFESSIONAL_ARMY false")); + law.getScopedEffects().put(Scope.FACTION, new LawEffect(Scope.FACTION, rules)); + Regiment professional = regiment("professional", false, true, 0, 3); + military.militaryView(null, player, faction, true); + Inventory inventory = top(); + assertTrue(lore(inventory.getItem(12)).contains("Blocked")); + assertTrue(lore(inventory.getItem(3)).contains("Your laws do not allow")); + military.click(fixture.ui.click(player, 3), inventory, player); + assertTrue(faction.getMilitary().getQueue().isEmpty()); + verify(player).sendMessage("§cYour laws do not allow recruiting a professional army."); + Regiment militia = regiment("militia", false, false, 0, 1); + for (int n = 0; n < 3; n++) assertTrue(faction.getMilitary().enqueue(militia)); + military.militaryView(inventory, player, faction, false); + military.click(fixture.ui.click(player, 4), inventory, player); + assertEquals(3, faction.getMilitary().getQueue().size()); + verify(player).sendMessage("§cQueue is full"); + assertTrue(lore(inventory.getItem(40)).contains("Queued...")); + assertNotNull(inventory.getItem(41)); + } + + @ParameterizedTest + @ValueSource( + strings = { + "missing_item", + "no_meta", + "no_id", + "missing_faction", + "no_regiment", + "missing_regiment", + "no_type", + "former_leader", + "other_type" + }) + void militaryIgnoresStaleOrNonActionButtons(String state) { + Regiment r = regiment("militia", false, false, 1, 0); + military.militaryView(null, player, faction, true); + Inventory inventory = top(); + ItemStack button = military.creator.createRegimentIncreaseButton(faction, r); + if (state.equals("missing_item")) button = null; + if (state.equals("no_meta")) button = new ItemStack(Material.STONE); + if (state.equals("no_id")) remove(button, "id"); + if (state.equals("missing_faction")) set(button, "id", "missing"); + if (state.equals("no_regiment")) remove(button, "regiment"); + if (state.equals("missing_regiment")) set(button, "regiment", "missing"); + if (state.equals("no_type")) remove(button, "type"); + if (state.equals("former_leader")) { + faction.addMember("Successor"); + faction.setLeader("Successor"); + } + if (state.equals("other_type")) set(button, "type", "unknown"); + inventory.setItem(3, button); + InventoryClickEvent event = fixture.ui.click(player, 3); + military.click(event, inventory, player); + assertTrue(event.isCancelled()); + assertTrue(faction.getMilitary().getQueue().isEmpty()); + assertTrue(navigation.confirming.isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = {"wrong_holder", "removed", "former_leader"}) + void cancellationButtonsCannotActForAMissingOrForeignFaction(String state) { + Regiment r = regiment("militia", false, false, 1, 0); + faction.getMilitary().enqueue(r); + military.militaryView(null, player, faction, true); + Inventory inventory = top(); + if (state.equals("wrong_holder")) { + Inventory other = fixture.ui.inventory(null, 54, "Military"); + other.setItem(39, inventory.getItem(39)); + inventory = other; + player.openInventory(inventory); + } + if (state.equals("removed")) FactionManager.factions.clear(); + if (state.equals("former_leader")) { + faction.addMember("Successor"); + faction.setLeader("Successor"); + } + military.click(fixture.ui.click(player, 39), inventory, player); + verify(navigation, never()).openQueueCancelConfirm(any(), any(), anyString(), anyString()); + assertEquals(1, faction.getMilitary().getQueue().size()); + } + + @Test + void regimentSummaryIncludesReducedUpkeepFrozenTimersAndBoundedLevyContributors() { + Regiment r = regiment("infantry", false, false, 4, 2); + r.setFreeSlots(0); + faction + .getRank() + .getModifiers() + .add(new FactionModifier(FactionModifiers.MILITARY_UPKEEP, -25)); + assertTrue(lore(military.creator.createMilitarySummary(faction)).contains("6.00d (From 8.00)")); + assertTrue( + lore(military.creator.createRegimentIcon(faction, r)).contains("6.00d (1.50d per slot)")); + Regiment levy = regiment("levy", true, false, 0, 0); + for (int n = 0; n < 12; n++) levy.addLevyEntry(new LevyEntry(faction, n + 1)); + String levyLore = lore(military.creator.createRegimentIcon(faction, levy)); + assertTrue(levyLore.contains("Total Levies: 78")); + assertTrue(levyLore.contains("And 2 more...")); + assertEquals(10, levyLore.lines().filter(line -> line.startsWith("- realm:")).count()); + try (MockedStatic<PreparationFreeze> freeze = + mockStatic(PreparationFreeze.class, CALLS_REAL_METHODS)) { + freeze + .when(() -> PreparationFreeze.frozenUntil(eq(faction), any(Instant.class))) + .thenReturn(Instant.now().plusSeconds(3600)); + ItemStack queue = military.creator.createQueueItem(new MilitaryExpansion(r, 60), 0, faction); + assertTrue(lore(queue).contains("Frozen: battle postponed")); + assertEquals( + QueueCancelPayload.military("realm", 0), + queue + .getItemMeta() + .getPersistentDataContainer() + .get(Keys.QUEUE_CANCEL, PersistentDataType.STRING)); + } + } + + @Test + void formedHomeCompanyAppearsAfterFactionRegimentsAndDisappearsWhenRemoved() { + Regiment r = regiment("militia", false, false, 1, 0); + MercenaryCompany company = + new MercenaryCompany(faction.getOrCreateMainGuild(), "The Guard", new Regiment(r), 0); + faction.getOrCreateMainGuild().setCompany(company); + military.militaryView(null, player, faction, true); + Inventory inventory = top(); + assertEquals("The Guard", name(inventory.getItem(13))); + assertTrue(lore(inventory.getItem(13)).contains("1 slots")); + assertTrue(lore(inventory.getItem(13)).contains("Only filled slots count toward offense")); + faction.getOrCreateMainGuild().setCompany(null); + military.militaryView(inventory, player, faction, false); + assertEquals(Material.AIR, inventory.getItem(13).getType()); + } + + @Test + void vehiclePoolIconShowsTheRegistryAndOutstandingMaintenance() { + PlayerVehicleRegistry registry = mock(PlayerVehicleRegistry.class); + VehicleMaintenanceStore store = mock(VehicleMaintenanceStore.class); + PlayerVehicleRecord record = + new PlayerVehicleRecord( + player.getUniqueId(), "vehicle-123", "wagon", OwnershipMode.POOL, null, "realm"); + when(registry.getPoolVehicles("realm")).thenReturn(List.of(record)); + when(store.unpaidUuids()).thenReturn(Set.of("vehicle-123")); + when(fixture.ui.plugin.getVehicleMaintenanceStore()).thenReturn(store); + try (MockedStatic<SimpleFactions> plugin = + mockStatic(SimpleFactions.class, CALLS_REAL_METHODS)) { + plugin.when(SimpleFactions::getVehicleRegistry).thenReturn(registry); + ItemStack item = military.creator.createVehiclePoolIcon(faction); + assertEquals("Vehicle Pool", name(item)); + assertTrue(lore(item).contains("Unpaid: 1 of 1")); + assertTrue(lore(item).contains("wagon")); + } + } + + @Test + void anOldTierAliasCannotBeAppliedAfterAClaimPromotesTheFaction() { + faction.getTier().getAliases().add("March"); + titles.tierView(null, player, faction, true); + Inventory inventory = top(); + assertEquals(Material.YELLOW_CONCRETE, inventory.getItem(10).getType()); + faction.addTitle(fixture.title("New County", "county", 10)); + assertEquals("county", faction.getTier().getId()); + titles.click(fixture.ui.click(player, 10), inventory, player); + assertEquals(-1, faction.getTier().getIndex()); + assertEquals("county", faction.getTier().getFormattedName()); + } + + @Test + void foreignMilitaryUsesTheReportedRendererForOpeningAndRefresh() { + Player visitor = fixture.player("Visitor"); + Inventory reported = + fixture.ui.inventory( + new SFInventoryHolder("realm", SFGUI.MILITARY_VIEW), 54, "Military View"); + try (MockedStatic<EspionageService> intelligence = mockStatic(EspionageService.class); + MockedStatic<ReportedMenus> menus = mockStatic(ReportedMenus.class)) { + menus + .when( + () -> ReportedMenus.open(visitor, "realm", SFGUI.MILITARY_VIEW, 54, "Military View")) + .thenReturn(reported); + military.militaryView(null, visitor, faction, true); + assertSame(reported, visitor.getOpenInventory().getTopInventory()); + military.militaryView(reported, visitor, faction, false); + menus.verify(() -> ReportedMenus.military(reported, visitor, faction, navigation), times(2)); + menus.verify( + () -> ReportedMenus.open(visitor, "realm", SFGUI.MILITARY_VIEW, 54, "Military View"), + times(1)); + assertNull(reported.getItem(3)); + } + } + + @Test + void missingPluginOrFactionStillProducesAnEmptyVehiclePoolDescription() { + SimpleFactions previous = SimpleFactions.plugin; + try { + SimpleFactions.plugin = null; + ItemStack item = military.creator.createVehiclePoolIcon(null); + assertTrue(lore(item).contains("No vehicles in the pool.")); + } finally { + SimpleFactions.plugin = previous; + } + } + + @Test + void aCompositeTitleListsItsRequiredCountiesAndAnEnabledDuchyUsesFreeTitles() { + faction.addMember("Citizen"); + faction.addProvince(11); + Title first = fixture.title("First County", "county", 10), + second = fixture.title("Second County", "county", 11); + faction.addTitle(first); + faction.addTitle(second); + Tier duchy = configureTier("duchy", 3, 2); + JsonObject json = new JsonObject(); + json.addProperty("name", "Combined Duchy"); + JsonArray required = new JsonArray(); + required.add(first.getId()); + required.add(second.getId()); + json.add("titles", required); + Title composite = new Title(duchy, "combined", json); + TitleLoader.getTitles().add(composite); + ItemStack existing = + titles.creator.createTitleItem( + player, + faction, + composite, + TitleManager.getProvinces(faction), + TitleManager.getTitles(faction)); + assertEquals(Material.YELLOW_CONCRETE, existing.getType()); + assertTrue(lore(existing).contains("Required Titles:")); + assertTrue(lore(existing).contains("First County")); + assertTrue(lore(existing).contains("Second County")); + TitleLoader.getTitles().remove(composite); + titles.titleTypeView(null, player, faction, duchy, true, 0); + ItemStack create = top().getItem(52); + assertTrue(lore(create).contains("2 free titles of the type county")); + assertTrue(lore(create).contains("Currently: 2 [100%]")); + titles.click(fixture.ui.click(player, 52), top(), player); + assertSame(duchy, TitleManager.isFormingTitle.get(player)); + verify(player).sendMessage("§eType the name of the new duchy §ein chat."); + assertEquals(0, titles.creator.getNewTitleCost(faction, null)); + when(IconGetter.hasIcon("duchy")).thenReturn(true); + when(IconGetter.getIcon("duchy")).thenAnswer(call -> new ItemStack(Material.NETHER_STAR)); + ItemStack category = titles.creator.createTierViewItem(faction, duchy, 1); + assertEquals(Material.NETHER_STAR, category.getType()); + assertEquals("duchy", id(category)); + } + + @Test + void aClaimAboveTheLiegesTierIsRefusedWithoutChangingEitherTitleSet() { + Title claim = fixture.title("North County", "county", 10); + Faction liege = fixture.saved("liege", "Liege"); + liege.addProvince(20); + fixture.subject(liege, faction); + titles.titleTypeView(null, player, faction, claim.getTier(), true, 0); + assertEquals(Material.YELLOW_CONCRETE, top().getItem(0).getType()); + titles.click(fixture.ui.click(player, 0), top(), player); + assertFalse(faction.hasTitle(claim)); + assertTrue(liege.getTitles().isEmpty()); + verify(player) + .sendMessage( + "§cCannot form this title as it would make your tier higher than that of your" + + " overlord!"); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void aCollapsingGovernmentCannotClaimOrStartCreatingTitles(boolean existing) { + Tier county = configureTier("county", 2, 1); + Title claim = fixture.title("North County", "county", existing ? 10 : 20); + StabilityReport report = new StabilityReport(); + report.status = StabilityStatus.COLLAPSING; + report.stability = 5; + try (MockedStatic<StateStability> state = mockStatic(StateStability.class)) { + state.when(() -> StateStability.of(faction)).thenReturn(report); + titles.titleTypeView(null, player, faction, county, true, 0); + titles.click(fixture.ui.click(player, existing ? 0 : 52), top(), player); + assertFalse(faction.hasTitle(claim)); + assertFalse(TitleManager.isFormingTitle.containsKey(player)); + verify(player).sendMessage("§cA Collapsing State cannot form titles."); + } + } + + @Test + void grantingOneOfSeveralSpareTitlesKeepsTheRemainingGrantChoicesOpen() { + faction.addMember("Citizen"); + faction.addMember("Other"); + Faction subject = fixture.saved("subject", "Subject"); + subject.addMember("Member"); + subject.addProvince(20); + subject.addProvince(21); + fixture.subject(faction, subject); + Title first = fixture.title("First", "county", 20), + second = fixture.title("Second", "county", 21), + home = fixture.title("Home", "county", 10); + faction.addTitle(first); + faction.addTitle(second); + faction.addTitle(home); + titles.titleTypeView(null, player, subject, first.getTier(), true, 0); + Inventory inventory = top(); + titles.click(fixture.ui.click(player, 0), inventory, player); + assertTrue(subject.hasTitle(first)); + assertSame(inventory, top()); + assertEquals(second.getId(), id(inventory.getItem(0))); + assertEquals(Material.GREEN_CONCRETE, inventory.getItem(0).getType()); + assertNull(inventory.getItem(1)); + } + + @ParameterizedTest + @ValueSource(strings = {"county", "landless"}) + void tiersThatCannotBeFormedNeverAdvertiseFreeCreationOrFailToRender(String id) { + Tier tier = TierLoader.getByString(id); + assertFalse(tier.canForm()); + ItemStack item = assertDoesNotThrow(() -> titles.creator.createNewTitleItem(faction, tier)); + assertTrue(lore(item).contains("Unavailable")); + assertFalse(lore(item).contains("Click to create")); + assertFalse(lore(item).contains("-1%")); + } + + @Test + void titleHandlerLeavesAnUnrelatedInventoryUntouched() { + Inventory inventory = fixture.ui.inventory(null, 27, "Unrelated"); + inventory.setItem(0, new ItemStack(Material.STONE)); + player.openInventory(inventory); + InventoryClickEvent event = fixture.ui.click(player, 0); + titles.click(event, inventory, player); + assertFalse(event.isCancelled()); + assertSame(inventory, top()); + assertTrue(TitleManager.isFormingTitle.isEmpty()); + } + + @Test + void creatingAnotherCountyDoesNotRaiseTheSubjectsTierAboveAnEqualCountyLiege() { + Tier county = configureTier("county", 2, 1); + faction.addMember("Citizen"); + faction.addProvince(11); + faction.addTitle(fixture.title("Home County", "county", 10)); + Faction liege = fixture.saved("liege", "Liege"); + liege.addProvince(20); + liege.addTitle(fixture.title("Liege County", "county", 20)); + fixture.subject(liege, faction); + assertEquals(2, faction.getTier().getTier()); + assertEquals(2, liege.getTier().getTier()); + assertEquals(List.of(11), faction.getUntitledProvinces()); + titles.titleTypeView(null, player, faction, county, true, 0); + titles.click(fixture.ui.click(player, 52), top(), player); + assertSame(county, TitleManager.isFormingTitle.get(player)); + verify(player).sendMessage("§eType the name of the new county §ein chat."); + } + + private Tier configureTier(String id, int level, int formCost) { + TierLoader.oList.removeIf(t -> t.getId().equals(id)); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set(id + ".tier", level); + yaml.set(id + ".form-cost", formCost); + yaml.set(id + ".prestige", 50); + Tier tier = new Tier(id, yaml.getConfigurationSection(id)); + TierLoader.oList.add(tier); + return tier; + } + + private Regiment regiment( + String id, boolean levy, boolean professional, int slots, double upkeep) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("name", id); + yaml.set("item.material", "PAPER"); + yaml.set("default-slots", slots); + yaml.set("upkeep", upkeep); + yaml.set("expansion-time", 60); + yaml.set("levy", levy); + yaml.set("professional", professional); + Regiment r = new Regiment(id, yaml); + faction.getMilitary().getRegiments().add(r); + return r; + } + + private String id(ItemStack item) { + return string(item, "id"); + } + + private String string(ItemStack item, String key) { + return item.getItemMeta() + .getPersistentDataContainer() + .get(new NamespacedKey(fixture.ui.plugin, key), PersistentDataType.STRING); + } + + private void set(ItemStack item, String key, String value) { + ItemMeta meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(new NamespacedKey(fixture.ui.plugin, key), PersistentDataType.STRING, value); + item.setItemMeta(meta); + } + + private void remove(ItemStack item, String key) { + ItemMeta meta = item.getItemMeta(); + meta.getPersistentDataContainer().remove(new NamespacedKey(fixture.ui.plugin, key)); + item.setItemMeta(meta); + } + + private static String name(ItemStack item) { + return ChatColor.stripColor(item.getItemMeta().getDisplayName()); + } + + private static String lore(ItemStack item) { + return item.getItemMeta().getLore() == null + ? "" + : String.join( + "\n", item.getItemMeta().getLore().stream().map(ChatColor::stripColor).toList()); + } + + private Inventory top() { + return player.getOpenInventory().getTopInventory(); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/BranchIncomePublicationCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/BranchIncomePublicationCoverageTest.java new file mode 100644 index 00000000..bdbe8af0 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/BranchIncomePublicationCoverageTest.java @@ -0,0 +1,249 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.enums.*; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.branch.*; +import net.tfminecraft.simplefactions.guild.income.BranchIncomePreview; +import net.tfminecraft.simplefactions.keys.Keys; +import net.tfminecraft.simplefactions.managers.ProvinceManager; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +class BranchIncomePublicationCoverageTest { + private FactionDomainFixture fixture; + private MockedStatic<net.tfminecraft.tlibs.TLibs> items; + private Guild guild; + private Branch branch; + private ProvinceManager provinces; + private Province capital; + private Player player; + private Inventory inventory; + private BranchIncomePreview.Prepared prepared; + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + fixture.provincesEnabled(true); + guild = fixture.guild(fixture.saved("home", "Alice"), "fields", "Alice"); + capital = new Province(1, "PLAINS", 40, 0, 0); + provinces = new ProvinceManager(); + provinces.start(Map.of(1, capital)); + when(fixture.ui.plugin.getProvinceManager()).thenReturn(provinces); + when(fixture.ui.plugin.isEnabled()).thenReturn(true); + for (int n = 0; n < 3; n++) guild.getFaction().getOrCreateMainGuild().getBranch(0).levelUp(); + guild.setCapital(1, false); + branch = guild.getBranch(0); + branch.levelUp(); + branch.getModifiers().put(GuildModifier.PRODUCTION, new BranchModifier(8, 2)); + branch.getModifiers().put(GuildModifier.TRADE_CARRY, new BranchModifier(1, 0)); + player = fixture.player("Alice"); + items = mockStatic(net.tfminecraft.tlibs.TLibs.class); + var api = mock(net.tfminecraft.tlibs.objects.api.ItemAPI.class); + var creator = mock(net.tfminecraft.tlibs.objects.api.subapi.ItemCreator.class); + when(api.getCreator()).thenReturn(creator); + items.when(net.tfminecraft.tlibs.TLibs::getItemAPI).thenReturn(api); + when(creator.getItemsAdderItem(anyString())).thenAnswer(c -> new ItemStack(Material.PAPER)); + inventory = + fixture.ui.inventory(new SFInventoryHolder("fields", SFGUI.GUILD_VIEW), 54, "Guild"); + player.openInventory(inventory); + capital.clearData(); + guild.getTradeBreakdown().clear(); + prepared = BranchIncomePreview.prepare(provinces); + fixture.ui.tasks.clear(); + fixture.ui.asyncTasks.clear(); + } + + @AfterEach + void close() { + if (items != null) items.close(); + fixture.close(); + } + + private void display(boolean up) { + var creator = new GuildCreator(); + inventory.setItem( + 20, + up + ? creator.createBranchUpgradeItem(player, guild, branch) + : creator.createBranchDowngradeItem(player, guild, branch)); + } + + private String lore() { + return String.join(" ", inventory.getItem(20).getItemMeta().getLore()); + } + + private void schedule(boolean up) { + BranchIncomePreviewService.schedule( + player, inventory, 20, prepared, guild, branch, up ? 1 : -1); + } + + private void compute() { + assertEquals(1, fixture.ui.asyncTasks.size()); + fixture.ui.asyncTasks.remove(0).run(); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void paintsComputedTradeChangeWithoutChangingLiveEconomy(boolean up) { + display(up); + double expected = BranchIncomePreview.estimate(prepared, guild, branch, up ? 1 : -1); + assertTrue(up ? expected > 0 : expected < 0, "Preview needs a meaningful nonzero economy"); + double balance = guild.getBank().getWealth(); + schedule(up); + assertTrue(lore().contains("Calculating")); + assertNotNull( + inventory + .getItem(20) + .getItemMeta() + .getPersistentDataContainer() + .get(Keys.BRANCH_PREVIEW, PersistentDataType.LONG)); + compute(); + assertTrue(lore().contains("Calculating"), "Async worker must not write to a menu"); + fixture.ui.runTasks(); + assertTrue(lore().contains(String.format("%.2f", expected) + "d/day")); + assertFalse(lore().contains("Calculating")); + assertEquals(1, branch.getLevel()); + assertEquals(balance, guild.getBank().getWealth()); + assertTrue(capital.getAllData().isEmpty()); + assertEquals(0, guild.getTradeBreakdown().getIncome()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "offline", + "closed", + "level", + "item_removed", + "item_replaced", + "direction", + "branch", + "token" + }) + void staleCalculationCannotRewriteChangedMenu(String changed) { + display(true); + schedule(true); + compute(); + switch (changed) { + case "offline" -> when(player.isOnline()).thenReturn(false); + case "closed" -> player.openInventory(fixture.ui.inventory(null, 9, "Another")); + case "level" -> branch.levelUp(); + case "item_removed" -> inventory.setItem(20, null); + case "item_replaced" -> display(true); + default -> { + ItemStack item = inventory.getItem(20); + var meta = item.getItemMeta(); + if (changed.equals("direction")) + meta.getPersistentDataContainer() + .set(Keys.BOOLEAN_FLAG, PersistentDataType.BOOLEAN, false); + if (changed.equals("branch")) + meta.getPersistentDataContainer() + .set(Keys.BRANCH_ID, PersistentDataType.STRING, "different"); + if (changed.equals("token")) meta.getPersistentDataContainer().remove(Keys.BRANCH_PREVIEW); + item.setItemMeta(meta); + } + } + fixture.ui.runTasks(); + if (changed.equals("item_removed")) assertNull(inventory.getItem(20)); + else assertTrue(lore().contains("Calculating")); + } + + @Test + void replacingPendingCalculationKeepsNewTokenAndResult() { + display(true); + schedule(true); + long first = + inventory + .getItem(20) + .getItemMeta() + .getPersistentDataContainer() + .get(Keys.BRANCH_PREVIEW, PersistentDataType.LONG); + Runnable old = fixture.ui.asyncTasks.remove(0); + display(false); + schedule(false); + long second = + inventory + .getItem(20) + .getItemMeta() + .getPersistentDataContainer() + .get(Keys.BRANCH_PREVIEW, PersistentDataType.LONG); + assertNotEquals(first, second); + old.run(); + fixture.ui.runTasks(); + assertTrue(lore().contains("Calculating")); + compute(); + fixture.ui.runTasks(); + assertFalse(lore().contains("Calculating")); + assertTrue(lore().contains("-")); + } + + @Test + void failedEstimateLogsFailureAndPublishesUnavailableWithoutChangingTheEconomy() { + display(true); + double balance = guild.getBank().getWealth(); + schedule(true); + var logger = mock(java.util.logging.Logger.class); + when(fixture.ui.plugin.getLogger()).thenReturn(logger); + RuntimeException failure = new IllegalStateException("Preview calculation failed"); + // Inject a failed calculation at the service boundary; the ordinary paths use real snapshots. + try (var calculations = mockStatic(BranchIncomePreview.class, CALLS_REAL_METHODS)) { + calculations + .when( + () -> + BranchIncomePreview.estimate( + eq(prepared), eq(guild), anyMap(), anyMap(), anyDouble())) + .thenThrow(failure); + compute(); + } + assertTrue(lore().contains("Calculating")); + fixture.ui.runTasks(); + assertTrue(lore().contains("Income estimate unavailable")); + verify(logger).log(eq(java.util.logging.Level.WARNING), contains("fields"), same(failure)); + assertEquals(1, branch.getLevel()); + assertEquals(balance, guild.getBank().getWealth()); + assertTrue(capital.getAllData().isEmpty()); + assertEquals(0, guild.getTradeBreakdown().getIncome()); + schedule(true); + compute(); + fixture.ui.runTasks(); + assertFalse(lore().contains("unavailable")); + } + + @Test + void disabledPluginAndMissingInputsDoNotScheduleOrPublish() { + display(true); + BranchIncomePreviewService.schedule(null, inventory, 20, prepared, guild, branch, 1); + BranchIncomePreviewService.schedule(player, inventory, 0, prepared, guild, branch, 1); + when(fixture.ui.plugin.isEnabled()).thenReturn(false); + schedule(true); + assertTrue(fixture.ui.asyncTasks.isEmpty()); + when(fixture.ui.plugin.isEnabled()).thenReturn(true); + schedule(true); + when(fixture.ui.plugin.isEnabled()).thenReturn(false); + compute(); + assertTrue(fixture.ui.tasks.isEmpty()); + assertTrue(lore().contains("Calculating")); + SimpleFactions old = SimpleFactions.plugin; + try { + SimpleFactions.plugin = null; + schedule(true); + } finally { + SimpleFactions.plugin = old; + } + assertTrue(fixture.ui.asyncTasks.isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ElectionMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ElectionMenusCoverageTest.java new file mode 100644 index 00000000..17e77564 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ElectionMenusCoverageTest.java @@ -0,0 +1,196 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.simplefactions.government.election.*; +import net.tfminecraft.simplefactions.keys.Keys; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.bukkit.entity.Player; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; + +class ElectionMenusCoverageTest { + private org.mockito.MockedStatic<net.tfminecraft.tlibs.TLibs> items; + private FactionDomainFixture fixture; + private InventoryManager inventory; + private Faction home; + private Player alice; + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + fixture.lawGroup( + "constitution", + Map.of( + "effects.faction.rules", + List.of("LEADER_ELECTIONS true", "HAS_COUNCIL true", "ELECTED_COUNCIL true"), + "effects.faction.council-size", + 2)); + home = fixture.saved("home", "Alice"); + alice = fixture.player("Alice"); + inventory = new InventoryManager(); + items = mockStatic(net.tfminecraft.tlibs.TLibs.class); + var api = mock(net.tfminecraft.tlibs.objects.api.ItemAPI.class); + var creator = mock(net.tfminecraft.tlibs.objects.api.subapi.ItemCreator.class); + when(api.getCreator()).thenReturn(creator); + items.when(net.tfminecraft.tlibs.TLibs::getItemAPI).thenReturn(api); + when(creator.getItemFromPath(anyString())) + .thenAnswer(call -> new ItemStack(org.bukkit.Material.PAPER)); + when(creator.getItemsAdderItem(anyString())) + .thenAnswer(call -> new ItemStack(org.bukkit.Material.PAPER)); + when(fixture.ui.plugin.getProvinceManager()) + .thenReturn(new net.tfminecraft.simplefactions.managers.ProvinceManager()); + } + + @AfterEach + void close() { + if (items != null) items.close(); + fixture.close(); + } + + private Inventory top() { + return alice.getOpenInventory().getTopInventory(); + } + + private String key(ItemStack item) { + return item == null + ? null + : item.getItemMeta() + .getPersistentDataContainer() + .get(Keys.STRING_KEY, PersistentDataType.STRING); + } + + private int named(Inventory menu, String text) { + for (int slot = 0; slot < menu.getSize(); slot++) { + ItemStack item = menu.getItem(slot); + if (item != null + && item.getItemMeta() != null + && item.getItemMeta().getDisplayName().contains(text)) return slot; + } + return -1; + } + + @Test + void everyCandidateCanBeReachedAndVotedForBeyondFiftyFourEntries() { + Election election = home.getGovernment().getElection(); + for (int n = 0; n < 57; n++) { + String candidate = "Candidate" + n; + home.addMember(candidate); + election.addCandidate(Candidate.LEADER, candidate); + } + election.start(); + inventory.electionView.votingView(alice, home, Candidate.LEADER); + Set<String> reached = new HashSet<>(); + for (int page = 0; page < 8; page++) { + Inventory menu = top(); + for (ItemStack item : menu.getContents()) { + String id = key(item); + if (id != null && id.startsWith("Candidate")) reached.add(id); + } + int next = named(menu, "Next"); + if (next < 0) break; + inventory.electionView.click(fixture.ui.click(alice, next), menu, alice); + } + assertEquals(new HashSet<>(election.getCandidates(Candidate.LEADER)), reached); + int last = named(top(), "Candidate56"); + assertTrue(last >= 0); + inventory.electionView.click(fixture.ui.click(alice, last), top(), alice); + assertEquals("Candidate56", election.getVote(Candidate.LEADER, "Alice")); + } + + @Test + void signupWithdrawalAndOneBallotUseActualElectionState() { + Election election = home.getGovernment().getElection(); + inventory.electionView.electionView(alice, home); + assertEquals("LEADER", key(top().getItem(0))); + inventory.electionView.click(fixture.ui.click(alice, 0), top(), alice); + assertEquals(List.of("Alice"), election.getCandidates(Candidate.LEADER)); + assertTrue(String.join(" ", top().getItem(0).getItemMeta().getLore()).contains("withdraw")); + inventory.electionView.click(fixture.ui.click(alice, 0), top(), alice); + assertTrue(election.getCandidates(Candidate.LEADER).isEmpty()); + home.addMember("Bob"); + election.addCandidate(Candidate.LEADER, "Bob"); + election.addCandidate(Candidate.COUNCIL, "Bob"); + election.start(); + inventory.electionView.electionView(alice, home); + inventory.electionView.click(fixture.ui.click(alice, 0), top(), alice); + Inventory ballot = top(); + assertEquals("Bob", key(ballot.getItem(0))); + inventory.electionView.click(fixture.ui.click(alice, 0), ballot, alice); + assertEquals("Bob", election.getVote(Candidate.LEADER, "Alice")); + assertEquals(1, election.getVotes(Candidate.LEADER, "Bob")); + inventory.electionView.electionView(alice, home); + assertTrue(election.canVote("Alice")); + assertTrue( + String.join(" ", top().getItem(0).getItemMeta().getLore()).contains("already voted")); + } + + @Test + void staleCandidateMetadataDoesNotCastOrOverwriteABallot() { + Election election = home.getGovernment().getElection(); + home.addMember("Bob"); + election.addCandidate(Candidate.LEADER, "Bob"); + inventory.electionView.electionView(alice, home); + ItemStack item = top().getItem(0); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(Keys.STRING_KEY, PersistentDataType.STRING, "removed-office"); + item.setItemMeta(meta); + inventory.electionView.click(fixture.ui.click(alice, 0), top(), alice); + assertEquals(List.of("Bob"), election.getCandidates(Candidate.LEADER)); + election.start(); + inventory.electionView.votingView(alice, home, Candidate.LEADER); + item = top().getItem(0); + meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(Keys.SECONDARY_STRING_KEY, PersistentDataType.STRING, "removed-office"); + item.setItemMeta(meta); + java.io.PrintStream stderr = System.err; + java.io.ByteArrayOutputStream captured = new java.io.ByteArrayOutputStream(); + try { + System.setErr(new java.io.PrintStream(captured)); + inventory.electionView.click(fixture.ui.click(alice, 0), top(), alice); + } finally { + System.setErr(stderr); + } + assertTrue(captured.toString().contains("removed-office")); + assertNull(election.getVote(Candidate.LEADER, "Alice")); + assertEquals(0, election.getVotes(Candidate.LEADER, "Bob")); + meta.getPersistentDataContainer() + .set(Keys.SECONDARY_STRING_KEY, PersistentDataType.STRING, "LEADER"); + item.setItemMeta(meta); + election.removeCandidate(Candidate.LEADER, "Bob"); + inventory.electionView.click(fixture.ui.click(alice, 0), top(), alice); + assertNull(election.getVote(Candidate.LEADER, "Alice")); + } + + @Test + void previousPageAndRefreshAfterWithdrawalsKeepCandidatesAccessible() { + Election election = home.getGovernment().getElection(); + for (int n = 0; n < 50; n++) { + home.addMember("Candidate" + n); + election.addCandidate(Candidate.LEADER, "Candidate" + n); + } + election.start(); + inventory.electionView.votingView(alice, home, Candidate.LEADER); + Inventory menu = top(); + SFInventoryHolder holder = (SFInventoryHolder) menu.getHolder(); + inventory.electionView.click(fixture.ui.click(alice, 53), menu, alice); + assertEquals(1, holder.getPage()); + inventory.electionView.click(fixture.ui.click(alice, 45), menu, alice); + assertEquals(0, holder.getPage()); + inventory.electionView.click(fixture.ui.click(alice, 53), menu, alice); + for (int n = 1; n < 50; n++) election.removeCandidate(Candidate.LEADER, "Candidate" + n); + inventory.electionView.votingView(alice, home, Candidate.LEADER, menu); + assertEquals(0, holder.getPage()); + assertEquals("Candidate0", key(menu.getItem(0))); + assertNull(menu.getItem(53)); + assertNull(election.getVote(Candidate.LEADER, "Alice")); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/FactionMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/FactionMenusCoverageTest.java new file mode 100644 index 00000000..9b90352c --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/FactionMenusCoverageTest.java @@ -0,0 +1,837 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.tfminecraft.simplefactions.diplomacy.Relation; +import net.tfminecraft.simplefactions.enums.FactionModifiers; +import net.tfminecraft.simplefactions.enums.MenuItemType; +import net.tfminecraft.simplefactions.enums.RankType; +import net.tfminecraft.simplefactions.enums.Rules; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.espionage.CharacterNames; +import net.tfminecraft.simplefactions.espionage.EspionageAccess; +import net.tfminecraft.simplefactions.espionage.EspionageService; +import net.tfminecraft.simplefactions.espionage.IntelligenceReport; +import net.tfminecraft.simplefactions.espionage.RosterLore; +import net.tfminecraft.simplefactions.government.Government; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.installation.Installation; +import net.tfminecraft.simplefactions.installation.InstallationKind; +import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader; +import net.tfminecraft.simplefactions.loaders.RankLoader; +import net.tfminecraft.simplefactions.loaders.RelationLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.managers.TitleManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.FactionModifier; +import net.tfminecraft.simplefactions.objects.Modifier; +import net.tfminecraft.simplefactions.objects.PrestigeRank; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.tiers.Title; +import net.tfminecraft.simplefactions.util.LegacyModelData; +import net.tfminecraft.simplefactions.utils.FactionRanker; +import net.tfminecraft.simplefactions.utils.HomeSettlementNames; +import net.tfminecraft.simplefactions.utils.Represents; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemCreator; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +class FactionMenusCoverageTest { + private FactionDomainFixture fixture; + private Faction faction; + private Player player; + private InventoryManager manager; + private FactionView view; + private FactionCreator creator; + private MockedStatic<EspionageService> intelligence; + private FactionRanker ranker; + private final List<MockedStatic<?>> scopes = new ArrayList<>(); + private HashMap<Player, RankType> oldRanking; + private HashMap<Player, Integer> oldPage; + + private <T> MockedStatic<T> scoped(Class<T> type) { + MockedStatic<T> s = mockStatic(type); + scopes.add(s); + return s; + } + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + player = fixture.player("Leader"); + faction = fixture.saved("realm", "Leader"); + faction.getOrCreateMainGuild(); + manager = new InventoryManager(); + view = manager.factionView; + creator = view.creator; + intelligence = scoped(EspionageService.class); + intelligence.when(() -> EspionageService.canViewExact(any(), any())).thenReturn(true); + intelligence.when(() -> EspionageService.isOwn(player, faction)).thenReturn(true); + scoped(CharacterNames.class) + .when(() -> CharacterNames.display(any(), anyString(), any())) + .thenAnswer(call -> call.getArgument(1)); + scoped(RosterLore.class) + .when(() -> RosterLore.faction(any(), any())) + .thenReturn(List.of("Leader", "Alice")); + scoped(HomeSettlementNames.class) + .when(() -> HomeSettlementNames.of(any(Faction.class))) + .thenReturn("River Town"); + ranker = mock(FactionRanker.class); + creator.r = ranker; + view.r = ranker; + scoped(EspionageAccess.class); + ItemAPI api = mock(ItemAPI.class); + ItemCreator items = mock(ItemCreator.class); + when(api.getCreator()).thenReturn(items); + when(items.getItemsAdderItem(anyString())).thenAnswer(call -> new ItemStack(Material.PAPER)); + scoped(TLibs.class).when(TLibs::getItemAPI).thenReturn(api); + scoped(IconGetter.class); + oldRanking = FactionView.currentRanking; + oldPage = FactionView.currentPage; + FactionView.currentRanking = new HashMap<>(); + FactionView.currentPage = new HashMap<>(); + } + + @AfterEach + void close() { + try { + for (int n = scopes.size() - 1; n >= 0; n--) scopes.get(n).close(); + } finally { + FactionView.currentRanking = oldRanking; + FactionView.currentPage = oldPage; + fixture.close(); + } + } + + @ParameterizedTest + @ValueSource(strings = {"leadership", "deleted"}) + void aPendingBannerCannotApplyAfterTheFactionOrItsLeadershipChanges(String change) { + Inventory inventory = open(); + inventory.setItem(19, new ItemStack(Material.PAPER)); + manager.clickButton(fixture.ui.click(player, 19)); + assertEquals(1, fixture.bannerCallbacks.size()); + if (change.equals("leadership")) { + faction.addMember("Successor"); + faction.setLeader("Successor"); + } else net.tfminecraft.simplefactions.managers.FactionManager.factions.clear(); + fixture.bannerCallbacks.getFirst().accept(new ArrayList<>(List.of("red"))); + assertEquals(List.of("white"), faction.getBannerPatterns()); + } + + @Test + void aBannerButtonDoesNotGrantItemsAfterFactionMembershipIsLost() { + Inventory inventory = open(); + inventory.setItem(1, new ItemStack(Material.CHEST)); + manager.clickButton(fixture.ui.click(player, 1)); + assertEquals(Material.WHITE_BANNER, player.getInventory().getItem(0).getType()); + player.getInventory().clear(); + faction.getOrCreateMainGuild().kick("Leader"); + manager.clickButton(fixture.ui.click(player, 1)); + assertNull(player.getInventory().getItem(0)); + } + + @Test + void ownedAndEstimatedListLoreKeepsPublicIdentityButLabelsPrivateValues() { + Faction display = spy(faction); + doReturn(25.0).when(display).getProsperity(); + display.setWealth(200.0); + display.setPrestige(150.0); + doReturn(List.of("Leader", "Alice", "Bob")).when(display).getCompleteMemberList(); + when(ranker.getVisibleRank(player, display, RankType.WEALTH)).thenReturn(2); + when(ranker.getPrestigeRank(display)).thenReturn(1); + Title title = fixture.title("North County", "county", 1); + doReturn(List.of(title)).when(display).getTitles(); + doReturn(title).when(display).getHighestTitle(); + try (MockedStatic<TitleManager> titles = mockStatic(TitleManager.class)) { + titles.when(() -> TitleManager.getRealmSize(display)).thenReturn(3); + ItemStack item = creator.createListItem(player, display); + assertEquals("realm", id(item)); + assertLore(item, "Primary Title", true); + assertLore(item, "from subjects", true); + assertLore(item, "200.0d", true); + assertLore(item, "River Town", true); + assertLore(item, "25.0", true); + intelligence.when(() -> EspionageService.canViewExact(player, display)).thenReturn(false); + IntelligenceReport report = mock(IntelligenceReport.class); + when(report.display(anyString())).thenReturn("Unknown"); + when(report.display("Wealth")).thenReturn("100–300"); + intelligence.when(() -> EspionageService.report(player, display)).thenReturn(report); + item = creator.createListItem(player, display); + assertLore(item, "100–300d", true); + assertLore(item, "estimated 2", true); + assertLore(item, "Unknown", true); + intelligence.when(() -> EspionageService.report(player, display)).thenReturn(null); + when(ranker.getVisibleRank(player, display, RankType.WEALTH)).thenReturn(null); + assertLore(creator.createListItem(player, display), "Unknown", true); + doReturn(List.of(1, 2, 3)).when(display).getProvinces(); + assertLore(creator.createListItem(player, display), "Realm Size: 3", true); + } + } + + @Test + void listLoreDescribesMutualAndAsymmetricRelationsGuildsSubjectsAndAllies() { + Faction other = fixture.saved("other", "Other"); + other.getOrCreateMainGuild(); + fixture.guild(other, "Merchants", "Trader"); + fixture.subject(other, faction); + Faction ally = fixture.saved("ally", "Ally"); + other.setRelation( + ally, + new Relation( + fixture.relationType("ally", Map.of("name", "Allied")), + RelationLoader.getDefaultAttitude())); + ItemStack item = creator.createListItem(player, other); + assertLore(item, "outgoing", true); + assertLore(item, "incoming", true); + assertLore(item, "Merchants", true); + assertLore(item, "Subjects", true); + assertLore(item, "Allies", true); + assertLore(item, "ally", true); + faction.setRelation( + other, new Relation(RelationLoader.getDefaultType(), RelationLoader.getDefaultAttitude())); + other.setRelation( + faction, + new Relation(RelationLoader.getDefaultType(), RelationLoader.getDefaultAttitude())); + assertLore(creator.createListItem(player, other), "mutual", true); + assertFalse(String.join("\n", creator.listLore(null, other)).contains("Diplomacy")); + } + + @Test + void dissolvePreviewExplainsGuildTransfersIndependentSubjectsAndAnEmptyRealm() { + assertLore(creator.createDissolveItem(faction), "No other factions or guilds", true); + Guild guild = fixture.guild(faction, "Merchants", "Trader"); + Faction subject = fixture.saved("subject", "Subject"); + subject.getOrCreateMainGuild(); + fixture.subject(faction, subject); + ItemStack independent = creator.createDissolveItem(faction); + assertLore(independent, "Merchants", true); + assertLore(independent, "independent faction", true); + assertLore(independent, "subject", true); + assertEquals("realm", id(independent)); + Faction overlord = fixture.saved("overlord", "Overlord"); + overlord.getOrCreateMainGuild(); + fixture.subject(overlord, faction); + try (MockedStatic<WarManager> wars = mockStatic(WarManager.class)) { + assertLore(creator.createDissolveItem(faction), "transferred to overlord", true); + wars.when(() -> WarManager.isAtWar(subject)).thenReturn(true); + assertLore(creator.createDissolveItem(faction), "at war", true); + } + assertEquals("Trader", guild.getLeader()); + assertSame(faction, guild.getFaction()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void prestigeLoreDisplaysCurrentAndAdjacentRankEffectsWithCorrectSigns(boolean an) { + PrestigeRank low = RankLoader.getByLevel(1), + middle = RankLoader.getByLevel(2), + high = fixture.rank("illustrious", 3, 20000); + low.setAn(an); + high.setAn(an); + low.getModifiers().add(new FactionModifier(FactionModifiers.MILITARY_UPKEEP, 5)); + middle.getModifiers().add(new FactionModifier(FactionModifiers.MILITARY_UPKEEP, 0)); + middle + .getModifiers() + .add(new FactionModifier(FactionModifiers.DIPLOMATIC_CAPACITY_MULTIPLIER, 2)); + high.getModifiers() + .add(new FactionModifier(FactionModifiers.DIPLOMATIC_CAPACITY_MULTIPLIER, 3)); + faction.setRank(middle); + faction.setPrestigeModifiers( + new ArrayList<>( + List.of( + new Modifier("Achievements", 10.0, false), + new Modifier("Losses", -2.0, false), + new Modifier("20% Malus", -3.0, false), + new Modifier("10% Inactivity", -4.0, false)))); + ItemStack item = creator.createMenuItem(player, faction, MenuItemType.PRESTIGE); + assertLore(item, "+10.0 from Achievements", true); + assertLore(item, "-2.0 from Losses", true); + assertLore(item, "20% Malus: -3.0", true); + assertLore(item, "10% Inactivity: -4.0", true); + assertLore(item, an ? "become an illustrious" : "become a illustrious", true); + assertLore(item, an ? "become an common" : "become a common", true); + assertLore(item, "Diplomatic", true); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void governmentLoreIncludesCouncilRepresentationAndPowerGain(boolean council) { + Faction display = spy(faction); + Government gov = mock(Government.class, RETURNS_DEEP_STUBS); + doReturn(gov).when(display).getGovernment(); + when(gov.getPower()).thenReturn(council ? -4.0 : 5.0); + when(gov.getMaxPower()).thenReturn(council ? -2.0 : 10.0); + when(gov.getPowerGain()).thenReturn(council ? -1.0 : 2.0); + when(gov.hasCouncil()).thenReturn(council); + when(gov.hasLeaderElections()).thenReturn(council); + when(gov.hasCouncilElections()).thenReturn(council); + when(gov.getCouncil().getType()).thenReturn(Rules.ELECTED_COUNCIL); + when(gov.getCouncil().getCurrentSize()).thenReturn(1); + when(gov.getCouncil().getMaxSize()).thenReturn(3); + when(gov.getCouncilMembers()).thenReturn(List.of("Alice")); + try (MockedStatic<Represents> represents = mockStatic(Represents.class)) { + represents.when(() -> Represents.represents(display, "Alice")).thenReturn("Merchants"); + ItemStack item = creator.createMenuItem(player, display, MenuItemType.GOVERNMENT); + assertEquals("realm", id(item)); + assertLore(item, "Administrative Power", true); + assertLore(item, council ? "-4.0/-2.0" : "5.0/10.0", true); + assertLore(item, council ? "Alice" : "Has Council", true); + if (council) assertLore(item, "Merchants", true); + } + } + + @Test + void menuItemsContainTheirActualDomainSummariesAndActionIds() { + faction.addMember("Alice"); + faction.setWealth(75.0); + faction.getOrCreateMainGuild().addWealthModifier(new Modifier("Treasury", 75.0, false)); + assertLore(creator.createMenuItem(player, faction, MenuItemType.BANNER), "Riverfolk", true); + assertLore( + creator.createMenuItem(player, faction, MenuItemType.BANNER_GET), "Click to get", true); + assertLore( + creator.createMenuItem(player, faction, MenuItemType.BANNER_RANDOM), "randomise", true); + assertLore(creator.createMenuItem(player, faction, MenuItemType.LEADER), "Ruling System", true); + assertLore( + creator.createMenuItem(player, faction, MenuItemType.WEALTH), "75.0d from Treasury", true); + assertTrue(name(creator.createMenuItem(player, faction, MenuItemType.MEMBERS)).contains("2")); + assertLore(creator.createMenuItem(player, faction, MenuItemType.MEMBERS), "Alice", true); + assertLore(creator.createMenuItem(player, faction, MenuItemType.GUILDS), "1 guild", true); + fixture.guild(faction, "Merchants", "Trader"); + assertLore(creator.createMenuItem(player, faction, MenuItemType.GUILDS), "2 guilds", true); + for (MenuItemType type : + List.of( + MenuItemType.MILITARY, + MenuItemType.TIER, + MenuItemType.TITLES, + MenuItemType.MODIFIERS, + MenuItemType.TAX, + MenuItemType.LAWS)) + assertEquals("realm", id(creator.createMenuItem(player, faction, type))); + assertEquals( + Material.DIRT, + creator.createMenuItem(player, faction, MenuItemType.TRADE_BREAKDOWN).getType()); + ItemStack icon = new ItemStack(Material.PLAYER_HEAD); + ItemMeta meta = icon.getItemMeta(); + LegacyModelData.set(meta, 17); + icon.setItemMeta(meta); + when(IconGetter.hasIcon("WEALTH")).thenReturn(true); + when(IconGetter.getIcon("WEALTH")).thenReturn(icon); + ItemStack overridden = creator.createMenuItem(player, faction, MenuItemType.WEALTH); + assertEquals(Material.PLAYER_HEAD, overridden.getType()); + assertEquals(17, LegacyModelData.get(overridden.getItemMeta())); + assertLore(overridden, "Treasury", true); + } + + @Test + void installationsDiplomacyModifiersTaxesAndLawsShowOnlyApplicableValues() { + Faction display = spy(faction); + List<Installation> installations = new ArrayList<>(); + for (InstallationKind kind : InstallationKind.values()) + installations.add(new Installation(kind.name(), kind.name(), kind, 7, 0, 0, 0)); + var handler = + mock(net.tfminecraft.simplefactions.installation.handler.InstallationHandler.class); + when(handler.getAll()).thenReturn(installations); + doReturn(handler).when(display).getInstallationHandler(); + try (MockedStatic<InstallationConfigLoader> config = + mockStatic(InstallationConfigLoader.class)) { + config.when(() -> InstallationConfigLoader.getDailyUpkeep(any(), anyInt())).thenReturn(2.0); + ItemStack item = creator.createMenuItem(player, display, MenuItemType.INSTALLATIONS); + assertLore(item, "Forts: 1", true); + assertLore(item, "Train Stations: 1", true); + assertLore(item, "8.0d/day", true); + } + var diplomacy = mock(net.tfminecraft.simplefactions.diplomacy.DiplomacyHandler.class); + doReturn(diplomacy).when(display).getDiplomacyHandler(); + when(diplomacy.getUsedDiplomaticCapacity()).thenReturn(4.0); + when(diplomacy.getDiplomaticCapacity()).thenReturn(3.0); + assertLore(creator.createMenuItem(player, display, MenuItemType.DIPLOMACY), "4.0/3.0", true); + when(diplomacy.getDiplomaticCapacity()).thenReturn(5.0); + assertLore(creator.createMenuItem(player, display, MenuItemType.DIPLOMACY), "4.0/5.0", true); + doReturn( + List.of( + new FactionModifier(FactionModifiers.DIPLOMATIC_CAPACITY_MULTIPLIER, 2), + new FactionModifier(FactionModifiers.DIPLOMATIC_CAPACITY_MULTIPLIER, 0))) + .when(display) + .getCombinedModifiers(); + assertLore(creator.createMenuItem(player, display, MenuItemType.MODIFIERS), "Diplomatic", true); + var taxes = mock(net.tfminecraft.simplefactions.objects.handler.TaxHandler.class); + doReturn(taxes).when(display).getTaxHandler(); + when(taxes.canCollectTax(TaxTarget.CITIZENS)).thenReturn(true); + doReturn(5.0).when(display).getTaxRate(TaxTarget.CITIZENS, null, false); + doReturn(4.0).when(display).getTaxRate(TaxTarget.CITIZENS, null, true); + assertLore(creator.createMenuItem(player, display, MenuItemType.TAX), "5.0%", true); + assertLore(creator.createMenuItem(player, display, MenuItemType.TAX), "4.0% effective", true); + var law = fixture.lawGroup("trade", Map.of()); + law.setCurrent(law.getLaw("current")); + var laws = mock(net.tfminecraft.simplefactions.objects.handler.LawHandler.class); + when(laws.getGroupList()).thenReturn(List.of(law)); + doReturn(laws).when(display).getLawHandler(); + Government gov = mock(Government.class); + when(gov.getTotalUpkeep()).thenReturn(3.0); + doReturn(gov).when(display).getGovernment(); + assertLore( + creator.createMenuItem(player, display, MenuItemType.LAWS), + "3.0 Administrative Power", + true); + } + + @Test + void foreignMenuItemsDelegateToTheIntelligenceRendererAndKeepIcons() { + intelligence.when(() -> EspionageService.canViewExact(player, faction)).thenReturn(false); + try (MockedStatic<EspionageView> foreign = mockStatic(EspionageView.class)) { + ItemStack redacted = new ItemStack(Material.WRITABLE_BOOK); + foreign + .when(() -> EspionageView.factionItem(player, faction, MenuItemType.WEALTH)) + .thenReturn(redacted); + assertSame(redacted, creator.createMenuItem(player, faction, MenuItemType.WEALTH)); + foreign.verify(() -> EspionageView.factionItem(player, faction, MenuItemType.WEALTH)); + } + } + + @Test + void titlesDistinguishOwnersOverlordsAndOtherViewers() { + Title title = fixture.title("North County", "county", 7); + Faction target = spy(fixture.saved("subject", "Subject")); + target.getOrCreateMainGuild(); + doReturn(List.of(title)).when(target).getRankedTitles(); + assertLore(creator.createMenuItem(player, target, MenuItemType.TITLES), "North County", true); + assertLore(creator.createMenuItem(player, target, MenuItemType.TIER), "Click to edit", false); + fixture.subject(faction, target); + assertLore( + creator.createMenuItem(player, target, MenuItemType.TITLES), "grant to subject", true); + Player outsider = fixture.player("Outsider"); + assertLore(creator.createMenuItem(outsider, target, MenuItemType.TITLES), "North County", true); + when(IconGetter.hasIcon("TITLES")).thenReturn(true); + ItemStack bookIcon = new ItemStack(Material.BOOK); + ItemMeta bookMeta = bookIcon.getItemMeta(); + LegacyModelData.set(bookMeta, 0); + bookIcon.setItemMeta(bookMeta); + when(IconGetter.getIcon("TITLES")).thenReturn(bookIcon); + assertEquals( + Material.BOOK, creator.createMenuItem(player, target, MenuItemType.TITLES).getType()); + } + + @Test + void factionListPagesCarryStableIdsAndChangingRankReturnsToFirstPage() { + List<Faction> rows = new ArrayList<>(); + for (int n = 0; n < 49; n++) { + Faction row = mock(Faction.class); + when(row.getId()).thenReturn("f" + n); + rows.add(row); + } + view.creator = mock(FactionCreator.class); + when(view.creator.createListItem(eq(player), any())) + .thenAnswer(c -> tag(c.<Faction>getArgument(1).getId())); + when(ranker.getVisibleRankedList(eq(player), any())).thenReturn(rows); + view.factionList(player); + Inventory first = top(); + assertEquals("f0", id(first.getItem(0))); + assertNotNull(first.getItem(53)); + assertNull(first.getItem(45)); + click(53); + assertEquals(1, FactionView.currentPage.get(player)); + assertEquals("f47", id(top().getItem(0))); + assertNotNull(top().getItem(45)); + assertNull(top().getItem(53)); + click(45); + assertEquals(0, FactionView.currentPage.get(player)); + assertEquals("f0", id(top().getItem(0))); + for (RankType rank : List.of(RankType.WEALTH, RankType.MEMBERS, RankType.PRESTIGE)) { + click(8); + assertEquals(rank, FactionView.currentRanking.get(player)); + assertEquals(0, FactionView.currentPage.get(player)); + } + Inventory existing = top(); + view.factionList(player, existing); + assertSame(existing, top()); + assertEquals("f0", id(top().getItem(0))); + } + + @Test + void factionListOnlyOpensRegisteredFactionIds() { + when(ranker.getVisibleRankedList(eq(player), any())).thenReturn(List.of()); + view.factionList(player); + Inventory list = top(); + FactionView dispatch = spy(view); + doNothing().when(dispatch).factionView(player, faction); + dispatch.click(fixture.ui.click(player, 0), list, player); + verify(dispatch, never()).factionView(eq(player), any(Faction.class)); + list.setItem(0, new ItemStack(Material.PAPER)); + dispatch.click(fixture.ui.click(player, 0), list, player); + list.setItem(0, tag("deleted")); + dispatch.click(fixture.ui.click(player, 0), list, player); + verify(dispatch, never()).factionView(eq(player), any(Faction.class)); + list.setItem(0, tag("realm")); + dispatch.click(fixture.ui.click(player, 0), list, player); + verify(dispatch).factionView(player, faction); + } + + @Test + void factionPageShowsOwnerActionsAndRefreshesInPlace() { + view.guildCreator = mock(GuildCreator.class); + when(view.guildCreator.createLedgerItem(player, faction.getOrCreateMainGuild())) + .thenAnswer(call -> new ItemStack(Material.BOOK)); + view.factionView(player, faction); + Inventory inventory = top(); + assertEquals(SFGUI.FACTION_VIEW, ((SFInventoryHolder) inventory.getHolder()).getType()); + assertEquals("realm", id(inventory.getItem(23))); + assertEquals(Material.CHEST, inventory.getItem(1).getType()); + assertNotNull(inventory.getItem(19)); + assertNotNull(inventory.getItem(20)); + assertNotNull(inventory.getItem(53)); + view.factionView(player, faction, inventory); + assertSame(inventory, top()); + Player visitor = fixture.player("Visitor"); + view.factionView(visitor, faction); + assertNull(visitor.getOpenInventory().getTopInventory().getItem(1)); + assertNull(visitor.getOpenInventory().getTopInventory().getItem(19)); + } + + @Test + void privateFactionPageUsesForeignRendererForOpenAndRefresh() { + intelligence.when(() -> EspionageService.canViewExact(player, faction)).thenReturn(false); + try (MockedStatic<EspionageView> foreign = mockStatic(EspionageView.class)) { + foreign + .when(() -> EspionageView.foreign(any(), eq(player), eq(faction), eq(manager))) + .thenAnswer( + c -> { + c.<Inventory>getArgument(0).setItem(4, new ItemStack(Material.SPYGLASS)); + return null; + }); + view.factionView(player, faction); + Inventory inventory = top(); + assertEquals(Material.SPYGLASS, inventory.getItem(4).getType()); + view.factionView(player, faction, inventory); + assertSame(inventory, top()); + foreign.verify(() -> EspionageView.foreign(inventory, player, faction, manager), times(2)); + } + } + + @ParameterizedTest + @ValueSource(ints = {11, 15, 25, 28, 29, 30, 31, 32, 33, 34}) + void ownerMenuClicksRouteToTheCurrentFactionAndConfirmation(int slot) { + InventoryManager navigation = navigation(); + Inventory inventory = open(); + inventory.setItem(slot, tag("realm")); + click(slot); + switch (slot) { + case 11 -> verify(navigation).governmentView(player, faction, null); + case 15 -> verify(navigation).ledgerView(player, faction.getOrCreateMainGuild(), null); + case 25 -> verify(navigation).taxView(player, faction); + case 28 -> verify(navigation).lawView(player, faction, null); + case 29 -> verify(navigation).militaryView(null, player, faction, true); + case 30 -> { + assertSame(faction, navigation.confirming.get(player)); + verify(navigation).confirmView(player, faction, "dissolve", "true"); + } + case 31 -> verify(navigation).diplomacyListView(null, player, faction, true); + case 32 -> verify(navigation).installationsView(null, player, faction, true); + case 33 -> verify(navigation).tierView(null, player, faction, true); + case 34 -> verify(navigation).titleView(null, player, faction, true); + default -> fail("Unhandled navigation case"); + } + } + + @ParameterizedTest + @ValueSource(ints = {11, 25, 28, 29, 32, 19}) + void restrictedViewsRouteOnlyToTheirReadOnlyPage(int slot) { + InventoryManager navigation = navigation(); + Inventory inventory = open(); + inventory.setItem(slot, tag("realm")); + intelligence.when(() -> EspionageService.canViewExact(player, faction)).thenReturn(false); + click(slot); + switch (slot) { + case 11 -> verify(navigation).governmentView(player, faction, null); + case 25 -> verify(navigation).taxView(player, faction); + case 28 -> verify(navigation).lawView(player, faction, null); + case 29 -> verify(navigation).militaryView(null, player, faction, true); + case 32 -> verify(navigation).installationsView(null, player, faction, true); + case 19 -> { + verifyNoInteractions(navigation); + assertTrue(fixture.bannerCallbacks.isEmpty()); + } + default -> fail(); + } + } + + @ParameterizedTest + @ValueSource(ints = {11, 28, 29, 30, 31, 32, 33, 34, 23}) + void taggedActionsIgnoreMissingOrDeletedTargets(int slot) { + InventoryManager navigation = navigation(); + Inventory inventory = open(); + inventory.setItem(slot, new ItemStack(Material.PAPER)); + click(slot); + inventory.setItem(slot, tag("deleted")); + click(slot); + verifyNoInteractions(navigation); + assertTrue(navigation.confirming.isEmpty()); + } + + @Test + void titleAndTierEditingRequiresCurrentLeadershipOrTheOverlord() { + InventoryManager navigation = navigation(); + faction.addMember("Successor"); + faction.setLeader("Successor"); + Inventory inventory = open(); + for (int slot : List.of(33, 34, 19)) { + inventory.setItem(slot, tag("realm")); + click(slot); + } + verifyNoInteractions(navigation); + assertTrue(fixture.bannerCallbacks.isEmpty()); + Faction own = fixture.saved("own", "Leader"); + own.getOrCreateMainGuild(); + inventory.setItem(34, tag("realm")); + click(34); + verifyNoInteractions(navigation); + fixture.subject(own, faction); + click(34); + verify(navigation).titleView(null, player, faction, true); + } + + @Test + void positionsOpensTheFactionPositionPage() { + InventoryManager navigation = navigation(); + Inventory inventory = open(); + inventory.setItem(20, new ItemStack(Material.PAPER)); + try (MockedStatic<EspionageView> positions = mockStatic(EspionageView.class)) { + click(20); + positions.verify(() -> EspionageView.positions(player, faction, navigation)); + } + } + + @Test + void currentLeaderBannerRequestsApplyOnceAndReportUnavailablePatterns() { + fixture.useRealBannerRefresh(); + open().setItem(19, new ItemStack(Material.PAPER)); + click(19); + fixture.bannerCallbacks.getFirst().accept(new ArrayList<>(List.of("red"))); + assertEquals(List.of("red"), faction.getBannerPatterns()); + assertEquals("Banner of realm", name(top().getItem(10))); + assertEquals(faction.getBanner().getType(), top().getItem(10).getType()); + assertLore(top().getItem(10), "Riverfolk", true); + click(19); + fixture.bannerCallbacks.get(1).accept(null); + verify(player).sendMessage(contains("Could not generate")); + assertEquals(List.of("red"), faction.getBannerPatterns()); + when(player.isOnline()).thenReturn(false); + click(19); + clearInvocations(player); + fixture.bannerCallbacks.get(2).accept(null); + verify(player, never()).sendMessage(anyString()); + click(19); + clearInvocations(player); + fixture.bannerCallbacks.get(3).accept(new ArrayList<>(List.of("blue"))); + assertEquals(List.of("blue"), faction.getBannerPatterns()); + verify(player, never()).playSound(eq(player), any(Sound.class), anyFloat(), anyFloat()); + } + + @Test + void guildListPagesPreserveHolderPageAndOpenOnlyExistingGuilds() { + InventoryManager navigation = navigation(); + view.guildCreator = mock(GuildCreator.class); + when(view.guildCreator.createListItem(eq(player), any())) + .thenAnswer(c -> tag(c.<Guild>getArgument(1).getId())); + for (int n = 0; n < 49; n++) fixture.guild(faction, "g" + n, "Member" + n); + List<Guild> ordered = faction.getGuildHandler().getGuilds(); + view.factionGuildsView(player, faction, (Inventory) null); + assertEquals(ordered.get(0).getId(), id(top().getItem(0))); + assertNotNull(top().getItem(53)); + click(53); + assertEquals(1, ((SFInventoryHolder) top().getHolder()).getPage()); + Inventory page = top(); + String firstId = id(page.getItem(0)); + assertEquals(ordered.get(47).getId(), firstId); + view.factionGuildsView(player, faction, page); + assertSame(page, top()); + assertEquals(firstId, id(page.getItem(0))); + click(45); + assertEquals(0, ((SFInventoryHolder) top().getHolder()).getPage()); + click(0); + verify(navigation.guildView).guildView(player, ordered.get(0), true); + Inventory inventory = top(); + inventory.setItem(0, null); + click(0); + inventory.setItem(0, new ItemStack(Material.PAPER)); + click(0); + ItemStack heading = new ItemStack(Material.PAPER); + ItemMeta headingMeta = heading.getItemMeta(); + headingMeta.setDisplayName("Guild directory"); + heading.setItemMeta(headingMeta); + inventory.setItem(0, heading); + click(0); + inventory.setItem(0, tag("deleted")); + click(0); + verifyNoMoreInteractions(navigation.guildView); + inventory.setItem(0, tag("realm")); + FactionManager.factions.clear(); + click(0); + verifyNoMoreInteractions(navigation.guildView); + } + + @Test + void guildButtonOpensTheActualFactionGuildListAndIgnoresUnmarkedItems() { + Inventory inventory = open(); + inventory.setItem(23, null); + click(23); + assertSame(inventory, top()); + inventory.setItem(23, new ItemStack(Material.PAPER)); + click(23); + assertSame(inventory, top()); + view.guildCreator = mock(GuildCreator.class); + when(view.guildCreator.createListItem(eq(player), any())) + .thenAnswer(c -> tag(c.<Guild>getArgument(1).getId())); + inventory.setItem(23, creator.createMenuItem(player, faction, MenuItemType.GUILDS)); + click(23); + assertEquals(SFGUI.FACTION_GUILDS, ((SFInventoryHolder) top().getHolder()).getType()); + assertEquals("realm", id(top().getItem(0))); + } + + @Test + void menuClickProtectionBlocksTopAndTransferActionsButAllowsPlayerInventory() { + Inventory inventory = open(); + InventoryClickEvent top = fixture.ui.click(player, 0); + view.clickPreventions(top, inventory, player); + assertTrue(top.isCancelled()); + InventoryClickEvent bottom = fixture.ui.click(player, 54); + view.clickPreventions(bottom, inventory, player); + assertFalse(bottom.isCancelled()); + for (InventoryAction action : + List.of(InventoryAction.HOTBAR_SWAP, InventoryAction.HOTBAR_MOVE_AND_READD)) { + InventoryClickEvent hotbar = + new InventoryClickEvent( + player.getOpenInventory(), + org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, + 54, + ClickType.NUMBER_KEY, + action, + 0); + view.clickPreventions(hotbar, inventory, player); + assertTrue(hotbar.isCancelled()); + } + InventoryClickEvent shift = + new InventoryClickEvent( + player.getOpenInventory(), + org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER, + 54, + ClickType.SHIFT_LEFT, + InventoryAction.MOVE_TO_OTHER_INVENTORY); + view.clickPreventions(shift, inventory, player); + assertTrue(shift.isCancelled()); + InventoryClickEvent outside = fixture.ui.click(player, -999); + view.clickPreventions(outside, inventory, player); + assertFalse(outside.isCancelled()); + view.clickPreventions(bottom, null, player); + view.clickPreventions(bottom, fixture.ui.inventory(null, 9, "Other"), player); + assertFalse(bottom.isCancelled()); + } + + @Test + void staleOrUnrelatedFactionHoldersCannotRouteActions() { + InventoryManager navigation = navigation(); + Inventory inventory = open(); + FactionManager.factions.clear(); + inventory.setItem(11, tag("realm")); + click(11); + verifyNoInteractions(navigation); + inventory = fixture.ui.inventory(null, 54, "§7Faction View"); + player.openInventory(inventory); + click(11); + verifyNoInteractions(navigation); + inventory = fixture.ui.inventory(null, 9, "Other"); + player.openInventory(inventory); + InventoryClickEvent event = fixture.ui.click(player, 0); + view.click(event, inventory, player); + assertFalse(event.isCancelled()); + } + + private InventoryManager navigation() { + InventoryManager nav = mock(InventoryManager.class); + nav.confirming = new HashMap<>(); + nav.guildView = mock(GuildView.class); + when(nav.createBackButton(any())).thenAnswer(call -> new ItemStack(Material.PAPER)); + clearInvocations(nav); + view.inv = nav; + return nav; + } + + private Inventory top() { + return player.getOpenInventory().getTopInventory(); + } + + private void click(int slot) { + Inventory inventory = top(); + InventoryClickEvent event = fixture.ui.click(player, slot); + view.click(event, inventory, player); + assertTrue(event.isCancelled()); + } + + private ItemStack tag(String id) { + ItemStack item = new ItemStack(Material.PAPER); + ItemMeta meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(new NamespacedKey(fixture.ui.plugin, "id"), PersistentDataType.STRING, id); + item.setItemMeta(meta); + return item; + } + + private static String name(ItemStack item) { + return ChatColor.stripColor(item.getItemMeta().getDisplayName()); + } + + private static String lore(ItemStack item) { + return item.getItemMeta().getLore() == null + ? "" + : String.join( + "\n", item.getItemMeta().getLore().stream().map(ChatColor::stripColor).toList()); + } + + private static void assertLore(ItemStack item, String text, boolean present) { + assertEquals( + present, + lore(item).contains(text), + () -> "Expected " + text + " present=" + present + " in " + lore(item)); + } + + private String id(ItemStack item) { + return item.getItemMeta() + .getPersistentDataContainer() + .get(new NamespacedKey(fixture.ui.plugin, "id"), PersistentDataType.STRING); + } + + private Inventory open() { + Inventory inventory = + fixture.ui.inventory( + new SFInventoryHolder("realm", SFGUI.FACTION_VIEW), 54, "§7Faction View"); + player.openInventory(inventory); + return inventory; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/FinancialMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/FinancialMenusCoverageTest.java new file mode 100644 index 00000000..f95cbaa8 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/FinancialMenusCoverageTest.java @@ -0,0 +1,596 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.espionage.EspionageService; +import net.tfminecraft.simplefactions.government.proposal.FeeChange; +import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.government.proposal.Proposal; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.loans.CreditCalculator; +import net.tfminecraft.simplefactions.guild.loans.Loan; +import net.tfminecraft.simplefactions.keys.Keys; +import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.objects.Bracket; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.vehicles.fees.VfBuildersCatalog; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemCreator; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +class FinancialMenusCoverageTest { + private FactionDomainFixture fixture; + private Faction faction; + private Guild lender; + private Guild borrower; + private Player leader; + private Player borrowerLeader; + private InventoryManager navigation; + private LoanView loans; + private VehicleFeeView fees; + private String oldYear; + private int oldIrlYear; + private final List<MockedStatic<?>> scopes = new ArrayList<>(); + private final List<VfBuildersCatalog.Category> categories = new ArrayList<>(); + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + faction = fixture.saved("lenders", "Leader"); + lender = faction.getOrCreateMainGuild(); + borrower = fixture.saved("borrowers", "Borrower").getOrCreateMainGuild(); + leader = fixture.player("Leader"); + borrowerLeader = fixture.player("Borrower"); + oldYear = Cache.baseYear; + oldIrlYear = Cache.baseIrlYear; + Cache.baseYear = "322 AE"; + Cache.baseIrlYear = 2026; + navigation = mock(InventoryManager.class); + navigation.confirming = new HashMap<>(); + navigation.governmentView = mock(GovernmentView.class); + when(navigation.createBackButton(any())).thenAnswer(call -> new ItemStack(Material.BARRIER)); + loans = new LoanView(navigation); + fees = new VehicleFeeView(navigation); + ItemAPI items = mock(ItemAPI.class); + ItemCreator creator = mock(ItemCreator.class); + when(items.getCreator()).thenReturn(creator); + when(creator.getItemsAdderItem(anyString())).thenAnswer(call -> new ItemStack(Material.PAPER)); + when(creator.getItemFromPath(anyString())) + .thenAnswer(call -> new ItemStack(Material.GOLD_NUGGET)); + scope(TLibs.class).when(TLibs::getItemAPI).thenReturn(items); + scope(IconGetter.class) + .when(() -> IconGetter.getIconOrDefault(anyString(), any())) + .thenAnswer(call -> new ItemStack((Material) call.getArgument(1))); + MockedStatic<VfBuildersCatalog> catalog = scope(VfBuildersCatalog.class); + catalog.when(VfBuildersCatalog::categories).thenAnswer(call -> new ArrayList<>(categories)); + catalog + .when(() -> VfBuildersCatalog.category(anyString())) + .thenAnswer( + call -> + categories.stream() + .filter(c -> c.id().equalsIgnoreCase(call.getArgument(0))) + .findFirst() + .orElse(null)); + } + + @AfterEach + void close() { + try { + for (int i = scopes.size() - 1; i >= 0; i--) scopes.get(i).close(); + } finally { + Cache.baseYear = oldYear; + Cache.baseIrlYear = oldIrlYear; + fixture.close(); + } + } + + @Test + void vehicleFeeBackReturnsToTheSameFeeCategorySelection() { + faction.getVehicleFeeHandler().applyBracket(FeeKind.VEHICLE_TAX, new Bracket(0, 100)); + categories.add( + new VfBuildersCatalog.Category( + "cars", null, List.of(new VfBuildersCatalog.Entry("car", null)))); + fees.feeVehicleView(leader, faction, FeeKind.VEHICLE_TAX, "cars", null); + SFInventoryHolder holder = (SFInventoryHolder) top(leader).getHolder(); + fees.back(leader, faction, holder); + SFInventoryHolder after = (SFInventoryHolder) top(leader).getHolder(); + assertEquals(SFGUI.FEE_CATEGORY_VIEW, after.getType()); + assertEquals("VEHICLE_TAX", after.getSecondaryId()); + } + + @Test + void aLoanPaidAfterOpeningTheMenuCannotBePutIntoDefault() { + Loan loan = loan("loan", System.currentTimeMillis()); + loans.loanDetailView(borrowerLeader, borrower, loan, true); + Inventory oldMenu = top(borrowerLeader); + loan.makePayment(loan.getTotalOwed(), false); + assertTrue(loan.isPaidOff()); + int credit = borrower.getLoanHandler().getCreditScore(); + loans.click(fixture.ui.click(borrowerLeader, 14), oldMenu, borrowerLeader); + assertFalse(loan.hasDefaulted()); + assertEquals(credit, borrower.getLoanHandler().getCreditScore()); + assertEquals(0, loan.getTotalOwed()); + } + + @Test + void aTakenLoanWhoseIssuerDisappearedWhileTheMenuWasOpenIsIgnored() { + loan("loan", System.currentTimeMillis()); + loans.loansTakenView(borrowerLeader, borrower); + Inventory oldMenu = top(borrowerLeader); + FactionManager.factions.remove(faction); + assertDoesNotThrow( + () -> loans.click(fixture.ui.click(borrowerLeader, 0), oldMenu, borrowerLeader)); + assertSame(oldMenu, top(borrowerLeader)); + } + + @Test + void loanMainMenuTotalsAndIssuingControlsReflectCurrentGuildLeadership() { + loan("first", System.currentTimeMillis()); + loans.loanMainView(leader, lender); + Inventory menu = top(leader); + assertEquals(SFGUI.LOAN_MAIN_VIEW, ((SFInventoryHolder) menu.getHolder()).getType()); + assertEquals("Loans Given", name(menu.getItem(2))); + assertTrue(lore(menu.getItem(2)).contains("Total Lent: 1000.00d")); + assertNotNull(menu.getItem(6)); + faction.setLeader("Successor"); + loans.loanMainView(leader, lender, menu); + assertNull(menu.getItem(6)); + assertSame(menu, top(leader)); + loans.loanMainView(borrowerLeader, borrower); + assertTrue(lore(top(borrowerLeader).getItem(4)).contains("Total Owed: 1000.0d")); + } + + @Test + void reportedLoanMenusExposeUnknownTotalsAndNoIssuingActionOnOpenAndRefresh() { + scope(EspionageService.class) + .when(() -> EspionageService.canViewExact(any(), any())) + .thenReturn(false); + loans.loanMainView(leader, lender); + Inventory menu = top(leader); + assertTrue(((SFInventoryHolder) menu.getHolder()).isReported()); + assertTrue(lore(menu.getItem(2)).contains("Unknown")); + assertNull(menu.getItem(6)); + menu.setItem(6, new ItemStack(Material.WRITABLE_BOOK)); + loans.loanMainView(leader, lender, menu); + assertSame(menu, top(leader)); + assertNull(menu.getItem(6)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void loanListNavigationDisplaysOnlyTheCorrectSideAndRefreshesRemovedRecords(boolean given) { + Loan loan = loan("first", System.currentTimeMillis()); + Player actor = given ? leader : borrowerLeader; + Guild owner = given ? lender : borrower; + loans.loanMainView(actor, owner); + loans.click(fixture.ui.click(actor, given ? 2 : 4), top(actor), actor); + Inventory list = top(actor); + assertEquals(loan.getId(), data(list.getItem(0), Keys.STRING_KEY)); + assertTrue(name(list.getItem(0)).startsWith(given ? "Loan to" : "Loan from")); + loans.click(fixture.ui.click(actor, 0), list, actor); + assertEquals( + given ? SFGUI.ISSUED_LOAN_DETAIL_VIEW : SFGUI.TAKEN_LOAN_DETAIL_VIEW, + ((SFInventoryHolder) top(actor).getHolder()).getType()); + assertEquals(loan.getId(), data(top(actor).getItem(15), Keys.STRING_KEY)); + lender.getLoanHandler().removeLoan(loan.getId()); + if (given) loans.loansGivenView(actor, owner, list); + else loans.loansTakenView(actor, owner, list); + assertNull(list.getItem(0)); + assertEquals(Material.BARRIER, list.getItem(53).getType()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void loanListsReserveTheBottomRowWhenMoreThanFortyFiveLoansExist(boolean given) { + for (int n = 0; n < 46; n++) loan("loan-" + n, System.currentTimeMillis()); + Player actor = given ? leader : borrowerLeader; + if (given) loans.loansGivenView(actor, lender); + else loans.loansTakenView(actor, borrower); + Inventory list = top(actor); + for (int n = 0; n < 45; n++) assertNotNull(data(list.getItem(n), Keys.STRING_KEY)); + assertNull(list.getItem(45)); + assertEquals(Material.BARRIER, list.getItem(53).getType()); + } + + @ParameterizedTest + @ValueSource(strings = {"book", "wrong_item", "former_leader"}) + void issuingUsesTheCurrentLeaderAndRequiresABookAndQuill(String state) { + ItemStack held = + new ItemStack(state.equals("wrong_item") ? Material.DIAMOND : Material.WRITABLE_BOOK); + var inventory = leader.getInventory(); + inventory.setItem(0, held); + when(inventory.getItemInMainHand()).thenAnswer(call -> inventory.getItem(0)); + doAnswer( + call -> { + inventory.setItem(0, call.getArgument(0)); + return null; + }) + .when(inventory) + .setItemInMainHand(any()); + loans.loanMainView(leader, lender); + Inventory menu = top(leader); + if (state.equals("former_leader")) faction.setLeader("Successor"); + InventoryClickEvent event = fixture.ui.click(leader, 6); + loans.click(event, menu, leader); + assertTrue(event.isCancelled()); + if (state.equals("book")) { + ItemMeta meta = leader.getInventory().getItem(0).getItemMeta(); + assertEquals( + lender.getId(), + meta.getPersistentDataContainer().get(Keys.STRING_KEY, PersistentDataType.STRING)); + assertEquals(1, meta.getPersistentDataContainer().get(Keys.INT, PersistentDataType.INTEGER)); + verify(leader).closeInventory(); + } else { + assertSame(held, leader.getInventory().getItem(0)); + assertSame(menu, top(leader)); + if (state.equals("wrong_item")) verify(leader).sendMessage(contains("book and quill")); + } + } + + @ParameterizedTest + @ValueSource(strings = {"active", "overdue", "defaulted", "paid", "resumed", "interest"}) + void loanDetailLoreAndControlsMatchTheActualLoanState(String state) { + long start = System.currentTimeMillis() - (state.equals("overdue") ? 45L : 15L) * 86400000; + Loan loan = loan("first", start); + if (state.equals("defaulted") || state.equals("resumed")) loan.setDefaulted(true); + if (state.equals("resumed")) loan.setDefaulted(false); + if (state.equals("paid")) loan.makePayment(loan.getTotalOwed(), false); + if (state.equals("interest")) { + loan.setAutoPay(false); + loan.tickDay(); + } + loans.loanDetailView(borrowerLeader, borrower, loan, true); + Inventory menu = top(borrowerLeader); + String detail = lore(menu.getItem(15)); + assertTrue(detail.contains("Original Amount: 1000.0d")); + assertEquals(lender.getId(), data(menu.getItem(15), Keys.SECONDARY_STRING_KEY)); + assertTrue(detail.contains("Issue Date:")); + if (state.equals("defaulted")) { + assertTrue(detail.contains("in default")); + assertNull(menu.getItem(11)); + assertEquals("Resume Payments", name(menu.getItem(14))); + } + if (state.equals("paid")) { + assertTrue(detail.contains("paid off")); + assertNull(menu.getItem(11)); + assertNull(menu.getItem(14)); + } + if (state.equals("overdue")) { + assertTrue(detail.contains("overdue")); + assertTrue(detail.contains("Daily Penalty:")); + } + if (state.equals("interest")) assertTrue(detail.contains("unpaid interest")); + if (state.equals("resumed")) + assertTrue(lore(menu.getItem(14)).contains("already defaulted before")); + loans.loanDetailView(leader, lender, loan, false); + assertEquals( + state.equals("paid") ? "Settle Loan" : "Forgive Loan", name(top(leader).getItem(14))); + assertNotNull(top(leader).getItem(13)); + } + + @Test + void loanPaymentAndInterestButtonsExplainRealCreditConsequences() { + Loan early = loan("early", System.currentTimeMillis()); + assertEquals(0, CreditCalculator.calculatePayoffBonus(early)); + assertTrue(lore(loans.creator.createPayOffLoanButton(early)).contains("no effect")); + Loan mature = loan("mature", System.currentTimeMillis() - 29L * 86400000); + assertTrue(CreditCalculator.calculatePayoffBonus(mature) > 0); + assertTrue(lore(loans.creator.createPayOffLoanButton(mature)).contains("credit score bonus")); + mature.makePayment(mature.getTotalOwed(), false); + assertFalse(lore(loans.creator.createPayOffLoanButton(mature)).contains("credit score bonus")); + Loan tinyDebt = loan("tiny", System.currentTimeMillis() - 45L * 86400000); + tinyDebt.makePayment(999.99, false); + assertEquals(0, CreditCalculator.calculateDefaultPenalty(tinyDebt)); + assertTrue( + lore(loans.creator.createDefaultItem(tinyDebt)).contains("no effect on your credit score")); + } + + @Test + void borrowerActionsToggleAutopayAndDefaultWithoutDirectlyChangingBalances() { + Loan loan = loan("first", System.currentTimeMillis()); + double borrowerBalance = borrower.getBank().getWealth(), + lenderBalance = lender.getBank().getWealth(); + loans.loanDetailView(borrowerLeader, borrower, loan, true); + loans.click(fixture.ui.click(borrowerLeader, 11), top(borrowerLeader), borrowerLeader); + verify(navigation).setPayingLoan(borrowerLeader, loan); + loans.loanDetailView(borrowerLeader, borrower, loan, true); + loans.click(fixture.ui.click(borrowerLeader, 12), top(borrowerLeader), borrowerLeader); + assertFalse(loan.isAutoPay()); + loans.click(fixture.ui.click(borrowerLeader, 12), top(borrowerLeader), borrowerLeader); + assertTrue(loan.isAutoPay()); + int credit = borrower.getLoanHandler().getCreditScore(); + loans.click(fixture.ui.click(borrowerLeader, 14), top(borrowerLeader), borrowerLeader); + assertTrue(loan.hasDefaulted()); + assertFalse(loan.isAutoPay()); + assertTrue(borrower.getLoanHandler().getCreditScore() < credit); + int afterDefault = borrower.getLoanHandler().getCreditScore(); + loans.click(fixture.ui.click(borrowerLeader, 14), top(borrowerLeader), borrowerLeader); + assertFalse(loan.hasDefaulted()); + assertEquals(afterDefault, borrower.getLoanHandler().getCreditScore()); + assertEquals(borrowerBalance, borrower.getBank().getWealth()); + assertEquals(lenderBalance, lender.getBank().getWealth()); + } + + @Test + void lenderMayPauseResumeAndForgiveWithoutDebitingEitherBank() { + Loan loan = loan("first", System.currentTimeMillis()); + double total = lender.getBank().getWealth() + borrower.getBank().getWealth(); + loans.loanDetailView(leader, lender, loan, false); + loans.click(fixture.ui.click(leader, 13), top(leader), leader); + assertTrue(loan.isInterestPaused()); + assertEquals("Resume Interest", name(top(leader).getItem(13))); + loans.click(fixture.ui.click(leader, 13), top(leader), leader); + assertFalse(loan.isInterestPaused()); + loans.click(fixture.ui.click(leader, 14), top(leader), leader); + assertTrue(lender.getLoanHandler().getLoansGiven().isEmpty()); + assertTrue(borrower.getLoanHandler().getLoansTaken().isEmpty()); + assertEquals(SFGUI.LOANS_GIVEN_VIEW, ((SFInventoryHolder) top(leader).getHolder()).getType()); + assertEquals(total, lender.getBank().getWealth() + borrower.getBank().getWealth()); + } + + @ParameterizedTest + @ValueSource(ints = {11, 12, 14}) + void formerBorrowerLeaderCannotUseAnyStaleLoanAction(int slot) { + Loan loan = loan("first", System.currentTimeMillis()); + loans.loanDetailView(borrowerLeader, borrower, loan, true); + Inventory menu = top(borrowerLeader); + borrower.getFaction().setLeader("Successor"); + loans.click(fixture.ui.click(borrowerLeader, slot), menu, borrowerLeader); + assertTrue(loan.isAutoPay()); + assertFalse(loan.hasDefaulted()); + verify(navigation, never()).setPayingLoan(any(), any()); + assertEquals(1000, loan.getTotalOwed()); + } + + @ParameterizedTest + @ValueSource(ints = {13, 14}) + void formerLenderLeaderCannotPauseOrForgiveLoans(int slot) { + Loan loan = loan("first", System.currentTimeMillis()); + loans.loanDetailView(leader, lender, loan, false); + faction.setLeader("Successor"); + loans.click(fixture.ui.click(leader, slot), top(leader), leader); + assertFalse(loan.isInterestPaused()); + assertSame(loan, lender.getLoanHandler().getLoanById(loan.getId())); + } + + @Test + void staleAndUnrelatedLoanMenusAreIgnoredWithoutChangingTheLoan() { + Loan loan = loan("first", System.currentTimeMillis()); + loans.loansGivenView(leader, lender); + Inventory menu = top(leader); + lender.getLoanHandler().removeLoan(loan.getId()); + loans.click(fixture.ui.click(leader, 0), menu, leader); + assertSame(menu, top(leader)); + FactionManager.factions.remove(faction); + InventoryClickEvent removed = fixture.ui.click(leader, 0); + loans.click(removed, menu, leader); + assertTrue(removed.isCancelled()); + Inventory plain = fixture.ui.inventory(null, 9, "Ordinary"); + leader.openInventory(plain); + InventoryClickEvent unrelated = fixture.ui.click(leader, 0); + loans.click(unrelated, plain, leader); + assertFalse(unrelated.isCancelled()); + } + + @Test + void feeSelectionReflectsAllConfiguredBracketsAndPerVehicleOverrides() { + assertFalse(VehicleFeeView.anyChargeable(faction)); + for (FeeKind kind : FeeKind.values()) { + faction.getVehicleFeeHandler().applyBracket(kind, new Bracket(0, 100)); + faction.getVehicleFeeHandler().setRate(kind, null, 5); + faction.getVehicleFeeHandler().setRate(kind, "car", 10); + } + assertTrue(VehicleFeeView.anyChargeable(faction)); + fees.feeProposalView(leader, faction, null); + Inventory menu = top(leader); + for (int n = 0; n < FeeKind.values().length; n++) { + assertEquals(FeeKind.values()[n].name(), data(menu.getItem(n), Keys.STRING_KEY)); + assertTrue(lore(menu.getItem(n)).contains("Vehicles with their own rate: 1")); + assertTrue(lore(menu.getItem(n)).contains("The leader is exempt")); + } + faction.getVehicleFeeHandler().applyBracket(FeeKind.VEHICLE_TAX, new Bracket(0, 0)); + fees.feeProposalView(leader, faction, menu); + assertEquals("REGISTRATION_FEE", data(menu.getItem(0), Keys.STRING_KEY)); + assertNull(menu.getItem(2)); + } + + @ParameterizedTest + @EnumSource(FeeKind.class) + void feeMenusShowTheChosenUnitsChargeAndStartGeneralOrSpecificInput(FeeKind kind) { + faction.getVehicleFeeHandler().applyBracket(kind, new Bracket(0, 100)); + faction.getVehicleFeeHandler().setRate(kind, null, 5); + faction.getVehicleFeeHandler().setRate(kind, "car", 10); + scope(VehiclesConfigLoader.class) + .when(() -> VehiclesConfigLoader.getUpkeep(anyString())) + .thenReturn(20.0); + categories.add( + new VfBuildersCatalog.Category( + "cars", + null, + List.of( + new VfBuildersCatalog.Entry("car", null), + new VfBuildersCatalog.Entry("truck", null)))); + fees.feeProposalView(leader, faction, null); + fees.click(fixture.ui.click(leader, 0), top(leader), leader); + Inventory category = top(leader); + assertEquals(SFGUI.FEE_CATEGORY_VIEW, ((SFInventoryHolder) category.getHolder()).getType()); + assertEquals("*", data(category.getItem(0), Keys.STRING_KEY)); + assertTrue(lore(category.getItem(1)).contains("With their own rate: 1")); + fees.click(fixture.ui.click(leader, 1), category, leader); + Inventory vehicles = top(leader); + assertTrue(lore(vehicles.getItem(0)).contains("Own Rate: " + kind.formatRate(10))); + assertTrue(lore(vehicles.getItem(1)).contains("(general)")); + assertTrue( + lore(vehicles.getItem(0)) + .contains("Charge: " + (kind.isPercent() ? "2.00d per day" : "200.00d"))); + fees.click(fixture.ui.click(leader, 0), vehicles, leader); + verify(navigation).setChangingFee(faction, leader, kind, "car"); + verify(leader) + .sendMessage(contains(kind.isPercent() ? "percentage of upkeep" : "multiple of upkeep")); + fees.feeCategoryView(leader, faction, kind, null); + fees.click(fixture.ui.click(leader, 0), top(leader), leader); + verify(navigation).setChangingFee(faction, leader, kind, null); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void feeInputRechecksProposalConflictsAndThePlayersCurrentPermission(boolean conflict) { + FeeKind kind = FeeKind.VEHICLE_TAX; + faction.getVehicleFeeHandler().applyBracket(kind, new Bracket(0, 100)); + Player actor = conflict ? leader : fixture.player("Outsider"); + fees.feeCategoryView(actor, faction, kind, null); + if (conflict) { + Proposal active = new Proposal("Other", faction.getGovernment()); + active.setFeeProposal(new FeeChange(kind, null, 20)); + faction.getGovernment().propose(active); + } + fees.click(fixture.ui.click(actor, 0), top(actor), actor); + verify(navigation, never()).setChangingFee(any(), any(), any(), any()); + verify(actor).sendMessage(contains("cannot propose right now")); + fees.feeCategoryView(actor, faction, kind, top(actor)); + assertTrue(lore(top(actor).getItem(0)).contains("Another proposal is active")); + } + + @Test + void feeMenuCapacityAndRefreshDoNotLeaveStaleItemsOrOverwriteBackNavigation() { + FeeKind kind = FeeKind.TRANSFER_FEE; + for (int n = 0; n < 54; n++) { + List<VfBuildersCatalog.Entry> entries = new ArrayList<>(); + for (int j = 0; j < 55; j++) entries.add(new VfBuildersCatalog.Entry("vehicle-" + j, null)); + categories.add(new VfBuildersCatalog.Category("category-" + n, null, entries)); + } + fees.feeCategoryView(leader, faction, kind, null); + assertNotNull(top(leader).getItem(52)); + assertEquals(Material.BARRIER, top(leader).getItem(53).getType()); + fees.feeVehicleView(leader, faction, kind, "category-0", null); + Inventory vehicles = top(leader); + assertEquals("vehicle-52", data(vehicles.getItem(52), Keys.STRING_KEY)); + assertEquals(Material.BARRIER, vehicles.getItem(53).getType()); + categories.clear(); + categories.add( + new VfBuildersCatalog.Category( + "category-0", null, List.of(new VfBuildersCatalog.Entry("only", null)))); + fees.feeVehicleView(leader, faction, kind, "category-0", vehicles); + assertNull(vehicles.getItem(1)); + assertEquals("only", data(vehicles.getItem(0), Keys.STRING_KEY)); + } + + @Test + void vehicleFeeNavigationFallsBackSafelyForRemovedCategoriesAndInvalidKinds() { + fees.feeVehicleView(leader, faction, FeeKind.VEHICLE_TAX, "missing", null); + assertEquals(SFGUI.FEE_CATEGORY_VIEW, ((SFInventoryHolder) top(leader).getHolder()).getType()); + fees.back(leader, faction, (SFInventoryHolder) top(leader).getHolder()); + assertEquals(SFGUI.FEE_PROPOSAL_VIEW, ((SFInventoryHolder) top(leader).getHolder()).getType()); + fees.back(leader, faction, (SFInventoryHolder) top(leader).getHolder()); + verify(navigation.governmentView).proposalView(leader, faction, null); + fees.back( + leader, + faction, + new SFInventoryHolder(faction.getId(), SFGUI.FEE_VEHICLE_VIEW, "removed_kind:cars")); + assertEquals(SFGUI.FEE_PROPOSAL_VIEW, ((SFInventoryHolder) top(leader).getHolder()).getType()); + fees.back(leader, faction, new SFInventoryHolder(faction.getId(), SFGUI.FEE_VEHICLE_VIEW)); + fees.back(leader, faction, new SFInventoryHolder(faction.getId(), SFGUI.LOAN_MAIN_VIEW)); + assertEquals(SFGUI.FEE_PROPOSAL_VIEW, ((SFInventoryHolder) top(leader).getHolder()).getType()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "ordinary", + "empty", + "no_key", + "missing_faction", + "invalid_kind", + "missing_kind", + "invalid_vehicle_kind", + "unrelated_type" + }) + void staleOrUnrelatedFeeClicksNeverStartInputOrMutateAnyRate(String state) { + FeeKind kind = FeeKind.VEHICLE_TAX; + faction.getVehicleFeeHandler().applyBracket(kind, new Bracket(0, 100)); + faction.getVehicleFeeHandler().setRate(kind, null, 5); + fees.feeCategoryView(leader, faction, kind, null); + Inventory menu = top(leader); + ItemStack general = menu.getItem(0); + switch (state) { + case "ordinary" -> menu = fixture.ui.inventory(null, 9, "Other plugin"); + case "empty" -> menu.setItem(0, null); + case "no_key" -> menu.setItem(0, new ItemStack(Material.PAPER)); + case "missing_faction" -> FactionManager.factions.remove(faction); + case "invalid_kind", "missing_kind", "invalid_vehicle_kind", "unrelated_type" -> { + SFGUI type = + state.equals("invalid_vehicle_kind") + ? SFGUI.FEE_VEHICLE_VIEW + : state.equals("unrelated_type") ? SFGUI.LOAN_MAIN_VIEW : SFGUI.FEE_CATEGORY_VIEW; + menu = + fixture.ui.inventory( + new SFInventoryHolder( + faction.getId(), type, state.equals("missing_kind") ? null : "obsolete:cars"), + 54, + "Stale menu"); + menu.setItem(0, general); + } + } + leader.openInventory(menu); + InventoryClickEvent event = fixture.ui.click(leader, 0); + fees.click(event, menu, leader); + assertTrue(event.isCancelled()); + assertSame(menu, top(leader)); + assertEquals(5, faction.getVehicleFeeHandler().getRate(kind)); + verify(navigation, never()).setChangingFee(any(), any(), any(), any()); + } + + private String name(ItemStack item) { + return ChatColor.stripColor(item.getItemMeta().getDisplayName()); + } + + private String lore(ItemStack item) { + return ChatColor.stripColor(String.join("\n", item.getItemMeta().getLore())); + } + + private String data(ItemStack item, org.bukkit.NamespacedKey key) { + return item.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); + } + + private Loan loan(String id, long start) { + Loan loan = new Loan(id, 1000, lender, borrower, start, 30, 7, 2, true); + lender.getLoanHandler().issueLoan(loan); + return loan; + } + + private Inventory top(Player player) { + return player.getOpenInventory().getTopInventory(); + } + + private <T> MockedStatic<T> scope(Class<T> type) { + MockedStatic<T> scope = mockStatic(type); + scopes.add(scope); + return scope; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentMenusCoverageTest.java new file mode 100644 index 00000000..8f6fb44f --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentMenusCoverageTest.java @@ -0,0 +1,1477 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.enums.Member; +import net.tfminecraft.simplefactions.enums.Rules; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.enums.Scope; +import net.tfminecraft.simplefactions.enums.Stance; +import net.tfminecraft.simplefactions.espionage.CharacterNames; +import net.tfminecraft.simplefactions.espionage.EspionageService; +import net.tfminecraft.simplefactions.government.Council; +import net.tfminecraft.simplefactions.government.Government; +import net.tfminecraft.simplefactions.government.StabilityModifier; +import net.tfminecraft.simplefactions.government.election.Candidate; +import net.tfminecraft.simplefactions.government.election.Election; +import net.tfminecraft.simplefactions.government.handler.ProposalHandler; +import net.tfminecraft.simplefactions.government.movement.Action; +import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.government.movement.PoliticalAction; +import net.tfminecraft.simplefactions.government.movement.cause.Cause; +import net.tfminecraft.simplefactions.government.proposal.Proposal; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.government.stability.StabilityFacts; +import net.tfminecraft.simplefactions.government.stability.StabilityReport; +import net.tfminecraft.simplefactions.government.stability.StabilityStatus; +import net.tfminecraft.simplefactions.government.stability.StabilityTuning; +import net.tfminecraft.simplefactions.government.stability.StateStability; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.keys.Keys; +import net.tfminecraft.simplefactions.laws.CanHaveLaw; +import net.tfminecraft.simplefactions.laws.Law; +import net.tfminecraft.simplefactions.laws.LawEffect; +import net.tfminecraft.simplefactions.laws.LawGroup; +import net.tfminecraft.simplefactions.loaders.PoliticalActionLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.managers.RelationManager; +import net.tfminecraft.simplefactions.managers.SessionManager; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.handler.GuildHandler; +import net.tfminecraft.simplefactions.objects.handler.LawHandler; +import net.tfminecraft.simplefactions.objects.handler.TaxHandler; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import net.tfminecraft.simplefactions.utils.EconomicImpact; +import net.tfminecraft.simplefactions.utils.LoreWriter; +import net.tfminecraft.simplefactions.utils.Represents; +import net.tfminecraft.simplefactions.utils.Wealth; +import net.tfminecraft.simplefactions.war.civilwar.CivilWarHostMovementRules; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.resolution.CouncilPeaceQueries; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemCreator; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +class GovernmentMenusCoverageTest { + private GuiTestFixture ui; + private Player player; + private Faction faction; + private Government government; + private Council council; + private Election election; + private ProposalHandler proposals; + private LawHandler laws; + private GuildHandler guildHandler; + private TaxHandler taxes; + private InventoryManager manager; + private SessionManager sessions; + private GovernmentCreator creator; + private GovernmentView view; + private StabilityReport report; + private StabilityFacts facts; + private final List<MockedStatic<?>> mocks = new ArrayList<>(); + private final Map<String, Faction> factions = new LinkedHashMap<>(); + private final Map<String, Guild> guilds = new LinkedHashMap<>(); + private List<Faction> previousFactions; + private Map<String, String> previousIcons; + private Map<Scope, LawEffect> previousEffects; + private MockedStatic<FactionManager> factionLookup; + private MockedStatic<RelationManager> relations; + private MockedStatic<EspionageService> intelligence; + private MockedStatic<CouncilPeaceQueries> peace; + private MockedStatic<CanHaveLaw> canHaveLaw; + private MockedStatic<CivilWarHostMovementRules> hostRules; + private MockedStatic<LoreWriter> loreWriter; + private MockedStatic<VehicleFeeView> fees; + + private <T> MockedStatic<T> scoped(Class<T> type) { + MockedStatic<T> mocked = mockStatic(type); + mocks.add(mocked); + return mocked; + } + + @BeforeEach + void setup() { + ui = new GuiTestFixture(); + player = ui.player("Alice"); + previousFactions = new ArrayList<>(FactionManager.factions); + previousIcons = new HashMap<>(Cache.icons); + previousEffects = new HashMap<>(Cache.baseEffects); + FactionManager.factions.clear(); + Cache.icons.clear(); + Cache.baseEffects.clear(); + factionLookup = scoped(FactionManager.class); + factionLookup + .when(() -> FactionManager.getByString(anyString())) + .thenAnswer(call -> factions.get(call.getArgument(0))); + factionLookup + .when(() -> FactionManager.getGuildByString(anyString())) + .thenAnswer(call -> guilds.get(call.getArgument(0))); + relations = scoped(RelationManager.class); + intelligence = scoped(EspionageService.class); + intelligence.when(() -> EspionageService.canViewExact(any(), any())).thenReturn(true); + peace = scoped(CouncilPeaceQueries.class); + peace + .when(() -> CouncilPeaceQueries.isWarEndAction(any())) + .thenAnswer( + call -> Set.of(Action.WHITE_PEACE, Action.SURRENDER).contains(call.getArgument(0))); + canHaveLaw = scoped(CanHaveLaw.class); + hostRules = scoped(CivilWarHostMovementRules.class); + loreWriter = scoped(LoreWriter.class); + scoped(EconomicImpact.class); + fees = scoped(VehicleFeeView.class); + scoped(Represents.class) + .when(() -> Represents.represents(any(), anyString())) + .thenReturn("Artisans"); + MockedStatic<Wealth> wealth = scoped(Wealth.class); + wealth.when(() -> Wealth.wealth(anyString())).thenReturn(125.5); + wealth + .when(() -> Wealth.topWealth(any(), eq(true))) + .thenReturn(List.of("Alice", "Bob", "Cara")); + scoped(CharacterNames.class) + .when(() -> CharacterNames.display(any(), anyString(), any())) + .thenAnswer(call -> call.getArgument(1)); + scoped(PoliticalActionLoader.class) + .when(() -> PoliticalActionLoader.getByAction(any())) + .thenAnswer(call -> new PoliticalAction(call.getArgument(0))); + ItemAPI itemApi = mock(ItemAPI.class); + ItemCreator items = mock(ItemCreator.class); + when(itemApi.getCreator()).thenReturn(items); + when(items.getItemFromPath(anyString())).thenAnswer(call -> new ItemStack(Material.PAPER)); + when(items.getItemsAdderItem(anyString())).thenAnswer(call -> new ItemStack(Material.PAPER)); + scoped(TLibs.class).when(TLibs::getItemAPI).thenReturn(itemApi); + faction = faction("home", "Home"); + government = mock(Government.class); + council = mock(Council.class); + election = mock(Election.class); + proposals = mock(ProposalHandler.class); + laws = mock(LawHandler.class); + guildHandler = mock(GuildHandler.class); + taxes = new TaxHandler(faction, 5, 10, 20, 15, 30); + when(faction.getGovernment()).thenReturn(government); + when(faction.getLawHandler()).thenReturn(laws); + when(faction.getGuildHandler()).thenReturn(guildHandler); + when(faction.getTaxHandler()).thenReturn(taxes); + when(faction.getTaxRate(any(), nullable(String.class), anyBoolean())) + .thenAnswer( + call -> + taxes.getTaxRate(call.getArgument(0), call.getArgument(1), call.getArgument(2))); + when(faction.getLeader()).thenReturn("Alice"); + when(faction.isLeader(anyString())) + .thenAnswer(call -> faction.getLeader().equalsIgnoreCase(call.getArgument(0))); + when(faction.getRulerTitle()).thenReturn("Governor"); + when(faction.getGovernmentString()).thenReturn("Republic"); + when(faction.getMembers()).thenAnswer(call -> new ArrayList<>(List.of("Alice", "Bob", "Cara"))); + when(government.getFaction()).thenReturn(faction); + when(government.getCouncil()).thenReturn(council); + when(government.getElection()).thenReturn(election); + when(government.getTaxEfficiency()).thenReturn(0.8); + when(government.getPower()).thenReturn(100.0); + when(government.getMaxPower()).thenReturn(200.0); + when(government.getPowerGain()).thenReturn(3.5); + when(government.hasCouncil()).thenReturn(true); + when(government.canProposeOrStartMovement(player)).thenReturn(true); + when(government.canBeProposed(any())).thenReturn(true); + when(council.getType()).thenReturn(Rules.APPOINTED_COUNCIL); + when(council.getMaxSize()).thenReturn(3); + when(council.getProposalHandler()).thenReturn(proposals); + when(election.getPreviousVotes()) + .thenReturn(Map.of(Candidate.LEADER, Map.of(), Candidate.COUNCIL, Map.of())); + report = new StabilityReport(); + report.stability = 100; + report.legitimacy = 80; + when(government.stateReport()).thenReturn(report); + facts = new StabilityFacts(); + scoped(StateStability.class).when(() -> StateStability.facts(faction)).thenReturn(facts); + scoped(StabilityTuning.class).when(StabilityTuning::get).thenReturn(StabilityTuning.DEFAULTS); + sessions = mock(SessionManager.class); + when(ui.plugin.getSessionManager()).thenReturn(sessions); + manager = mock(InventoryManager.class); + when(manager.createBackButton(any())).thenAnswer(call -> new ItemStack(Material.BARRIER)); + manager.movementView = mock(MovementView.class); + manager.vehicleFeeView = mock(VehicleFeeView.class); + creator = new GovernmentCreator(); + view = new GovernmentView(manager); + } + + @AfterEach + void cleanup() { + for (int n = mocks.size() - 1; n >= 0; n--) mocks.get(n).close(); + FactionManager.factions.clear(); + FactionManager.factions.addAll(previousFactions); + Cache.icons.clear(); + Cache.icons.putAll(previousIcons); + Cache.baseEffects.clear(); + Cache.baseEffects.putAll(previousEffects); + ui.close(); + } + + @ParameterizedTest + @EnumSource( + value = TaxTarget.class, + names = {"GUILD_ID", "VASSAL_ID", "TARIFF_ID"}) + void specificTaxCardsShowTheStoredOverrideAndTheDifferentBaseRate(TaxTarget target) { + Guild guild = guild("target", "Artisans"); + Faction other = faction("target", "Neighbour"); + taxes.setTaxRate(target, "target", 40); + + ItemStack item = creator.createSpecificTaxItem(player, faction, "target", target); + + String lore = lore(item); + assertTrue(lore.contains("Current Rate: 40.0%"), lore); + double base = target == TaxTarget.GUILD_ID ? 10 : target == TaxTarget.VASSAL_ID ? 20 : 30; + assertTrue(lore.contains("Base Rate: " + base + "%"), lore); + assertEquals("target", data(item, Keys.STRING_KEY)); + assertEquals(target.name(), data(item, Keys.SECONDARY_STRING_KEY)); + } + + @Test + void councilMeetingClickRevalidatesLeadershipAfterTheMenuWasOpened() { + when(council.canHostSession()).thenReturn(true); + when(council.hasEnoughValidVoters()).thenReturn(true); + view.governmentView(player, faction, null); + Inventory menu = top(); + assertTrue(name(menu.getItem(23)).contains("Start Council Meeting")); + when(faction.getLeader()).thenReturn("Bob"); + + InventoryClickEvent click = ui.click(player, 23); + view.click(click, menu, player); + + assertTrue(click.isCancelled()); + verify(sessions, never()).newSession(any(), any()); + } + + @Test + void councilMeetingClickRevalidatesWhetherTheCouncilCanStillHost() { + when(council.canHostSession()).thenReturn(true); + when(council.hasEnoughValidVoters()).thenReturn(true); + view.governmentView(player, faction, null); + when(council.canHostSession()).thenReturn(false); + click(23); + verifyNoInteractions(sessions); + verify(player, never()).closeInventory(); + } + + @Test + void governmentSummaryDisplaysRulerElectionRulesAndSignedAdministrativePower() { + ItemStack summary = creator.createGovernmentItem(faction); + assertEquals("Government:", name(summary)); + assertTrue(lore(summary).contains("Governor: Alice")); + assertTrue(lore(summary).contains("100.0/200.0")); + assertTrue(lore(summary).contains("+3.5")); + when(government.getPower()).thenReturn(-5.0); + when(government.getMaxPower()).thenReturn(-1.0); + when(government.getPowerGain()).thenReturn(-2.0); + when(government.hasLeaderElections()).thenReturn(true); + String negative = lore(creator.createGovernmentItem(player, faction)); + assertTrue(negative.contains("-5.0/-1.0")); + assertTrue(negative.contains("-2.0")); + assertTrue(negative.contains("Leader Elections: ✔")); + } + + @Test + void councilSummaryReportsSeatsAndRepresentedMembers() { + assertTrue(lore(creator.createCouncilItem(faction)).contains("Council Size: 0/3")); + when(council.getCurrentSize()).thenReturn(2); + when(government.getCouncilMembers()).thenReturn(List.of("Bob", "Cara")); + when(government.hasCouncilElections()).thenReturn(true); + String summary = lore(creator.createCouncilItem(faction)); + assertTrue(summary.contains("Council Size: 2/3")); + assertTrue(summary.contains("Council Elections: ✔")); + assertTrue(summary.contains("Bob (Artisans)")); + assertTrue(summary.contains("Cara (Artisans)")); + } + + @Test + void stabilitySummaryKeepsValidModifiersWhenThePublicModifierListContainsANullEntry() { + when(government.getStabilityModifiers()) + .thenReturn( + java.util.Arrays.asList( + new StabilityModifier("Victory", 2, 1), + null, + new StabilityModifier("Unrest", -3, 1))); + String summary = lore(creator.createStabilityItem(faction)); + assertTrue(summary.contains("Victory: +2%")); + assertTrue(summary.contains("Unrest: -3%")); + assertFalse(summary.contains("null")); + } + + @Test + void electionCardsShowActiveTimingAndPreviousResultsWithEligibilityAndVoteShares() { + when(government.hasElection()).thenReturn(true); + when(government.getTimeUntilElectionEnds()).thenReturn("2 days"); + assertTrue(lore(creator.createElectionItem(player, faction)).contains("Ends in: 2 days")); + when(government.hasElection()).thenReturn(false); + when(government.getTimeUntilNextElection()).thenReturn("3 days"); + when(government.getLastElectionString()).thenReturn("Yesterday"); + when(government.hasLeaderElections()).thenReturn(true); + when(government.hasCouncilElections()).thenReturn(true); + when(council.getType()).thenReturn(Rules.ELECTED_COUNCIL); + when(faction.canBecomeLeader("Bob")).thenReturn(true); + when(council.isMember("Alice")).thenReturn(true); + when(council.canBeMember("Bob", true, false)).thenReturn(true); + when(election.getPreviousVotes()) + .thenReturn( + Map.of( + Candidate.LEADER, Map.of("Alice", 6, "Bob", 3, "Cara", 1), + Candidate.COUNCIL, Map.of("Alice", 0, "Bob", 0, "Cara", 0))); + when(election.getWinners(any())).thenReturn(List.of("Alice", "Bob", "Cara")); + String summary = lore(creator.createElectionItem(player, faction)); + assertTrue(summary.contains("Next Election: 3 days")); + assertTrue(summary.contains("Last Election: Yesterday")); + assertTrue(summary.contains("Leader Results")); + assertTrue(summary.contains("Council Results")); + assertTrue(summary.contains("[1] Alice (60%)")); + assertTrue(summary.contains("[2] Bob (30%)")); + assertTrue(summary.contains("[3] Cara (10%)")); + assertTrue(summary.contains("[1] Alice (0%)")); + } + + @ParameterizedTest + @ValueSource(doubles = {0.0, 40.0, 60.0, 80.0, 100.0}) + void stabilityCardsDescribeActualScoresAndTheirConsequences(double score) { + report.stability = score; + report.legitimacy = score; + report.status = StabilityStatus.fromStability(score); + report.illegitimate = score < 50; + report.weakStateMalus = score == 0 ? 5.25 : 0; + report.requiredLevels = 12.5; + report.overextension = score == 0 ? 3 : 0; + facts.bankrupt = score == 0; + when(government.getStabilityModifiers()) + .thenReturn( + List.of( + new StabilityModifier("Victory", 2, 1), new StabilityModifier("Unrest", -3, 1))); + String stability = lore(creator.createStabilityItem(faction)); + assertTrue(name(creator.createStabilityItem(faction)).contains(report.status.getLabel())); + assertTrue(stability.contains("Victory: +2%")); + assertTrue(stability.contains("Unrest: -3%")); + if (score == 0) { + assertTrue(stability.contains("State needs to be size 12.5")); + assertTrue(stability.contains("Provinces: -3")); + assertTrue(stability.contains("State is bankrupt")); + assertTrue(stability.contains("Offensive War: Locked")); + assertTrue(stability.contains("Title Formation: Locked")); + assertTrue(stability.contains("Movement Organization: 2x")); + } + String legitimacy = lore(creator.createLegitimacyItem(faction)); + assertTrue( + legitimacy.contains(score < 50 ? "Illegitimate" : score < 65 ? "Contested" : "Legitimate")); + assertTrue(legitimacy.contains("Stability:")); + } + + @Test + void legitimacyListsGuildAndVassalStancesWithoutColorCodesInNames() { + facts.guilds.add(body("Realm", 10, "SUPPORT", true)); + facts.guilds.add(body("§aArtisans", 5, "OPPOSE", false)); + facts.guilds.add(body("Farmers", 5, "NEUTRAL", false)); + facts.guilds.add(body("", 2, "SUPPORT", false)); + facts.vassals.add(body("Neighbour", 8, "SUPPORT", false)); + String text = lore(creator.createLegitimacyItem(faction)); + assertTrue(text.contains("Artisans: Oppose")); + assertTrue(text.contains("Farmers: Neutral")); + assertTrue(text.contains("A guild: Support")); + assertTrue(text.contains("Neighbour (Vassal): Support")); + assertFalse(text.contains("Realm:")); + } + + @ParameterizedTest + @EnumSource(Stance.class) + void stanceCardsEncodeTheGuildAndUseAColorMatchingItsStance(Stance stance) { + Guild guild = guild("artisans", "Artisans"); + when(guild.getStance(faction)).thenReturn(stance); + ItemStack item = creator.createStanceItem(faction, guild); + Material expected = + stance == Stance.OPPOSE + ? Material.RED_CONCRETE + : stance == Stance.SUPPORT ? Material.GREEN_CONCRETE : Material.YELLOW_CONCRETE; + assertEquals(expected, item.getType()); + assertEquals("artisans", data(item, Keys.STRING_KEY)); + assertTrue(lore(item).contains("Click to change")); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void councilRefusalCardsExplainTheCurrentChoice(boolean refuses) { + when(council.refuses("Alice")).thenReturn(refuses); + ItemStack item = creator.createToggleRefuseButton(player, faction); + assertEquals("home", data(item, Keys.STRING_KEY)); + assertTrue(lore(item).contains(refuses ? "refusing to join" : "open to joining")); + } + + @ParameterizedTest + @EnumSource(TaxTarget.class) + void taxCategoryCardsEncodeTheTargetAndOnlyOfferAvailableProposals(TaxTarget target) { + ItemStack available = creator.createTaxTypeItem(player, faction, target, true); + assertEquals(target.getDisplayName(), name(available)); + assertEquals(target.name(), data(available, Keys.STRING_KEY)); + boolean specific = + Set.of(TaxTarget.GUILD_ID, TaxTarget.VASSAL_ID, TaxTarget.TARIFF_ID).contains(target); + assertTrue(lore(available).contains(specific ? "Click to view options" : "Current Rate:")); + ItemStack readOnly = creator.createTaxTypeItem(player, faction, target, false); + assertTrue(lore(readOnly).contains(specific ? "specific rates" : "Current Rate:")); + when(government.canBeProposed(any())).thenReturn(false); + ItemStack unavailable = creator.createTaxTypeItem(player, faction, target, true); + if (!specific) assertTrue(lore(unavailable).contains("Another proposal is active")); + } + + @ParameterizedTest + @EnumSource( + value = TaxTarget.class, + names = {"GUILD_ID", "VASSAL_ID", "TARIFF_ID"}) + void specificTaxCardsHandleAbsentTargetsAndInheritedRates(TaxTarget target) { + assertNull(creator.createSpecificTaxItem(player, faction, "missing", target)); + guild("target", "Artisans"); + faction("target", "Neighbour"); + when(government.canProposeOrStartMovement(player)).thenReturn(false); + ItemStack card = creator.createSpecificTaxItem(player, faction, "target", target); + assertTrue(lore(card).contains("No specific")); + assertTrue(lore(card).contains("Another proposal is active")); + } + + @Test + void proposalEntryDistinguishesCouncilProposalsFromMovementsAndExplainsUnavailableActions() { + when(government.isCouncilMember(player)).thenReturn(true); + when(council.getCurrentProposals("Alice")).thenReturn(1); + ItemStack proposal = creator.createProposalItem(player, faction); + assertEquals("New Proposal", name(proposal)); + assertTrue(lore(proposal).contains("1/2")); + assertTrue(lore(proposal).contains("Click to start")); + when(government.isCouncilMember(player)).thenReturn(false); + when(government.getMovementByMember("Alice")).thenReturn(mock(Movement.class)); + ItemStack movement = creator.createProposalItem(player, faction); + assertEquals("New Movement", name(movement)); + assertTrue(lore(movement).contains("Potential Civil War")); + assertTrue(lore(movement).contains("cannot start")); + when(proposals.getProposals()).thenReturn(List.of(mock(Proposal.class))); + when(government.getMovements()).thenReturn(List.of(mock(Movement.class), mock(Movement.class))); + assertTrue( + lore(creator.createProposalListItem(player, faction)).contains("Active Proposals: 1")); + assertTrue( + lore(creator.createMovementListItem(player, faction)).contains("Active Movements: 2")); + } + + @ParameterizedTest + @EnumSource( + value = Rules.class, + names = {"APPOINTED_COUNCIL", "WEALTH_BASED_COUNCIL", "ELECTED_COUNCIL"}) + void councilSeatsShowTheirMemberAndEnforceSequentialAppointmentInTheirPresentation(Rules type) { + when(council.getType()).thenReturn(type); + when(council.getMembers()).thenReturn(List.of("Bob")); + ItemStack occupied = creator.createCouncilMemberItem(player, faction, 0); + assertEquals(Material.PLAYER_HEAD, occupied.getType()); + assertEquals("Bob", name(occupied)); + assertEquals("Bob", data(occupied, Keys.STRING_KEY)); + assertTrue(lore(occupied).contains("125.5d")); + assertTrue(lore(occupied).contains("Ranking: 2/3")); + assertTrue(lore(occupied).contains("Click to replace")); + assertEquals( + Material.GREEN_CONCRETE, creator.createCouncilMemberItem(player, faction, 1).getType()); + ItemStack tooFar = creator.createCouncilMemberItem(player, faction, 2); + assertEquals(Material.RED_CONCRETE, tooFar.getType()); + assertTrue(lore(tooFar).contains("Must fill seats in order")); + ItemStack outsider = creator.createCouncilMemberItem(ui.player("Visitor"), faction, 0); + assertFalse(lore(outsider).contains("Click to replace")); + ItemStack candidate = creator.createPotentialMemberItem(player, faction, "Cara", 1); + assertEquals("Cara", data(candidate, Keys.STRING_KEY)); + assertEquals( + 1, + candidate + .getItemMeta() + .getPersistentDataContainer() + .get(Keys.INT, PersistentDataType.INTEGER)); + assertTrue(lore(candidate).contains("Ranking: 3/3")); + } + + @ParameterizedTest + @ValueSource(strings = {"law", "tax", "fee", "political"}) + void proposalTypeCardsClearlyDescribeTheirAction(String type) { + ItemStack item = creator.createProposalTypeItem(type); + assertTrue(name(item).toLowerCase().contains(type.equals("fee") ? "vehicle fee" : type)); + assertTrue(lore(item).contains("Create a proposal")); + Proposal proposal = mock(Proposal.class); + when(proposal.isLawProposal()).thenReturn(type.equals("law")); + when(proposal.isFeeProposal()).thenReturn(type.equals("fee")); + when(proposal.getProposer()).thenReturn("Bob"); + ItemStack current = creator.createCurrentProposalItem(player, faction, proposal); + assertTrue(lore(current).contains("Proposed by: Bob")); + loreWriter.verify( + () -> + LoreWriter.applyProposalLore(eq(proposal), anyList(), eq(player), eq(faction), any())); + } + + @Test + void politicalAndWarCardsPreserveTheActionAndSpecificWarId() { + ItemStack action = creator.createPoliticalProposalTypeItem(player, faction, Action.WHITE_PEACE); + assertEquals("WHITE_PEACE", data(action, Keys.STRING_KEY)); + assertTrue(lore(action).contains("White Peace")); + War war = mock(War.class); + when(war.getId()).thenReturn(42); + when(war.getName()).thenReturn("Border War"); + ItemStack target = creator.createWarPeaceSelectItem(war, Action.SURRENDER); + assertEquals("Border War", name(target)); + assertEquals("42", data(target, Keys.STRING_KEY)); + assertTrue(lore(target).contains("Surrender")); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void favourAndRepressMenusExplainGuildAndVassalEligibilityAndCurrentState(boolean favour) { + Guild guild = guild("artisans", "Artisans"); + Faction vassal = faction("vassal", "Neighbour"); + when(vassal.getOrCreateMainGuild()).thenReturn(guild); + when(guild.getRepressFavourCost()).thenReturn(4.0); + assertEquals("Favour & Repress", name(creator.createFavourRepressEntryButton())); + assertEquals("Favour", name(creator.createFavourButton())); + assertEquals("Repress", name(creator.createRepressButton())); + Scope guildScope = favour ? Scope.FAVOURED_GUILDS : Scope.REPRESSED_GUILDS; + Scope vassalScope = favour ? Scope.FAVOURED_VASSALS : Scope.REPRESSED_VASSALS; + Cache.baseEffects.put(guildScope, mock(LawEffect.class)); + Cache.baseEffects.put(vassalScope, mock(LawEffect.class)); + assertTrue(lore(creator.createGuildsTypeButton(favour)).contains("Click to view guilds")); + assertTrue(lore(creator.createVassalsTypeButton(favour)).contains("Click to view vassals")); + loreWriter.verify(() -> LoreWriter.writeEffect(eq(guildScope), any(), anyList())); + loreWriter.verify(() -> LoreWriter.writeEffect(eq(vassalScope), any(), anyList())); + when(government.canFavour(guild)).thenReturn(favour); + when(government.canRepress(guild)).thenReturn(!favour); + for (boolean active : new boolean[] {false, true}) { + when(guild.isFavoured()).thenReturn(favour && active); + when(guild.isRepressed()).thenReturn(!favour && active); + ItemStack guildCard = creator.createFavourRepressGuildItem(player, faction, guild, favour); + ItemStack vassalCard = creator.createFavourRepressVassalItem(player, faction, vassal, favour); + assertEquals("artisans", data(guildCard, Keys.STRING_KEY)); + assertEquals("vassal", data(vassalCard, Keys.STRING_KEY)); + for (ItemStack card : List.of(guildCard, vassalCard)) { + assertTrue(lore(card).contains("Click to toggle")); + assertEquals(!active, lore(card).contains("Upkeep: 4.0 Administrative Power")); + assertTrue( + lore(card).contains(active ? "Currently" : favour ? "Not Favoured" : "Not Repressed")); + } + } + when(government.canFavour(guild)).thenReturn(false); + when(government.canRepress(guild)).thenReturn(false); + for (boolean opposing : new boolean[] {false, true}) { + when(guild.isFavoured()).thenReturn(!favour && opposing); + when(guild.isRepressed()).thenReturn(favour && opposing); + String blockedGuild = + lore(creator.createFavourRepressGuildItem(player, faction, guild, favour)); + String blockedVassal = + lore(creator.createFavourRepressVassalItem(player, faction, vassal, favour)); + assertTrue(blockedGuild.contains(favour ? "Cannot favour" : "Cannot repress")); + assertTrue(blockedVassal.contains(favour ? "Cannot favour" : "Cannot repress")); + if (opposing) { + assertTrue(blockedGuild.contains(favour ? "Guild is repressed" : "Guild is favoured")); + assertTrue(blockedVassal.contains(favour ? "Vassal is repressed" : "Vassal is favoured")); + } + } + } + + @Test + void governmentMenuRefreshesOnlyAvailableControlsWithoutOpeningAnotherInventory() { + Guild guild = guild("artisans", "Artisans"); + factionLookup.when(() -> FactionManager.getGuildByMember("Alice")).thenReturn(guild); + when(government.canAffectStability(guild)).thenReturn(true); + when(council.canBeMember("Alice", true, true)).thenReturn(true); + when(council.canHostSession()).thenReturn(true); + when(government.hasElections()).thenReturn(true); + when(faction.hasFactionRule(Rules.CAN_FAVOUR)).thenReturn(true); + view.governmentView(player, faction, null); + Inventory menu = top(); + assertMenu(SFGUI.GOVERNMENT_VIEW); + for (int slot : new int[] {10, 11, 12, 13, 14, 15, 16, 21, 23, 24, 28, 33, 53}) { + assertNotNull(menu.getItem(slot), "slot " + slot); + } + assertEquals("artisans", data(menu.getItem(28), Keys.STRING_KEY)); + when(government.canAffectStability(guild)).thenReturn(false); + when(council.canHostSession()).thenReturn(false); + when(government.hasElections()).thenReturn(false); + view.governmentView(player, faction, menu); + assertNull(menu.getItem(23)); + assertNull(menu.getItem(14)); + assertNull(menu.getItem(28)); + verify(player, times(1)).openInventory(any(Inventory.class)); + } + + @Test + void reportedGovernmentViewDelegatesBothItsInitialOpenAndRefresh() { + intelligence.when(() -> EspionageService.canViewExact(player, faction)).thenReturn(false); + Inventory reported = + ui.inventory( + new SFInventoryHolder("home", SFGUI.GOVERNMENT_VIEW), 54, "Reported Government"); + MockedStatic<ReportedMenus> reports = scoped(ReportedMenus.class); + reports + .when( + () -> ReportedMenus.open(player, "home", SFGUI.GOVERNMENT_VIEW, 54, "Government View")) + .thenReturn(reported); + view.governmentView(player, faction, null); + view.governmentView(player, faction, reported); + assertSame(reported, top()); + reports.verify(() -> ReportedMenus.government(reported, player, faction, manager), times(2)); + verify(player, times(1)).openInventory(reported); + } + + @Test + void councilSelectionListsOnlyEligibleCandidatesAndPreservesTheFactionRoster() { + when(council.getMembers()).thenReturn(List.of("Bob")); + when(faction.getVassalMembers()).thenReturn(List.of("Dana")); + when(council.canBeMember("Alice", true, false)).thenReturn(true); + when(council.canBeMember("Dana", true, false)).thenReturn(true); + view.councilView(player, faction, null); + Inventory seats = top(); + assertEquals("Bob", data(seats.getItem(10), Keys.STRING_KEY)); + assertNotNull(seats.getItem(12)); + assertNull(seats.getItem(13)); + click(10); + assertMenu(SFGUI.COUNCIL_SELECT); + Inventory candidates = top(); + assertEquals("Alice", data(candidates.getItem(0), Keys.STRING_KEY)); + assertEquals("Dana", data(candidates.getItem(1), Keys.STRING_KEY)); + assertNull(candidates.getItem(2)); + view.councilSelect(player, faction, candidates, 0); + assertEquals(List.of("Alice", "Bob", "Cara"), faction.getMembers()); + click(1); + verify(council).replaceMember(0, "Dana"); + assertMenu(SFGUI.COUNCIL_VIEW); + view.councilView(player, faction, top()); + } + + @Test + void nextCouncilSeatAppointsWhileGapsAndLostLeadershipDoNot() { + when(council.canBeMember(anyString(), eq(true), eq(false))).thenReturn(true); + view.councilView(player, faction, null); + Inventory seats = top(); + click(11); + assertSame(seats, top()); + verify(player).sendMessage("§cYou must fill council seats in order!"); + click(10); + click(1); + verify(council).addMember("Bob"); + view.councilSelect(player, faction, null, 2); + click(0); + verify(council, times(1)).addMember(anyString()); + view.councilSelect(player, faction, null, 0); + when(faction.getLeader()).thenReturn("Bob"); + click(0); + verify(council, times(1)).addMember(anyString()); + view.councilView(player, faction, null); + Inventory denied = top(); + click(10); + assertSame(denied, top()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void candidateWhoOptsOutAfterSelectionOpensCannotBeAppointedOrReplaceAMember(boolean replacing) { + when(council.getMembers()).thenReturn(replacing ? List.of("Bob") : List.of()); + when(council.canBeMember("Cara", true, false)).thenReturn(true); + view.councilSelect(player, faction, null, 0); + assertEquals("Cara", data(top().getItem(0), Keys.STRING_KEY)); + when(council.canBeMember("Cara", true, false)).thenReturn(false); + click(0); + verify(council, never()).addMember(anyString()); + verify(council, never()).replaceMember(anyInt(), anyString()); + } + + @Test + void governmentEntryButtonsRespectCurrentProposalAndCouncilState() { + when(council.canBeMember("Alice", true, true)).thenReturn(true); + when(council.canHostSession()).thenReturn(true); + view.governmentView(player, faction, null); + Inventory menu = top(); + when(government.canProposeOrStartMovement(player)).thenReturn(false); + click(15); + assertSame(menu, top()); + when(government.canProposeOrStartMovement(player)).thenReturn(true); + when(government.getMovementByMember("Alice")).thenReturn(mock(Movement.class)); + click(15); + assertSame(menu, top()); + when(government.getMovementByMember("Alice")).thenReturn(null); + click(15); + assertMenu(SFGUI.PROPOSAL_VIEW); + view.governmentView(player, faction, null); + click(21); + verify(council).toggleRefuse("Alice"); + click(33); + verify(manager).movementListView(player, faction, null); + click(24); + assertMenu(SFGUI.PROPOSALS); + assertTrue(click(0).isCancelled()); + view.governmentView(player, faction, null); + when(government.hasCouncil()).thenReturn(false); + menu = top(); + click(13); + assertSame(menu, top()); + click(23); + verifyNoInteractions(sessions); + when(government.hasCouncil()).thenReturn(true); + when(council.canHostSession()).thenReturn(true); + click(23); + verifyNoInteractions(sessions); + verify(player).sendMessage(contains("At least 75%")); + when(council.hasEnoughValidVoters()).thenReturn(true); + click(23); + verify(sessions).newSession(player, faction); + verify(player).closeInventory(); + } + + @Test + void councilEntryNavigatesToTheCurrentCouncilRoster() { + view.governmentView(player, faction, null); + click(13); + assertMenu(SFGUI.COUNCIL_VIEW); + assertEquals(Material.GREEN_CONCRETE, top().getItem(10).getType()); + assertEquals(Material.BARRIER, top().getItem(53).getType()); + } + + @Test + void stanceToggleUsesItsEncodedGuildAndRefreshesTheCurrentMenu() { + Guild guild = guild("artisans", "Artisans"); + when(guild.isLeader(player)).thenReturn(true); + factionLookup.when(() -> FactionManager.getGuildByMember("Alice")).thenReturn(guild); + when(government.canAffectStability(guild)).thenReturn(true); + view.governmentView(player, faction, null); + Inventory menu = top(); + click(28); + verify(guild).switchStance(); + assertSame(menu, top()); + guilds.remove("artisans"); + click(28); + verify(guild, times(1)).switchStance(); + } + + @Test + void proposalCategoriesAndListsShowActualAvailableChoicesAndRefreshInPlace() { + fees.when(() -> VehicleFeeView.anyChargeable(faction)).thenReturn(true); + view.proposalView(player, faction, null); + Inventory choices = top(); + assertNotNull(choices.getItem(2)); + assertNotNull(choices.getItem(3)); + click(3); + verify(manager.vehicleFeeView).feeProposalView(player, faction, null); + click(2); + assertMenu(SFGUI.POLITICAL_PROPOSAL_VIEW); + when(government.canPropose(player)).thenReturn(true); + when(government.canProposeOrStartMovement(player)).thenReturn(false); + peace.when(() -> CouncilPeaceQueries.isParticipatingInAny(faction)).thenReturn(true); + view.proposalView(player, faction, choices); + assertNotNull(choices.getItem(2)); + peace.when(() -> CouncilPeaceQueries.isParticipatingInAny(faction)).thenReturn(false); + view.proposalView(player, faction, choices); + assertNull(choices.getItem(2)); + Proposal pending = new Proposal("Bob", government); + pending.setPoliticalActionProposal(new PoliticalAction(Action.INDEPENDENCE)); + when(proposals.getProposals()).thenReturn(List.of(pending)); + view.proposalList(player, faction, null); + Inventory list = top(); + assertTrue(lore(list.getItem(0)).contains("Proposed by: Bob")); + assertEquals(Material.BARRIER, list.getItem(26).getType()); + view.proposalList(player, faction, list); + assertNull(list.getItem(1)); + } + + @ParameterizedTest + @EnumSource(TaxTarget.class) + void taxClicksOpenSpecificTargetsOrBeginTheCorrectChatEdit(TaxTarget target) { + Inventory menu = menu(SFGUI.TAX_PROPOSAL_VIEW, 18); + menu.setItem(0, creator.createTaxTypeItem(player, faction, target, true)); + click(0); + if (Set.of(TaxTarget.GUILD_ID, TaxTarget.VASSAL_ID, TaxTarget.TARIFF_ID).contains(target)) { + assertMenu(SFGUI.SPECIFIC_TAX_PROPOSAL_VIEW); + assertEquals(target.name(), holder().getSecondaryId()); + verify(manager, never()).setChanging(any(), any(), any(), any()); + } else { + verify(manager).setChanging(faction, player, target, "all"); + verify(player) + .sendTitle(eq("§aTax Change"), contains(target.getDisplayName()), eq(20), eq(40), eq(20)); + verify(player).closeInventory(); + } + } + + @ParameterizedTest + @EnumSource( + value = TaxTarget.class, + names = {"GUILD_ID", "VASSAL_ID", "TARIFF_ID"}) + void specificTaxMenusFilterTheRosterAndSelectTheTargetIdentity(TaxTarget target) { + Guild base = guild("base", "Home"); + when(base.isBase()).thenReturn(true); + Guild member = guild("artisans", "Artisans"); + Faction subject = faction("subject", "Neighbour"); + Faction sameRealm = faction("realm", "Realm Ally"); + relations.when(() -> RelationManager.sameRealm(sameRealm, faction)).thenReturn(true); + relations.when(() -> RelationManager.getSubjects(faction)).thenReturn(List.of(subject)); + when(guildHandler.getGuilds()).thenReturn(List.of(base, member)); + view.specificTaxProposalView(player, faction, null, target); + Inventory menu = top(); + String expected = target == TaxTarget.GUILD_ID ? "artisans" : "subject"; + assertEquals(expected, data(menu.getItem(0), Keys.STRING_KEY)); + assertNull(menu.getItem(1)); + assertEquals(Material.BARRIER, menu.getItem(53).getType()); + view.specificTaxProposalView(player, faction, menu, target); + click(0); + verify(manager).setChanging(faction, player, target, expected); + verify(player).closeInventory(); + } + + @ParameterizedTest + @EnumSource( + value = TaxTarget.class, + names = {"GUILD_ID", "VASSAL_ID", "TARIFF_ID"}) + void aSpecificTaxTargetRemovedWhileTheMenuIsOpenDoesNotStartAnEditOrThrow(TaxTarget target) { + Guild guild = guild("artisans", "Artisans"); + Faction neighbour = faction("neighbour", "Neighbour"); + when(guildHandler.getGuilds()).thenReturn(List.of(guild)); + relations.when(() -> RelationManager.getSubjects(faction)).thenReturn(List.of(neighbour)); + view.specificTaxProposalView(player, faction, null, target); + guilds.remove("artisans"); + factions.remove("neighbour"); + assertDoesNotThrow(() -> click(0)); + verify(manager, never()).setChanging(any(), any(), any(), any()); + } + + @Test + void taxViewOnlyShowsEnabledTaxesAndStaleTaxMetadataDoesNotBeginAnEdit() { + when(faction.hasFactionRule(any())).thenReturn(true); + view.taxProposalView(player, faction, null); + Inventory taxMenu = top(); + assertEquals( + TaxTarget.values().length, + java.util.Arrays.stream(taxMenu.getContents()).filter(java.util.Objects::nonNull).count() + - 1); + view.taxProposalView(player, faction, taxMenu); + taxMenu.setItem(0, encoded("removed-tax", null)); + click(0); + verify(manager, never()).setChanging(any(), any(), any(), any()); + taxMenu.setItem(0, new ItemStack(Material.PAPER)); + click(0); + factions.remove("home"); + click(0); + verify(manager, never()).setChanging(any(), any(), any(), any()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void favourAndRepressNavigationPreservesModeAndChangesOnlyAnEligibleSelectedGuild( + boolean favour) { + Guild guild = guild("artisans", "Artisans"); + Faction subject = faction("subject", "Neighbour"); + when(subject.getOrCreateMainGuild()).thenReturn(guild); + Guild base = guild("base", "Home"); + when(base.isBase()).thenReturn(true); + when(guildHandler.getGuilds()).thenReturn(List.of(base, guild)); + when(faction.getSubjects()).thenReturn(List.of(subject)); + when(faction.hasFactionRule(Rules.CAN_FAVOUR)).thenReturn(true); + when(faction.hasFactionRule(Rules.CAN_REPRESS)).thenReturn(true); + view.governmentView(player, faction, null); + click(16); + assertMenu(SFGUI.FAVOUR_REPRESS_MAIN); + view.favourRepressMainView(player, faction, top()); + click(favour ? 3 : 5); + assertMenu(SFGUI.FAVOUR_REPRESS_TYPE); + assertEquals(favour, holder().getFlag()); + view.favourRepressTypeView(player, faction, favour, top()); + for (boolean selectGuild : new boolean[] {true, false}) { + view.favourRepressTypeView(player, faction, favour, null); + click(selectGuild ? 3 : 5); + assertMenu(SFGUI.FAVOUR_REPRESS_SELECT); + assertEquals(selectGuild ? 1 : 0, holder().getPage()); + assertEquals(selectGuild ? "artisans" : "subject", data(top().getItem(0), Keys.STRING_KEY)); + assertNull(top().getItem(1)); + when(government.canFavour(guild)).thenReturn(false); + when(government.canRepress(guild)).thenReturn(false); + click(0); + verify(government, never()).toggleFavour(guild); + verify(government, never()).toggleRepress(guild); + when(government.canFavour(guild)).thenReturn(favour); + when(government.canRepress(guild)).thenReturn(!favour); + Inventory menu = top(); + click(0); + assertSame(menu, top()); + if (favour) verify(government).toggleFavour(guild); + else verify(government).toggleRepress(guild); + clearInvocations(government); + when(faction.getLeader()).thenReturn("Bob"); + click(0); + verify(government, never()).toggleFavour(any()); + verify(government, never()).toggleRepress(any()); + when(faction.getLeader()).thenReturn("Alice"); + } + } + + @Test + void lawMenusListGroupsAndTheirLawsAndUseBothIdsToNavigate() { + Law law = law(); + LawGroup group = laws.getGroup("charter"); + view.lawCreator = mock(LawCreator.class); + when(view.lawCreator.createLawGroupItem(player, faction, group)) + .thenAnswer(call -> encoded("charter", null)); + when(view.lawCreator.createLawItem(player, faction, group, law, true)) + .thenAnswer(call -> encoded("free", "charter")); + view.proposalView(player, faction, null); + click(0); + assertMenu(SFGUI.LAW_PROPOSAL_VIEW); + Inventory groups = top(); + assertEquals("charter", data(groups.getItem(10), Keys.STRING_KEY)); + view.lawProposalView(player, faction, groups); + click(10); + assertMenu(SFGUI.LAW_PROPOSAL_SELECT); + assertEquals("charter", holder().getSecondaryId()); + Inventory selection = top(); + assertEquals("free", data(selection.getItem(0), Keys.STRING_KEY)); + assertEquals("charter", data(selection.getItem(0), Keys.SECONDARY_STRING_KEY)); + view.lawProposalSelect(player, faction, group, selection); + assertNull(selection.getItem(1)); + view.proposalView(player, faction, null); + click(1); + assertMenu(SFGUI.TAX_PROPOSAL_VIEW); + } + + @ParameterizedTest + @ValueSource( + strings = { + "solo", + "council", + "cause", + "movement", + "poor-solo", + "poor-council", + "active-council", + "active-cause", + "blocked-law", + "blocked-host", + "denied" + }) + void lawSelectionRevalidatesTheRulesAndChargesOnlySuccessfulGovernmentChanges(String scenario) { + Law law = law(); + Movement movement = mock(Movement.class); + when(government.hasCouncil()).thenReturn(!scenario.endsWith("solo")); + when(government.canPropose(player)).thenReturn(scenario.endsWith("council")); + when(government.canProposeOrStartMovement(player)).thenReturn(!scenario.equals("denied")); + when(government.canBeProposed(any())).thenReturn(!scenario.startsWith("active")); + if (scenario.endsWith("cause")) + when(government.getMovementByMember("Alice")).thenReturn(movement); + if (scenario.startsWith("poor")) when(government.getPower()).thenReturn(39.0); + if (scenario.equals("blocked-law")) + canHaveLaw.when(() -> CanHaveLaw.blockReason(faction, law)).thenReturn("§cLaw is locked"); + if (scenario.equals("blocked-host")) + hostRules + .when(() -> CivilWarHostMovementRules.blocksHostGuildStart(faction, "Alice")) + .thenReturn(true); + Inventory menu = menu(SFGUI.LAW_PROPOSAL_SELECT, 27); + menu.setItem(0, encoded("free", "charter")); + click(0); + ArgumentCaptor<Proposal> proposal = ArgumentCaptor.forClass(Proposal.class); + switch (scenario) { + case "solo" -> { + verify(faction).applyLaw(law, laws.getGroup("charter")); + verify(government).spendPower(40); + assertMenu(SFGUI.GOVERNMENT_VIEW); + } + case "council" -> { + verify(government).propose(proposal.capture()); + assertSame(law, proposal.getValue().getLaw()); + verify(government).spendPower(40); + assertMenu(SFGUI.GOVERNMENT_VIEW); + } + case "cause" -> { + verify(movement).createCause(eq("Alice"), proposal.capture()); + assertSame(law, proposal.getValue().getLaw()); + verify(government, never()).spendPower(anyDouble()); + } + case "movement" -> { + verify(government).startMovement(eq("Alice"), proposal.capture()); + assertSame(law, proposal.getValue().getLaw()); + verify(government, never()).spendPower(anyDouble()); + } + default -> { + verify(faction, never()).applyLaw(any(), any()); + verify(government, never()).spendPower(anyDouble()); + verify(government, never()).propose(any()); + verify(government, never()).startMovement(anyString(), any()); + verify(movement, never()).createCause(anyString(), any()); + assertSame(menu, top()); + } + } + } + + @ParameterizedTest + @ValueSource( + strings = { + "start", + "cause", + "active", + "host-blocked", + "no-movement", + "no-political", + "invalid" + }) + void politicalSelectionUsesTheActionAndRechecksMovementPermissions(String scenario) { + when(government.canProposePolitical(player, Action.INDEPENDENCE)) + .thenReturn(!scenario.equals("no-political")); + when(government.canProposeOrStartMovement(player)).thenReturn(!scenario.equals("no-movement")); + when(government.canBeProposed(any())).thenReturn(!scenario.equals("active")); + Movement movement = mock(Movement.class); + if (Set.of("cause", "active").contains(scenario)) + when(government.getMovementByMember("Alice")).thenReturn(movement); + hostRules + .when(() -> CivilWarHostMovementRules.blocksHostGuildStart(faction, "Alice")) + .thenReturn(scenario.equals("host-blocked")); + Inventory menu = menu(SFGUI.POLITICAL_PROPOSAL_VIEW, 27); + menu.setItem(0, encoded(scenario.equals("invalid") ? "obsolete-action" : "INDEPENDENCE", null)); + click(0); + ArgumentCaptor<Proposal> proposal = ArgumentCaptor.forClass(Proposal.class); + if (scenario.equals("start")) verify(government).startMovement(eq("Alice"), proposal.capture()); + else if (scenario.equals("cause")) + verify(movement).createCause(eq("Alice"), proposal.capture()); + else { + verify(government, never()).startMovement(anyString(), any()); + verify(movement, never()).createCause(anyString(), any()); + assertSame(menu, top()); + return; + } + assertEquals(Action.INDEPENDENCE, proposal.getValue().getPoliticalAction().getAction()); + assertEquals("Alice", proposal.getValue().getProposer()); + assertMenu(SFGUI.GOVERNMENT_VIEW); + } + + @Test + void politicalWarActionOpensAListOfActualWarsAndCapsItAtTheBackButton() { + when(government.canProposePolitical(player, Action.WHITE_PEACE)).thenReturn(true); + List<War> wars = new ArrayList<>(); + for (int n = 0; n < 56; n++) { + War war = mock(War.class); + when(war.getId()).thenReturn(n + 1); + when(war.getName()).thenReturn("War " + (n + 1)); + wars.add(war); + } + peace.when(() -> CouncilPeaceQueries.warsFor(faction)).thenReturn(wars); + view.politicalProposalView(player, faction, null); + Inventory political = top(); + assertEquals("WHITE_PEACE", data(political.getItem(0), Keys.STRING_KEY)); + assertNull(political.getItem(1)); + view.politicalProposalView(player, faction, political); + click(0); + assertMenu(SFGUI.WAR_PEACE_SELECT); + assertEquals("WHITE_PEACE", holder().getSecondaryId()); + assertEquals("1", data(top().getItem(0), Keys.STRING_KEY)); + assertEquals("53", data(top().getItem(52), Keys.STRING_KEY)); + assertEquals(Material.BARRIER, top().getItem(53).getType()); + view.warPeaceSelectView(player, faction, Action.WHITE_PEACE, false, 0, top()); + assertEquals("53", data(top().getItem(52), Keys.STRING_KEY)); + } + + @ParameterizedTest + @ValueSource( + strings = { + "solo", + "council", + "movement", + "existing", + "active-council", + "active-existing", + "host-blocked", + "denied", + "political-denied", + "war-ended" + }) + void selectedWarIsRevalidatedBeforeApplyingProposingOrStartingAMovement(String scenario) { + when(government.canProposePolitical(player, Action.SURRENDER)) + .thenReturn(!scenario.equals("political-denied")); + when(government.hasCouncil()).thenReturn(!scenario.equals("solo")); + when(government.canPropose(player)).thenReturn(scenario.endsWith("council")); + when(government.canProposeOrStartMovement(player)).thenReturn(!scenario.equals("denied")); + when(government.canBeProposed(any())).thenReturn(!scenario.startsWith("active")); + peace + .when(() -> CouncilPeaceQueries.isValidTarget(faction, "42")) + .thenReturn(!scenario.equals("war-ended")); + hostRules + .when(() -> CivilWarHostMovementRules.blocksHostGuildStart(faction, "Alice")) + .thenReturn(scenario.equals("host-blocked")); + Movement movement = mock(Movement.class); + if (scenario.endsWith("existing")) + when(government.getMovementByMember("Alice")).thenReturn(movement); + Inventory menu = warMenu(false, 0); + click(0); + ArgumentCaptor<Proposal> proposal = ArgumentCaptor.forClass(Proposal.class); + switch (scenario) { + case "solo" -> verify(faction).applyPoliticalAction(isNull(), proposal.capture()); + case "council" -> verify(government).propose(proposal.capture()); + case "movement" -> verify(government).startMovement(eq("Alice"), proposal.capture()); + case "existing" -> verify(movement).createCause(eq("Alice"), proposal.capture()); + default -> { + verify(faction, never()).applyPoliticalAction(any(), any()); + verify(government, never()).propose(any()); + verify(government, never()).startMovement(anyString(), any()); + verify(movement, never()).createCause(anyString(), any()); + assertSame(menu, top()); + return; + } + } + assertEquals(Action.SURRENDER, proposal.getValue().getPoliticalAction().getAction()); + assertEquals("42", proposal.getValue().getTarget()); + assertMenu(SFGUI.GOVERNMENT_VIEW); + } + + @ParameterizedTest + @ValueSource( + strings = {"leader", "lost-leader", "no-leader", "removed-cause", "removed-movement"}) + void warTargetForAnExistingCauseCanOnlyBeChangedByItsCurrentLeader(String scenario) { + peace.when(() -> CouncilPeaceQueries.isValidTarget(faction, "42")).thenReturn(true); + Movement movement = mock(Movement.class); + Cause cause = mock(Cause.class); + Proposal proposal = new Proposal("Alice", government); + proposal.setPoliticalActionProposal(new PoliticalAction(Action.SURRENDER)); + when(cause.getProposal()).thenReturn(proposal); + when(cause.getMovement()).thenReturn(movement); + when(cause.hasLeader()).thenReturn(!scenario.equals("no-leader")); + when(cause.getLeader()).thenReturn(scenario.equals("lost-leader") ? "Bob" : "Alice"); + when(movement.getCauses()) + .thenReturn(scenario.equals("removed-cause") ? List.of() : List.of(cause)); + when(government.getMovementByMember("Alice")) + .thenReturn(scenario.equals("removed-movement") ? null : movement); + warMenu(true, 0); + click(0); + if (scenario.equals("leader")) { + assertEquals("42", proposal.getTarget()); + verify(manager.movementView).causeView(player, faction, movement, cause, null); + } else { + assertNull(proposal.getTarget()); + verifyNoInteractions(manager.movementView); + } + verify(government, never()).propose(any()); + verify(government, never()).startMovement(anyString(), any()); + } + + @ParameterizedTest + @ValueSource( + strings = {"empty-slot", "air", "missing-war-id", "missing-faction", "obsolete-action"}) + void invalidWarSelectionDataIsCancelledWithoutAnyGovernmentMutation(String state) { + Inventory menu = warMenu(false, 0); + switch (state) { + case "empty-slot" -> menu.setItem(0, null); + case "air" -> { + ItemStack air = new ItemStack(Material.AIR); + // Paper supplies no ItemMeta for AIR; this scoped override models that API boundary. + when(air.getItemMeta()).thenReturn(null); + menu.setItem(0, air); + } + case "missing-war-id" -> menu.setItem(0, new ItemStack(Material.PAPER)); + case "missing-faction" -> factions.remove("home"); + case "obsolete-action" -> { + menu = + ui.inventory( + new SFInventoryHolder("home", SFGUI.WAR_PEACE_SELECT, 0, false, "obsolete-action"), + 54, + "Select War"); + menu.setItem(0, encoded("42", null)); + player.openInventory(menu); + } + default -> fail("Unexpected fixture state"); + } + click(0); + assertSame(menu, top()); + verify(government, never()).propose(any()); + verify(government, never()).startMovement(anyString(), any()); + verify(faction, never()).applyPoliticalAction(any(), any()); + verifyNoInteractions(manager.movementView); + } + + @ParameterizedTest + @ValueSource( + strings = { + "removed", + "shifted", + "refreshed-removed", + "refreshed-shifted", + "switched-movement" + }) + void warTargetSelectionKeepsTheCauseThatOpenedTheMenu(String state) { + peace.when(() -> CouncilPeaceQueries.isValidTarget(faction, "42")).thenReturn(true); + Movement movement = mock(Movement.class); + when(movement.getFaction()).thenReturn(faction); + when(faction.getRelationToFaction("Alice")).thenReturn(Member.MEMBER); + List<Cause> causes = new ArrayList<>(); + for (int n = 0; n < 3; n++) { + Proposal proposal = new Proposal("Alice", government); + proposal.setPoliticalActionProposal(new PoliticalAction(Action.SURRENDER)); + causes.add(new Cause(movement, proposal, "Alice")); + } + List<Cause> original = List.copyOf(causes); + when(movement.getCauses()).thenReturn(causes); + when(government.getMovementByMember("Alice")).thenReturn(movement); + int selected = state.contains("shifted") ? 1 : 0; + view.warPeaceSelectView(player, faction, Action.SURRENDER, true, selected, null); + Inventory menu = top(); + if (state.equals("switched-movement")) { + Movement replacement = mock(Movement.class); + when(replacement.getCauses()).thenReturn(causes); + when(government.getMovementByMember("Alice")).thenReturn(replacement); + } else causes.removeFirst(); + if (state.startsWith("refreshed")) + view.warPeaceSelectView(player, faction, Action.SURRENDER, true, selected, menu); + menu.setItem(0, encoded("42", null)); + click(0); + for (int n = 0; n < original.size(); n++) { + assertEquals( + state.contains("shifted") && n == selected ? "42" : null, + original.get(n).getProposal().getTarget(), + "Only the originally displayed live cause may change"); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void warPickerBackUsesTheCapturedCauseAndFallsBackIfItWasRemoved(boolean surviving) { + Movement movement = mock(Movement.class); + when(movement.getFaction()).thenReturn(faction); + when(faction.getRelationToFaction("Alice")).thenReturn(Member.MEMBER); + List<Cause> causes = new ArrayList<>(); + for (int n = 0; n < 2; n++) { + Proposal proposal = new Proposal("Alice", government); + proposal.setPoliticalActionProposal(new PoliticalAction(Action.SURRENDER)); + causes.add(new Cause(movement, proposal, "Alice")); + } + when(movement.getCauses()).thenReturn(causes); + when(government.getMovementByMember("Alice")).thenReturn(movement); + Cause selected = causes.get(surviving ? 1 : 0); + view.warPeaceSelectView(player, faction, Action.SURRENDER, true, selected.getIndex(), null); + causes.removeFirst(); + InventoryManager router = new InventoryManager(); + router.governmentView = view; + router.movementView = manager.movementView; + top().setItem(53, router.createBackButton(SFGUI.WAR_PEACE_SELECT)); + router.clickButton(ui.click(player, 53)); + if (surviving) + verify(manager.movementView).causeView(player, faction, movement, selected, null); + else { + verifyNoInteractions(manager.movementView); + assertMenu(SFGUI.GOVERNMENT_VIEW); + } + } + + private Inventory warMenu(boolean cause, int index) { + view.warPeaceSelectView(player, faction, Action.SURRENDER, cause, index, null); + Inventory menu = top(); + menu.setItem(0, encoded("42", null)); + return menu; + } + + @Test + void largeCouncilCandidateRosterKeepsEveryCandidateSelectableAcrossPages() { + List<String> members = + java.util.stream.IntStream.range(0, 70).mapToObj(n -> "Member" + n).toList(); + when(faction.getMembers()).thenAnswer(call -> new ArrayList<>(members)); + when(council.getMembers()).thenReturn(List.of("Bob")); + when(council.canBeMember(anyString(), eq(true), eq(false))).thenReturn(true); + assertDoesNotThrow(() -> view.councilSelect(player, faction, null, 1)); + assertEquals("Member0", data(top().getItem(0), Keys.STRING_KEY)); + assertEquals("Member44", data(top().getItem(44), Keys.STRING_KEY)); + assertTrue(name(top().getItem(52)).contains("Next")); + assertEquals(Material.BARRIER, top().getItem(53).getType()); + click(52); + assertEquals(1, holder().getPage()); + assertEquals("Member45", data(top().getItem(0), Keys.STRING_KEY)); + assertEquals("Member69", data(top().getItem(24), Keys.STRING_KEY)); + assertEquals( + 1, + top() + .getItem(24) + .getItemMeta() + .getPersistentDataContainer() + .get(Keys.INT, PersistentDataType.INTEGER)); + assertNull(top().getItem(25)); + assertNull(top().getItem(52)); + assertTrue(name(top().getItem(45)).contains("Previous")); + view.councilSelect(player, faction, top(), 1); + assertEquals(1, holder().getPage()); + assertEquals("Member45", data(top().getItem(0), Keys.STRING_KEY)); + click(45); + assertEquals(0, holder().getPage()); + assertEquals("Member0", data(top().getItem(0), Keys.STRING_KEY)); + click(52); + click(24); + verify(council).addMember("Member69"); + assertMenu(SFGUI.COUNCIL_VIEW); + assertEquals(70, faction.getMembers().size()); + } + + @Test + void councilPagesClampAfterRosterChangesAndRejectNavigationAfterLeadershipIsLost() { + List<String> members = + java.util.stream.IntStream.range(0, 70).mapToObj(n -> "Member" + n).toList(); + when(faction.getMembers()).thenAnswer(call -> new ArrayList<>(members)); + when(council.canBeMember(anyString(), eq(true), eq(false))).thenReturn(true); + view.councilSelect(player, faction, null, 0); + Inventory original = top(); + when(faction.getLeader()).thenReturn("Bob"); + click(52); + assertSame(original, top()); + assertEquals(0, holder().getPage()); + when(faction.getLeader()).thenReturn("Alice"); + click(52); + assertEquals(1, holder().getPage()); + when(faction.getMembers()).thenAnswer(call -> new ArrayList<>(List.of("Member0", "Member1"))); + view.councilSelect(player, faction, top(), 0); + assertEquals(0, holder().getPage()); + assertEquals("Member0", data(top().getItem(0), Keys.STRING_KEY)); + assertNull(top().getItem(2)); + assertNull(top().getItem(45)); + assertNull(top().getItem(52)); + verify(council, never()).addMember(anyString()); + } + + @Test + void largeTaxAndFavourTargetListsKeepTheirFirstFiftyThreeEntriesAndBackControl() { + List<Guild> guildList = new ArrayList<>(); + List<Faction> subjects = new ArrayList<>(); + for (int n = 0; n < 60; n++) { + Guild guild = guild("guild-" + n, "Guild " + n); + Faction subject = faction("subject-" + n, "Subject " + n); + when(subject.getOrCreateMainGuild()).thenReturn(guild); + guildList.add(guild); + subjects.add(subject); + } + when(guildHandler.getGuilds()).thenReturn(guildList); + when(faction.getSubjects()).thenReturn(subjects); + relations.when(() -> RelationManager.getSubjects(faction)).thenReturn(subjects); + for (TaxTarget target : List.of(TaxTarget.GUILD_ID, TaxTarget.VASSAL_ID, TaxTarget.TARIFF_ID)) { + view.specificTaxProposalView(player, faction, null, target); + String prefix = target == TaxTarget.GUILD_ID ? "guild-" : "subject-"; + assertEquals(prefix + "0", data(top().getItem(0), Keys.STRING_KEY)); + assertEquals(prefix + "52", data(top().getItem(52), Keys.STRING_KEY)); + assertEquals(Material.BARRIER, top().getItem(53).getType()); + } + for (boolean guildMode : new boolean[] {true, false}) { + view.favourRepressSelectView(player, faction, true, guildMode, null); + assertEquals( + (guildMode ? "guild-" : "subject-") + "52", data(top().getItem(52), Keys.STRING_KEY)); + assertEquals(Material.BARRIER, top().getItem(53).getType()); + } + } + + private Law law() { + Law law = mock(Law.class); + Law old = mock(Law.class); + LawGroup group = mock(LawGroup.class); + when(law.getId()).thenReturn("free"); + when(law.getGroup()).thenReturn("charter"); + when(law.getCost()).thenReturn(20.0); + when(law.getCompatibility("old")).thenReturn(2); + when(old.getId()).thenReturn("old"); + when(group.getId()).thenReturn("charter"); + when(group.getCurrent()).thenReturn(old); + when(group.getLaws()).thenReturn(Map.of("free", law)); + when(laws.getGroupList()).thenReturn(List.of(group)); + when(laws.getGroup("charter")).thenReturn(group); + when(laws.getLaw("charter", "free")).thenReturn(law); + return law; + } + + private Inventory menu(SFGUI type, int size) { + Inventory menu = ui.inventory(new SFInventoryHolder("home", type), size, type.name()); + player.openInventory(menu); + return menu; + } + + private SFInventoryHolder holder() { + return (SFInventoryHolder) top().getHolder(); + } + + private void assertMenu(SFGUI expected) { + assertEquals(expected, holder().getType()); + } + + private InventoryClickEvent click(int slot) { + Inventory inventory = top(); + InventoryClickEvent event = ui.click(player, slot); + view.click(event, inventory, player); + assertTrue(event.isCancelled()); + return event; + } + + private ItemStack encoded(String primary, String secondary) { + ItemStack item = new ItemStack(Material.PAPER); + ItemMeta meta = item.getItemMeta(); + if (primary != null) + meta.getPersistentDataContainer().set(Keys.STRING_KEY, PersistentDataType.STRING, primary); + if (secondary != null) + meta.getPersistentDataContainer() + .set(Keys.SECONDARY_STRING_KEY, PersistentDataType.STRING, secondary); + item.setItemMeta(meta); + return item; + } + + private StabilityFacts.Body body(String name, int members, String stance, boolean realm) { + StabilityFacts.Body body = new StabilityFacts.Body(); + body.name = name; + body.members = members; + body.stance = stance; + body.realm = realm; + return body; + } + + private Faction faction(String id, String name) { + Faction value = mock(Faction.class); + when(value.getId()).thenReturn(id); + when(value.getName()).thenReturn(name); + ItemStack banner = new ItemStack(Material.BLUE_BANNER); + when(value.getBanner()).thenReturn(banner); + factions.put(id, value); + FactionManager.factions.add(value); + return value; + } + + private Guild guild(String id, String name) { + Guild value = mock(Guild.class); + when(value.getId()).thenReturn(id); + when(value.getName()).thenReturn(name); + ItemStack banner = new ItemStack(Material.WHITE_BANNER); + when(value.getBanner()).thenReturn(banner); + when(value.getFaction()).thenReturn(faction); + when(value.getStance(any())).thenReturn(Stance.NEUTRAL); + guilds.put(id, value); + return value; + } + + private Inventory top() { + return player.getOpenInventory().getTopInventory(); + } + + private String name(ItemStack item) { + return ChatColor.stripColor(item.getItemMeta().getDisplayName()); + } + + private String lore(ItemStack item) { + List<String> lines = item.getItemMeta().getLore(); + return lines == null ? "" : ChatColor.stripColor(String.join("\n", lines)); + } + + private String data(ItemStack item, org.bukkit.NamespacedKey key) { + return item.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GuildMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GuildMenusCoverageTest.java new file mode 100644 index 00000000..76abafcb --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GuildMenusCoverageTest.java @@ -0,0 +1,1312 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.database.GuildData; +import net.tfminecraft.simplefactions.diplomacy.RelationType; +import net.tfminecraft.simplefactions.enums.FactionModifiers; +import net.tfminecraft.simplefactions.enums.GuildModifier; +import net.tfminecraft.simplefactions.enums.MenuItemType; +import net.tfminecraft.simplefactions.enums.RankType; +import net.tfminecraft.simplefactions.enums.Rules; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.espionage.EspionageAccess; +import net.tfminecraft.simplefactions.espionage.EspionageService; +import net.tfminecraft.simplefactions.espionage.IntelligenceReport; +import net.tfminecraft.simplefactions.espionage.RosterLore; +import net.tfminecraft.simplefactions.government.StabilityModifier; +import net.tfminecraft.simplefactions.government.stability.GovernmentIncompatibility; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.GuildCoverageTest.GuildFixture; +import net.tfminecraft.simplefactions.guild.branch.Branch; +import net.tfminecraft.simplefactions.guild.branch.BranchModifier; +import net.tfminecraft.simplefactions.guild.income.BranchIncomePreview; +import net.tfminecraft.simplefactions.guild.income.Cashflow; +import net.tfminecraft.simplefactions.guild.income.DividendBreakdown; +import net.tfminecraft.simplefactions.guild.income.Ledger; +import net.tfminecraft.simplefactions.guild.income.LedgerHistory; +import net.tfminecraft.simplefactions.guild.income.TradeBreakdown; +import net.tfminecraft.simplefactions.guild.upgrade.Upgrade; +import net.tfminecraft.simplefactions.guild.upgrade.UpgradeExpansion; +import net.tfminecraft.simplefactions.inactivity.InactivityService; +import net.tfminecraft.simplefactions.keys.Keys; +import net.tfminecraft.simplefactions.loaders.BranchLoader; +import net.tfminecraft.simplefactions.loaders.RelationLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.managers.RelationManager; +import net.tfminecraft.simplefactions.managers.RelocationPrompt; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.map.MapSystem; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.FactionModifier; +import net.tfminecraft.simplefactions.objects.Modifier; +import net.tfminecraft.simplefactions.rest.BannerFetcher; +import net.tfminecraft.simplefactions.rest.RestServer; +import net.tfminecraft.simplefactions.util.LegacyModelData; +import net.tfminecraft.simplefactions.utils.FactionRanker; +import net.tfminecraft.simplefactions.utils.HomeSettlementNames; +import net.tfminecraft.simplefactions.war.freeze.PreparationFreeze; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemCreator; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Sound; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +class GuildMenusCoverageTest { + private GuildFixture fixture; + private Guild guild; + private InventoryManager manager; + private GuildView view; + private MockedStatic<EspionageService> intelligence; + private MockedStatic<EspionageAccess> access; + private MockedStatic<ReportedMenus> reported; + private MockedStatic<BranchIncomePreviewService> previewTasks; + private final List<MockedStatic<?>> scopes = new ArrayList<>(); + private HashMap<Player, RankType> oldRanking; + private HashMap<Player, Integer> oldPages; + private GuildCreator creator; + private MapSystem map; + private FactionRanker ranker; + private boolean oldProvinces; + + private <T> MockedStatic<T> scoped(Class<T> type) { + MockedStatic<T> scope = mockStatic(type); + scopes.add(scope); + return scope; + } + + @BeforeEach + void setUp() { + fixture = new GuildFixture(); + guild = fixture.guild("guild", 1); + fixture.factions.when(() -> FactionManager.getGuildByString("merchants")).thenReturn(guild); + manager = new InventoryManager(); + view = new GuildView(manager); + manager.guildView = view; + view.setProvinceManager(fixture.provinces); + intelligence = mockStatic(EspionageService.class); + intelligence + .when(() -> EspionageService.canViewExact(fixture.player, fixture.host)) + .thenReturn(true); + access = mockStatic(EspionageAccess.class); + oldRanking = GuildView.currentRanking; + oldPages = GuildView.currentPage; + GuildView.currentRanking = new HashMap<>(); + GuildView.currentPage = new HashMap<>(); + oldProvinces = Cache.provincesEnabled; + Cache.provincesEnabled = true; + ItemAPI api = mock(ItemAPI.class); + ItemCreator items = mock(ItemCreator.class); + when(api.getCreator()).thenReturn(items); + when(items.getItemsAdderItem(anyString())).thenAnswer(call -> new ItemStack(Material.PAPER)); + when(items.getItemFromPath(anyString())).thenAnswer(call -> new ItemStack(Material.PAPER)); + scoped(TLibs.class).when(TLibs::getItemAPI).thenReturn(api); + scoped(IconGetter.class) + .when(() -> IconGetter.getIconOrDefault(anyString(), any())) + .thenAnswer(call -> new ItemStack((Material) call.getArgument(1))); + reported = scoped(ReportedMenus.class); + reported + .when(() -> ReportedMenus.guildLeader(any(), any())) + .thenAnswer(call -> ((Guild) call.getArgument(1)).getLeader()); + scoped(HomeSettlementNames.class) + .when(() -> HomeSettlementNames.of(any(Guild.class))) + .thenReturn("Market Town"); + scoped(InactivityService.class); + scoped(RosterLore.class) + .when(() -> RosterLore.guild(any(), any())) + .thenReturn(List.of("Leader", "Alice")); + scoped(GovernmentIncompatibility.class) + .when(() -> GovernmentIncompatibility.factor(any())) + .thenReturn(1.0); + map = mock(MapSystem.class); + fixture.factions.when(FactionManager::getMap).thenReturn(map); + creator = view.creator; + ranker = mock(FactionRanker.class); + creator.r = ranker; + scoped(BranchIncomePreview.class); + previewTasks = scoped(BranchIncomePreviewService.class); + } + + @AfterEach + void close() { + try { + for (int i = scopes.size() - 1; i >= 0; i--) scopes.get(i).close(); + access.close(); + intelligence.close(); + } finally { + GuildView.currentRanking = oldRanking; + GuildView.currentPage = oldPages; + Cache.provincesEnabled = oldProvinces; + fixture.close(); + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void aBranchRemovedSinceTheMenuOpenedDoesNotMutateTheBank(boolean upgrade) { + Inventory inventory = open(); + ItemStack button = new ItemStack(Material.ARROW); + ItemMeta meta = button.getItemMeta(); + meta.getPersistentDataContainer().set(Keys.BRANCH_ID, PersistentDataType.STRING, "removed"); + meta.getPersistentDataContainer().set(Keys.BOOLEAN_FLAG, PersistentDataType.BOOLEAN, upgrade); + button.setItemMeta(meta); + inventory.setItem(20, button); + Bank bank = mock(Bank.class); + when(bank.getWealth()).thenReturn(1000.0); + guild.setBank(bank); + InventoryClickEvent click = fixture.ui.click(fixture.player, 20); + manager.clickButton(click); + assertTrue(click.isCancelled()); + verify(bank, never()).withdraw(anyDouble()); + verify(bank, never()).deposit(anyDouble()); + } + + @Test + void aBannerRequestCannotApplyAfterTheRequesterLosesLeadership() { + Inventory inventory = open(); + inventory.setItem(GuildView.BANNER_RANDOM_SLOT, new ItemStack(Material.WHITE_BANNER)); + List<Consumer<List<String>>> pending = new ArrayList<>(); + try (MockedStatic<BannerFetcher> banners = mockStatic(BannerFetcher.class)) { + banners + .when(() -> BannerFetcher.fetch(eq("guild:merchants"), any())) + .thenAnswer( + call -> { + pending.add(call.getArgument(1)); + return null; + }); + manager.clickButton(fixture.ui.click(fixture.player, GuildView.BANNER_RANDOM_SLOT)); + assertEquals(1, pending.size()); + guild.setLeader("Successor"); + pending.getFirst().accept(List.of("red")); + assertEquals(List.of("white"), guild.getBannerPatterns()); + } + } + + @Test + void evictionChargesTheOriginalHostAfterTheGuildBecomesLandless() { + guild.setLeader("GuildLeader"); + when(fixture.host.isLeader("Leader")).thenReturn(true); + when(fixture.host.getGovernment().getMovementByMember("GuildLeader")).thenReturn(null); + when(fixture.host.getGovernment().getPower()).thenReturn(1000.0); + Inventory inventory = open(); + inventory.setItem(34, new ItemStack(Material.PAPER)); + double cost = guild.getEvictionCost(); + try (MockedConstruction<Faction> created = + mockConstruction( + Faction.class, withSettings().defaultAnswer(RETURNS_DEEP_STUBS), (f, context) -> {})) { + manager.clickButton(fixture.ui.click(fixture.player, 34)); + assertEquals(1, created.constructed().size()); + assertSame(created.constructed().getFirst(), guild.getFaction()); + verify(fixture.host.getGovernment()).spendPower(cost); + verify(created.constructed().getFirst().getGovernment(), never()).spendPower(anyDouble()); + verify(fixture.player).closeInventory(); + } + } + + @Test + void branchDowngradeImmediatelyRemovesTheOldExpansionValueFromWealth() { + guild.getBank().setWealth(1000.0); + guild.updateWealth(); + assertEquals(1100, guild.getWealth()); + view = spy(view); + manager.guildView = view; + doNothing().when(view).guildView(any(), any(), any(Inventory.class)); + Inventory inventory = open(); + inventory.setItem( + 38, creator.createBranchDowngradeItem(fixture.player, guild, guild.getBranch(0))); + manager.clickButton(fixture.ui.click(fixture.player, 38)); + assertEquals(0, guild.getSize()); + assertEquals(1080, guild.getBank().getWealth()); + assertEquals(1080, guild.getWealth()); + verify(fixture.host).updatePrestige(); + verify(fixture.provinces).recalculateForSingleGuild(guild, true); + } + + @Test + void branchItemsDescribeRealDefinitionsCostsRefundsAndDeferredIncomeEstimates() { + Branch branch = guild.getBranch(0); + branch.getModifiers().put(GuildModifier.TRADE_UPKEEP, new BranchModifier(3, 1)); + ItemStack item = creator.createBranchItem(fixture.player, guild, branch); + assertEquals(Material.EMERALD, item.getType()); + assertEquals("commerce", payload(item, Keys.BRANCH_ID)); + assertLore(item, "Commerce", false); + assertLore(item, "Trade offices", true); + assertLore(item, "Trade Power", true); + assertLore(item, "Upkeep", true); + guild.getTradeBreakdown().setIncome(50); + guild.getTradeBreakdown().setUpkeep(4); + guild.getTradeBreakdown().setTariffs(6); + ItemStack up = creator.createBranchUpgradeItem(fixture.player, guild, branch); + ItemStack down = creator.createBranchDowngradeItem(fixture.player, guild, branch); + assertEquals(true, flag(up)); + assertEquals(false, flag(down)); + assertEquals("commerce", payload(up, Keys.BRANCH_ID)); + assertLore(up, "200.0d", true); + assertLore(up, "40.0", true); + assertLore(up, "Calculating", true); + assertLore(down, "80.0d", true); + assertLore(down, "Stats will decrease", true); + creator.writeUpgradeEstimate(up, guild, branch, 7.125); + assertLore(up, "+7.13d/day", true); + assertEquals("commerce", payload(up, Keys.BRANCH_ID)); + creator.writeDowngradeEstimate(down, guild, branch, -6.0); + assertLore(down, "-6.00d/day", true); + assertEquals(false, flag(down)); + creator.writeUpgradeEstimate(up, guild, branch, Double.NaN); + assertLore(up, "unavailable", true); + guild.setCapital(-1); + assertLore(creator.createBranchItem(fixture.player, guild, branch), "No capital!", true); + assertLore(creator.createBranchUpgradeItem(fixture.player, guild, branch), "No capital!", true); + assertLore( + creator.createBranchDowngradeItem(fixture.player, guild, branch), "No capital!", true); + branch.levelDown(); + assertLore( + creator.createBranchDowngradeItem(fixture.player, guild, branch), + "cannot be downgraded", + true); + } + + @Test + void upgradeItemsPreserveActionPayloadsAndExposeQueueAndUpkeepDetails() { + Upgrade upgrade = new Upgrade(fixture.upgrade("roads", true, 60), 2); + upgrade.getModifiers().put(GuildModifier.TRADE_POWER, new BranchModifier(2, 3)); + upgrade.getModifiers().put(GuildModifier.TRADE_UPKEEP, new BranchModifier(1, 2)); + ItemStack item = creator.createUpgradeItem(fixture.player, guild, upgrade); + assertEquals("roads", payload(item, Keys.STRING_KEY)); + assertLore(item, "6.00d/day", true); + assertLore(item, "Trade Power", true); + ItemStack up = creator.createUpgradeUpgradeItem(fixture.player, guild, upgrade); + ItemStack down = creator.createUpgradeDowngradeItem(fixture.player, guild, upgrade); + assertEquals("roads", payload(up, Keys.STRING_KEY)); + assertEquals(true, flag(up)); + assertEquals(false, flag(down)); + assertLore(up, "Time:", true); + assertLore(up, "+3.00d/day", true); + assertLore(down, "-3.00d/day", true); + upgrade.setLevel(0); + assertLore( + creator.createUpgradeDowngradeItem(fixture.player, guild, upgrade), + "cannot be downgraded", + true); + try (MockedStatic<PreparationFreeze> freeze = mockStatic(PreparationFreeze.class)) { + freeze.when(() -> PreparationFreeze.frozenLore(any())).thenReturn("§eFrozen until tomorrow"); + ItemStack running = + creator.createUpgradeQueueItem(new UpgradeExpansion(upgrade, 30), 0, guild); + assertLore(running, "Time Left", true); + assertLore(running, "Frozen until tomorrow", true); + assertEquals( + QueueCancelPayload.guildUpgrade("merchants", 0), payload(running, Keys.QUEUE_CANCEL)); + ItemStack queued = + creator.createUpgradeQueueItem(new UpgradeExpansion(upgrade, 30), 1, guild); + assertLore(queued, "Queued", true); + assertEquals( + QueueCancelPayload.guildUpgrade("merchants", 1), payload(queued, Keys.QUEUE_CANCEL)); + } + Guild display = displayGuild(guild); + when(display.getUpgrades()).thenReturn(List.of(upgrade)); + assertLore(creator.createUpgradesItem(fixture.player, display), "Roads", true); + guild.setLeader("Other"); + assertLore(creator.createUpgradesItem(fixture.player, guild), "Click to view", false); + } + + @Test + void listItemsShowExactOwnedValuesAndLabelForeignEstimatesAndUnknowns() { + Guild display = displayGuild(guild); + display.setWealth(300.0); + display.getTradeBreakdown().setTradePower(12); + when(display.getLedger().getNetIncome()).thenReturn(-5.0); + when(ranker.visibleGuildValue(fixture.player, display, RankType.INCOME)).thenReturn(-5.0); + when(ranker.getVisibleGuildRank(fixture.player, display, RankType.WEALTH)).thenReturn(2); + ItemStack own = creator.createPublicListItem(fixture.player, display); + assertEquals("merchants", payload(own, new NamespacedKey(fixture.ui.plugin, "id"))); + assertLore(own, "300.0d", true); + assertLore(own, "Market Town", true); + assertLore(own, "-5.0d/day", true); + assertLore(own, "estimated", false); + intelligence + .when(() -> EspionageService.canViewExact(fixture.player, fixture.host)) + .thenReturn(false); + IntelligenceReport report = mock(IntelligenceReport.class); + intelligence + .when(() -> EspionageService.report(fixture.player, fixture.host)) + .thenReturn(report); + when(report.display("Guild:merchants:Members")).thenReturn("3–5"); + when(report.display("Guild:merchants:Trade power")).thenReturn("Unknown"); + when(report.display("Guild:merchants:Income")).thenReturn("10–20"); + when(report.display("Guild:merchants:Wealth")).thenReturn("100–500"); + ItemStack foreign = creator.createListItem(fixture.player, display); + assertLore(foreign, "3–5", true); + assertLore(foreign, "Unknown", true); + assertLore(foreign, "10–20d/day", true); + assertLore(foreign, "estimated 2", true); + intelligence.when(() -> EspionageService.report(fixture.player, fixture.host)).thenReturn(null); + when(ranker.getVisibleGuildRank(fixture.player, display, RankType.WEALTH)).thenReturn(null); + display.setCapital(-1); + ItemStack unknown = creator.createListItem(fixture.player, display); + assertLore(unknown, "Unknown", true); + assertLore(unknown, "Trade Power", false); + ItemStack host = creator.createHostFactionItem(guild); + assertEquals("realm", payload(host, new NamespacedKey(fixture.ui.plugin, "id"))); + assertEquals("The Realm", ChatColor.stripColor(host.getItemMeta().getDisplayName())); + } + + @Test + void menuIdentityWealthAndBannerActionsUseConcreteItemsAndKeepSourceBannersUnchanged() { + guild.addWealthModifier(new Modifier("Treasure", 20.0, true)); + guild.setWealth(20.0); + assertEquals( + Material.WHITE_BANNER, + creator.createMenuItem(fixture.player, guild, MenuItemType.BANNER).getType()); + assertLore( + creator.createMenuItem(fixture.player, guild, MenuItemType.BANNER), + "Part of: The Realm", + true); + assertLore( + creator.createMenuItem(fixture.player, fixture.guild("realm", 0), MenuItemType.BANNER), + "Guild of The Realm", + true); + ItemStack get = creator.createMenuItem(fixture.player, guild, MenuItemType.BANNER_GET); + assertEquals(Material.CHEST, get.getType()); + assertLore(get, "Click to get a banner", true); + assertLore( + creator.createMenuItem(fixture.player, guild, MenuItemType.BANNER_RANDOM), + "randomise", + true); + ItemStack head = creator.createMenuItem(fixture.player, guild, MenuItemType.LEADER); + assertEquals(Material.PLAYER_HEAD, head.getType()); + assertTrue(ChatColor.stripColor(head.getItemMeta().getDisplayName()).contains("Leader")); + assertLore( + creator.createMenuItem(fixture.player, guild, MenuItemType.WEALTH), + "20.0d from Treasure", + true); + ItemStack members = creator.createMenuItem(fixture.player, guild, MenuItemType.MEMBERS); + assertTrue(ChatColor.stripColor(members.getItemMeta().getDisplayName()).contains("2/")); + assertEquals("", guild.getBanner().getItemMeta().getDisplayName()); + } + + @Test + void dividendDonationAndLedgerSummariesSeparateIncomeExpensesAndAffordability() { + Guild display = displayGuild(guild); + Ledger ledger = display.getLedger(); + assertLore(creator.createLedgerItem(fixture.player, display), "No income sources", true); + assertLore(creator.createLedgerItem(fixture.player, display), "No expenses", true); + when(ledger.getIncome(Cashflow.TRADE)).thenReturn(40.0); + when(ledger.getIncome(Cashflow.GUILD_PAYMENTS)).thenReturn(-5.0); + when(ledger.getNetIncome()).thenReturn(35.0); + ItemStack summary = creator.createLedgerItem(fixture.player, display); + assertLore(summary, "+40.00d", true); + assertLore(summary, "-5.00d", true); + assertLore(summary, "+35.00d", true); + when(ledger.getIncome(Cashflow.TRADE)).thenReturn(0.0); + display.getPillageHits().add(new StabilityModifier("Pillage", -100, 1)); + when(ledger.getNetIncome()).thenReturn(-5.0); + assertLore(creator.createLedgerItem(fixture.player, display), "Pillaged", true); + when(ledger.getDividendBreakdown()).thenReturn(new DividendBreakdown(100, 20, 2, 18, 2, 9)); + display.setDividendPercent(20); + ItemStack dividends = creator.createDividendItem(fixture.player, display); + assertLore(dividends, "20.00%", true); + assertLore(dividends, "9.00d", true); + assertLore(dividends, "limited by the guild bank", true); + display.getBank().setWealth(100.0); + display.setLeader("Other"); + assertLore(creator.createDividendItem(fixture.player, display), "Only the guild leader", true); + assertLore( + creator.createDividendItem(fixture.player, fixture.guild("realm", 0)), + "cannot pay dividends", + true); + assertLore(creator.createDonationItem(fixture.player, display), "Nothing is sent", true); + display.setDonationAmount(100); + ItemStack donation = creator.createDonationItem(fixture.player, display); + assertLore(donation, "100.00d", true); + assertLore(donation, "10.00d", true); + assertLore(donation, "110.00d", true); + assertLore(donation, "Only the guild leader", true); + display.setLeader("Leader"); + assertLore(creator.createDonationItem(fixture.player, display), "Click to set", true); + assertLore(creator.createLoansItem(fixture.player, display), "Loans Given: 0", true); + assertLore(creator.createLoansItem(fixture.player, display), "Click to View Details", true); + } + + @Test + void tradeBreakdownShowsPenaltiesTopContributorsAndOnlyFiveKnownGuilds() { + Guild display = displayGuild(guild); + TradeBreakdown trade = display.getTradeBreakdown(); + trade.setIncome(100); + trade.setUpkeep(7); + trade.setTariffs(3); + trade.setTradePower(20); + display.getPillageHits().add(new StabilityModifier("Pillage", -25, 1)); + when(GovernmentIncompatibility.factor(display)).thenReturn(.75); + for (int n = 0; n < 12; n++) { + Faction f = mock(Faction.class); + when(f.getName()).thenReturn("Contributor " + n); + trade.registerIncome(f, 12 - n); + trade.registerTariffs(f, n == 0 ? 2 : 0); + } + List<Guild> ranked = new ArrayList<>(); + Guild hidden = mock(Guild.class); + when(ranker.visibleGuildValue(fixture.player, hidden, RankType.INCOME)).thenReturn(null); + ranked.add(hidden); + for (int n = 0; n < 6; n++) { + Guild entry = mock(Guild.class, RETURNS_DEEP_STUBS); + when(entry.getName()).thenReturn("Visible " + n); + when(entry.getId()).thenReturn("visible" + n); + when(entry.getFaction()).thenReturn(fixture.host); + when(entry.getLedger().getNetIncome()).thenReturn(10.0 + n); + when(ranker.visibleGuildValue(fixture.player, entry, RankType.INCOME)).thenReturn(10.0 + n); + ranked.add(entry); + } + when(ranker.getVisibleRankedGuildList(fixture.player, RankType.INCOME)).thenReturn(ranked); + ItemStack item = creator.createMenuItem(fixture.player, display, MenuItemType.TRADE_BREAKDOWN); + assertLore(item, "Strained Government", true); + assertLore(item, "75.0%", true); + assertLore(item, "Pillage", true); + assertLore(item, "Contributor 9", true); + assertLore(item, "Contributor 10", false); + assertLore(item, "2.0", true); + assertLore(item, "Visible 4", true); + assertLore(item, "Visible 5", false); + Faction foreign = mock(Faction.class); + when(ranked.get(1).getFaction()).thenReturn(foreign); + IntelligenceReport report = mock(IntelligenceReport.class); + when(report.display("Guild:visible0:Income")).thenReturn("8–12"); + intelligence.when(() -> EspionageService.report(fixture.player, foreign)).thenReturn(report); + assertLore( + creator.createMenuItem(fixture.player, display, MenuItemType.TRADE_BREAKDOWN), + "8–12d/day", + true); + Guild base = fixture.guild("realm", 0); + assertLore( + creator.createMenuItem(fixture.player, base, MenuItemType.TRADE_BREAKDOWN), + "State Output", + true); + display.setHost(null); + assertLore( + creator.createMenuItem(fixture.player, display, MenuItemType.TRADE_BREAKDOWN), + "Strained Government", + false); + } + + @Test + void iconOverridesRetainMenuContentAndInactivityDoesNotHideUpgradeLevels() { + ItemStack icon = new ItemStack(Material.PLAYER_HEAD); + ItemMeta meta = icon.getItemMeta(); + LegacyModelData.set(meta, 123); + icon.setItemMeta(meta); + when(IconGetter.hasIcon("LEADER")).thenReturn(true); + when(IconGetter.getIcon("LEADER")).thenReturn(icon); + ItemStack leader = creator.createMenuItem(fixture.player, guild, MenuItemType.LEADER); + assertEquals(123, LegacyModelData.get(leader.getItemMeta())); + assertTrue(ChatColor.stripColor(leader.getItemMeta().getDisplayName()).contains("Leader")); + when(ReportedMenus.guildLeader(fixture.player, guild)).thenReturn("§7Unknown"); + assertNull( + ((org.bukkit.inventory.meta.SkullMeta) + creator.createMenuItem(fixture.player, guild, MenuItemType.LEADER).getItemMeta()) + .getOwningPlayer()); + assertEquals( + Material.DIRT, + creator.createMenuItem(fixture.player, guild, MenuItemType.PRESTIGE).getType()); + when(InactivityService.guildPercent(guild)).thenReturn(40); + assertLore(creator.createUpgradesItem(fixture.player, guild), "Inactive output -40%", true); + } + + @Test + void ledgerSourcesSortCapAndSeparateTodayFromHistoricalTotals() { + Guild display = displayGuild(guild); + Ledger ledger = display.getLedger(); + LedgerHistory history = ledger.getHistory(); + Map<String, Double> taxes = new LinkedHashMap<>(); + for (int n = 0; n < 7; n++) taxes.put("Citizen" + n, (double) n); + taxes.put("Negative", -2.0); + when(ledger.getCitizenTaxesCopy()).thenReturn(taxes); + history.closeDay(Map.of(LedgerHistory.Source.CITIZENS, Map.of("Yesterday", 15.0))); + history.addDeposit("Leader", 25); + history.addDeposit("Alice", 10); + ItemStack citizens = creator.createLedgerCitizensItem(display); + assertEquals(Material.PLAYER_HEAD, citizens.getType()); + assertLore(citizens, "Citizen6", true); + assertLore(citizens, "Citizen1", false); + assertLore(citizens, "Yesterday", true); + assertLore(citizens, "Negative", false); + when(ledger.getCitizenTaxesCopy()).thenReturn(Map.of("Zero", 0.0)); + assertLore(creator.createLedgerCitizensItem(display), "No citizen taxes", true); + Guild paying = displayGuild(fixture.guild("guild", 0)); + when(paying.getLedger().getIncome(Cashflow.GUILD_PAYMENTS)).thenReturn(-12.0); + Guild realm = fixture.guild("realm", 0); + when(fixture.host.getGuildHandler().getGuilds()).thenReturn(List.of(realm, paying, display)); + assertLore(creator.createLedgerGuildsItem(display), "Merchants", true); + assertLore(creator.createLedgerGuildsItem(display), "+12.00d", true); + Faction subject = mock(Faction.class); + when(subject.getName()).thenReturn("Northern Realm"); + when(subject.getOrCreateMainGuild()).thenReturn(paying); + try (MockedStatic<RelationManager> relations = mockStatic(RelationManager.class)) { + relations.when(() -> RelationManager.getSubjects(fixture.host)).thenReturn(List.of(subject)); + assertLore(creator.createLedgerVassalsItem(display), "No vassal income", true); + when(paying.getLedger().getIncome(Cashflow.OVERLORD_TAX)).thenReturn(-9.0); + assertLore(creator.createLedgerVassalsItem(display), "Northern Realm", true); + assertLore(creator.createLedgerVassalsItem(display), "+9.00d", true); + } + ItemStack deposits = creator.createLedgerDepositsItem(display); + assertLore(deposits, "not part of daily income", true); + assertLore(deposits, "+35.00d", true); + assertLore(deposits, "Leader", true); + Faction payer = mock(Faction.class); + when(payer.getId()).thenReturn("payer"); + when(payer.getName()).thenReturn("Tributary"); + when(payer.getOrCreateMainGuild()).thenReturn(paying); + when(paying.getLedger().getInternalTaxableIncome()).thenReturn(100.0); + Faction stranger = mock(Faction.class); + when(stranger.getId()).thenReturn("stranger"); + when(payer.getModifiers()) + .thenReturn( + List.of( + new FactionModifier(FactionModifiers.TRIBUTE, 10), + new FactionModifier(fixture.host, FactionModifiers.STABILITY_INFLUENCE, 10), + new FactionModifier(stranger, FactionModifiers.TRIBUTE, 10), + new FactionModifier(fixture.host, FactionModifiers.TRIBUTE, 15))); + Faction noGuild = mock(Faction.class); + when(noGuild.getId()).thenReturn("empty"); + fixture + .factions + .when(FactionManager::getCopy) + .thenReturn(Arrays.asList(null, fixture.host, noGuild, payer)); + assertLore(creator.createLedgerTributesItem(display), "Tributary", true); + assertLore(creator.createLedgerTributesItem(display), "+15.00d", true); + when(paying.getLedger().getInternalTaxableIncome()).thenReturn(0.0); + assertLore(creator.createLedgerTributesItem(display), "No tributes", true); + Guild hostless = mock(Guild.class); + fixture + .factions + .when(FactionManager::getAllGuilds) + .thenReturn(Arrays.asList(null, hostless, paying, display)); + paying.getTradeBreakdown().registerTariffs(fixture.host, 8); + assertLore(creator.createLedgerTariffsItem(display), "+8.00d", true); + assertLore(creator.createLedgerTariffsItem(display), "The Realm", true); + } + + @Test + void relocationElevationAndEvictionItemsExplainTheirAvailableAndUnavailableActions() { + Province province = mock(Province.class); + when(fixture.provinces.get(9)).thenReturn(province); + Faction foreign = mock(Faction.class); + when(foreign.getId()).thenReturn("foreign"); + when(foreign.getName()).thenReturn("Other Realm"); + RelationType relation = mock(RelationType.class); + when(relation.getName()).thenReturn("vassal"); + try (MockedStatic<RestServer> rest = mockStatic(RestServer.class); + MockedStatic<RelationLoader> relations = mockStatic(RelationLoader.class)) { + rest.when(() -> RestServer.getProvince(fixture.player)).thenReturn(9); + relations.when(RelationLoader::getElevationTarget).thenReturn(relation); + ItemStack cross = creator.createRelocateItem(fixture.player, foreign, guild); + assertLore(cross, "changes the faction", true); + assertLore(cross, "100.0d", true); + assertLore( + creator.createRelocateItem(fixture.player, fixture.host, guild), + "outside The Realm", + true); + when(fixture.host.hasProvince(9)).thenReturn(true); + assertLore( + creator.createRelocateItem(fixture.player, fixture.host, guild), + "within The Realm", + true); + assertLore(creator.createElevationItem(fixture.player, guild), "Unavailable", true); + when(fixture.host.hasFactionRule(Rules.CAN_HAVE_VASSALS)).thenReturn(true); + assertLore(creator.createElevationItem(fixture.player, guild), "Click to Elevate", true); + when(fixture.host.getGovernment().getMovementByMember("Leader")).thenReturn(null); + assertLore(creator.createEvictionItem(fixture.player, guild), "Click to Evict", true); + assertLore( + creator.createEvictionItem(fixture.player, fixture.guild("realm", 0)), + "Unavailable", + true); + } + } + + @Test + void guildListPagesReserveControlsCycleRankingAndOpenTheSelectedGuild() { + List<Guild> listed = new ArrayList<>(); + for (int n = 0; n < 50; n++) { + Guild entry = mock(Guild.class); + when(entry.getId()).thenReturn("guild" + n); + listed.add(entry); + } + GuildCreator listing = mock(GuildCreator.class); + when(listing.createListItem(eq(fixture.player), any())) + .thenAnswer( + call -> { + ItemStack item = new ItemStack(Material.PAPER); + ItemMeta meta = item.getItemMeta(); + meta.setDisplayName(((Guild) call.getArgument(1)).getId()); + meta.getPersistentDataContainer() + .set( + new NamespacedKey(fixture.ui.plugin, "id"), + PersistentDataType.STRING, + ((Guild) call.getArgument(1)).getId()); + item.setItemMeta(meta); + return item; + }); + view.creator = listing; + try (MockedConstruction<FactionRanker> rankings = + mockConstruction( + FactionRanker.class, + (mock, context) -> + when(mock.getVisibleRankedGuildList(eq(fixture.player), any())) + .thenReturn(listed))) { + view.guildList(fixture.player); + Inventory first = fixture.player.getOpenInventory().getTopInventory(); + assertEquals("guild0", first.getItem(0).getItemMeta().getDisplayName()); + assertEquals("guild46", first.getItem(52).getItemMeta().getDisplayName()); + assertNull(first.getItem(17)); + assertNotNull(first.getItem(53)); + assertNull(first.getItem(45)); + manager.clickButton(fixture.ui.click(fixture.player, 53)); + assertEquals(1, GuildView.currentPage.get(fixture.player)); + Inventory second = fixture.player.getOpenInventory().getTopInventory(); + assertEquals("guild47", second.getItem(0).getItemMeta().getDisplayName()); + assertNull(second.getItem(53)); + assertNotNull(second.getItem(45)); + manager.clickButton(fixture.ui.click(fixture.player, 45)); + assertEquals(0, GuildView.currentPage.get(fixture.player)); + for (RankType expected : + List.of(RankType.MEMBERS, RankType.TRADE_POWER, RankType.INCOME, RankType.WEALTH)) { + manager.clickButton(fixture.ui.click(fixture.player, 8)); + assertEquals(expected, GuildView.currentRanking.get(fixture.player)); + assertEquals(0, GuildView.currentPage.get(fixture.player)); + } + Guild selected = listed.getFirst(); + fixture.factions.when(() -> FactionManager.getGuildByString("guild0")).thenReturn(selected); + view = spy(view); + manager.guildView = view; + doNothing().when(view).guildView(fixture.player, selected); + manager.clickButton(fixture.ui.click(fixture.player, 0)); + verify(view).guildView(fixture.player, selected); + Inventory existing = fixture.player.getOpenInventory().getTopInventory(); + clearInvocations(fixture.player); + view.guildList(fixture.player, existing); + verify(fixture.player, never()).openInventory(any(Inventory.class)); + } + } + + @Test + void exactGuildRenderingUsesRealBranchControlsAndCurrentMembership() { + Guild display = displayGuild(guild); + fixture.factions.when(() -> FactionManager.getGuildByString("merchants")).thenReturn(display); + Upgrade upgrade = new Upgrade(fixture.upgrade("roads", false, 1), 1); + when(display.getUpgrades()).thenReturn(List.of(upgrade)); + BranchIncomePreview.Prepared prepared = mock(BranchIncomePreview.Prepared.class); + when(BranchIncomePreview.prepare(fixture.provinces)).thenReturn(prepared); + view.setProvinceManager(null); + view.guildView(fixture.player, display, true); + Inventory inventory = fixture.player.getOpenInventory().getTopInventory(); + SFInventoryHolder holder = (SFInventoryHolder) inventory.getHolder(); + assertEquals("merchants", holder.getId()); + assertTrue(holder.getFlag()); + assertEquals("commerce", payload(inventory.getItem(29), Keys.BRANCH_ID)); + assertEquals(true, flag(inventory.getItem(20))); + assertEquals(false, flag(inventory.getItem(38))); + assertNotNull(inventory.getItem(1)); + assertNotNull(inventory.getItem(28)); + assertNotNull(inventory.getItem(17)); + assertNotNull(inventory.getItem(8)); + assertNotNull(inventory.getItem(19)); + verify(fixture.provinces).recalculate(); + verify(fixture.provinces).getIncome(display); + verify(fixture.provinces).recalculateIfNeeded(); + previewTasks.verify( + () -> + BranchIncomePreviewService.schedule( + fixture.player, inventory, 20, prepared, display, display.getBranch(0), 1)); + previewTasks.verify( + () -> + BranchIncomePreviewService.schedule( + fixture.player, inventory, 38, prepared, display, display.getBranch(0), -1)); + display.setLeader("Other"); + display.kick("Leader"); + display.setCapital(-1); + view.guildView(fixture.player, display, inventory); + assertNull(inventory.getItem(1)); + assertNull(inventory.getItem(28)); + assertNull(inventory.getItem(20)); + assertNull(inventory.getItem(38)); + assertNotNull(inventory.getItem(29)); + } + + @Test + void ledgerRenderingCentresRealmSourcesAndKeepsTheBackDestination() { + Guild display = displayGuild(fixture.guild("realm", 0)); + try (MockedStatic<RelationManager> relations = mockStatic(RelationManager.class)) { + view.ledgerView(fixture.player, display, null, true); + Inventory inventory = fixture.player.getOpenInventory().getTopInventory(); + SFInventoryHolder holder = (SFInventoryHolder) inventory.getHolder(); + assertTrue(holder.getFlag()); + assertEquals(SFGUI.LEDGER_VIEW, holder.getType()); + for (int slot = 10; slot <= 15; slot++) assertNotNull(inventory.getItem(slot)); + assertNull(inventory.getItem(9)); + assertNull(inventory.getItem(16)); + assertNotNull(inventory.getItem(31)); + clearInvocations(fixture.player); + view.ledgerView(fixture.player, display, inventory); + verify(fixture.player, never()).openInventory(any(Inventory.class)); + Guild ordinary = displayGuild(guild); + view.ledgerView(fixture.player, ordinary, null); + assertNull(fixture.player.getOpenInventory().getTopInventory().getItem(10)); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void upgradeRenderingShowsNineDefinitionsAndThreeQueueEntriesWithLeaderOnlyControls( + boolean leader) { + for (int n = 0; n < 10; n++) fixture.upgrade("upgrade" + n, false, 2); + guild = fixture.guild("guild", 0); + if (!leader) guild.setLeader("Other"); + fixture.factions.when(() -> FactionManager.getGuildByString("merchants")).thenReturn(guild); + List<UpgradeExpansion> queue = new ArrayList<>(); + for (int n = 0; n < 4; n++) queue.add(new UpgradeExpansion(guild.getUpgrade("upgrade" + n), 2)); + guild.setUpgradeQueue(queue); + view.upgradeView(fixture.player, guild); + Inventory inventory = fixture.player.getOpenInventory().getTopInventory(); + assertEquals("upgrade0", payload(inventory.getItem(9), Keys.STRING_KEY)); + assertEquals("upgrade8", payload(inventory.getItem(17), Keys.STRING_KEY)); + assertEquals(leader, inventory.getItem(0) != null); + assertEquals(leader, inventory.getItem(18) != null); + assertNotNull(inventory.getItem(39)); + assertNotNull(inventory.getItem(41)); + assertNull(inventory.getItem(42)); + assertEquals( + QueueCancelPayload.guildUpgrade("merchants", 2), + payload(inventory.getItem(41), Keys.QUEUE_CANCEL)); + assertNotNull(inventory.getItem(53)); + } + + @ParameterizedTest + @ValueSource(strings = {"success", "failed", "offline_success", "offline_failed", "deleted"}) + void bannerCallbackHandlesSuccessFailureOfflinePlayersAndDeletedGuilds(String outcome) { + Inventory inventory = open(); + inventory.setItem(28, new ItemStack(Material.PAPER)); + List<Consumer<List<String>>> pending = new ArrayList<>(); + List<ItemStack> refreshed = new ArrayList<>(); + try (MockedStatic<BannerFetcher> banners = mockStatic(BannerFetcher.class)) { + banners + .when(() -> BannerFetcher.fetch(eq("guild:merchants"), any())) + .thenAnswer( + call -> { + pending.add(call.getArgument(1)); + return null; + }); + banners + .when( + () -> + BannerFetcher.refreshOpenView( + eq(fixture.player), eq(SFGUI.GUILD_VIEW), eq("merchants"), any())) + .thenAnswer( + call -> { + refreshed.add(((java.util.function.Supplier<ItemStack>) call.getArgument(3)).get()); + return null; + }); + manager.clickButton(fixture.ui.click(fixture.player, 28)); + assertEquals(1, pending.size()); + if (outcome.startsWith("offline")) when(fixture.player.isOnline()).thenReturn(false); + if (outcome.equals("deleted")) + fixture.factions.when(() -> FactionManager.getGuildByString("merchants")).thenReturn(null); + boolean success = outcome.endsWith("success"); + pending.getFirst().accept(success ? new ArrayList<>(List.of("red")) : null); + assertEquals(success ? List.of("red") : List.of("white"), guild.getBannerPatterns()); + assertEquals(success ? 1 : 0, refreshed.size()); + if (success) assertEquals(Material.RED_BANNER, refreshed.getFirst().getType()); + verify(fixture.player, times(outcome.equals("failed") ? 1 : 0)) + .sendMessage("§cCould not generate a banner right now. Try again later."); + verify(fixture.player, times(outcome.equals("success") ? 1 : 0)) + .playSound(fixture.player, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); + } + } + + @ParameterizedTest + @ValueSource(strings = {"not_leader", "base"}) + void onlyAnOrdinaryGuildLeaderCanRequestNewBannerPatterns(String denied) { + if (denied.equals("base")) { + guild = fixture.guild("realm", 0); + fixture.factions.when(() -> FactionManager.getGuildByString("merchants")).thenReturn(guild); + } else guild.setLeader("Other"); + Inventory inventory = open(); + inventory.setItem(28, new ItemStack(Material.PAPER)); + try (MockedStatic<BannerFetcher> banners = mockStatic(BannerFetcher.class)) { + manager.clickButton(fixture.ui.click(fixture.player, 28)); + banners.verifyNoInteractions(); + verify(fixture.player) + .sendMessage("§cYou must be the leader of a guild to change the banner!"); + } + } + + @Test + void bannerTakeCopiesTheGuildBannerAndRejectsStaleMembership() { + Inventory inventory = open(); + inventory.setItem(1, creator.createMenuItem(fixture.player, guild, MenuItemType.BANNER_GET)); + manager.clickButton(fixture.ui.click(fixture.player, 1)); + assertEquals(Material.WHITE_BANNER, fixture.player.getInventory().getItem(0).getType()); + assertNotSame(guild.getBanner(), fixture.player.getInventory().getItem(0)); + fixture.player.getInventory().clear(); + guild.kick("Leader"); + manager.clickButton(fixture.ui.click(fixture.player, 1)); + assertNull(fixture.player.getInventory().getItem(0)); + } + + @ParameterizedTest + @ValueSource( + strings = { + "upgrade", + "insufficient", + "downgrade_zero", + "no_flag", + "not_leader", + "no_branch_id" + }) + void branchClicksApplyOnlyAuthorizedAffordableWellFormedActions(String scenario) { + quietRendering(); + guild.getBank().setWealth(scenario.equals("insufficient") ? 0.0 : 1000.0); + guild.updateWealth(); + Branch branch = guild.getBranch(0); + if (scenario.equals("downgrade_zero")) branch.levelDown(); + if (scenario.equals("not_leader")) guild.setLeader("Other"); + Inventory inventory = open(); + ItemStack item = + scenario.equals("downgrade_zero") + ? creator.createBranchDowngradeItem(fixture.player, guild, branch) + : creator.createBranchUpgradeItem(fixture.player, guild, branch); + ItemMeta meta = item.getItemMeta(); + if (scenario.equals("no_flag")) meta.getPersistentDataContainer().remove(Keys.BOOLEAN_FLAG); + if (scenario.equals("no_branch_id")) meta.getPersistentDataContainer().remove(Keys.BRANCH_ID); + item.setItemMeta(meta); + inventory.setItem(20, item); + int before = branch.getLevel(); + double balance = guild.getBank().getWealth(); + InventoryClickEvent click = fixture.ui.click(fixture.player, 20); + manager.clickButton(click); + assertTrue(click.isCancelled()); + assertEquals(before + (scenario.equals("upgrade") ? 1 : 0), branch.getLevel()); + assertEquals(balance - (scenario.equals("upgrade") ? 200 : 0), guild.getBank().getWealth()); + if (scenario.equals("upgrade")) { + verify(fixture.provinces).recalculateForSingleGuild(guild, true); + verify(view).guildView(fixture.player, guild, inventory); + } + if (scenario.equals("insufficient")) + verify(fixture.player).sendMessage("§cCannot afford to upgrade"); + } + + @ParameterizedTest + @ValueSource( + strings = { + "upgrade", + "full", + "downgrade", + "zero", + "missing", + "no_flag", + "not_leader", + "queue", + "queue_denied", + "no_key" + }) + void upgradeClicksRespectOwnershipLimitsAndQueueCancellation(String scenario) { + fixture.upgrade("roads", false, 2); + guild = fixture.guild("guild", 0); + fixture.factions.when(() -> FactionManager.getGuildByString("merchants")).thenReturn(guild); + quietRendering(); + InventoryManager callbacks = routingBoundary(); + Upgrade upgrade = guild.getUpgrade("roads"); + if (scenario.equals("downgrade")) upgrade.setLevel(2); + if (scenario.equals("full")) for (int n = 0; n < 3; n++) guild.enqueueUpgrade(upgrade); + if (scenario.equals("not_leader") || scenario.equals("queue_denied")) guild.setLeader("Other"); + Inventory inventory = open(SFGUI.UPGRADE_VIEW, "§7Upgrade View"); + ItemStack item; + if (scenario.startsWith("queue")) + item = creator.createUpgradeQueueItem(new UpgradeExpansion(upgrade, 2), 0, guild); + else if (scenario.equals("downgrade") || scenario.equals("zero")) + item = creator.createUpgradeDowngradeItem(fixture.player, guild, upgrade); + else item = creator.createUpgradeUpgradeItem(fixture.player, guild, upgrade); + ItemMeta meta = item.getItemMeta(); + if (scenario.equals("missing")) + meta.getPersistentDataContainer().set(Keys.STRING_KEY, PersistentDataType.STRING, "removed"); + if (scenario.equals("no_flag")) meta.getPersistentDataContainer().remove(Keys.BOOLEAN_FLAG); + if (scenario.equals("no_key")) meta.getPersistentDataContainer().remove(Keys.STRING_KEY); + item.setItemMeta(meta); + inventory.setItem(0, item); + manager.clickButton(fixture.ui.click(fixture.player, 0)); + assertEquals( + scenario.equals("upgrade") ? 1 : scenario.equals("full") ? 3 : 0, + guild.getUpgradeQueue().size()); + assertEquals(scenario.equals("downgrade") ? 1 : 0, upgrade.getLevel()); + if (scenario.equals("full")) verify(fixture.player).sendMessage("§cUpgrade queue is full"); + verify(callbacks, times(scenario.equals("queue") ? 1 : 0)) + .openQueueCancelConfirm( + fixture.player, + fixture.host, + QueueCancelPayload.guildUpgrade("merchants", 0), + "§eCancel queued upgrade?"); + } + + @ParameterizedTest + @ValueSource(ints = {14, 16, 19, 25, 17, 8, 37}) + void authorizedGuildNavigationUsesTheRegisteredManagers(int slot) { + quietRendering(); + InventoryManager callbacks = routingBoundary(); + if (slot == 19) guild.setCompany(mock(MercenaryCompany.class)); + Inventory inventory = open(); + inventory.setItem(slot, new ItemStack(Material.PAPER)); + manager.clickButton(fixture.ui.click(fixture.player, slot)); + switch (slot) { + case 14 -> verify(view).ledgerView(fixture.player, guild, null, true); + case 16 -> verify(view).upgradeView(fixture.player, guild); + case 19 -> verify(callbacks).companyView(fixture.player, guild); + case 25 -> verify(callbacks).loanMainView(fixture.player, guild); + case 17 -> verify(callbacks).setChangingDividend(fixture.player, guild); + case 8 -> verify(callbacks).setChangingDonation(fixture.player, guild); + case 37 -> verify(callbacks).factionView(fixture.player, fixture.host); + default -> fail(); + } + } + + @ParameterizedTest + @ValueSource(ints = {16, 17, 8, 19}) + void unauthorizedAndMissingCompanyNavigationDoesNotStartAChange(int slot) { + quietRendering(); + InventoryManager callbacks = routingBoundary(); + if (slot != 19) guild.setLeader("Other"); + Inventory inventory = open(); + inventory.setItem(slot, new ItemStack(Material.PAPER)); + manager.clickButton(fixture.ui.click(fixture.player, slot)); + verify(callbacks, never()).setChangingDividend(any(), any()); + verify(callbacks, never()).setChangingDonation(any(), any()); + verify(callbacks, never()).companyView(any(), any()); + if (slot == 16) verify(view, never()).upgradeView(any(), any()); + else if (slot == 19) + verify(fixture.player) + .sendMessage("§7Use §e/company found <name> §7to found a mercenary company."); + } + + @ParameterizedTest + @ValueSource(ints = {16, 19, 25, 20}) + void foreignGuildClicksRouteOnlyToReportedReadOnlySubmenus(int slot) { + quietRendering(); + InventoryManager callbacks = routingBoundary(); + intelligence + .when(() -> EspionageService.canViewExact(fixture.player, fixture.host)) + .thenReturn(false); + Inventory inventory = open(); + inventory.setItem(slot, new ItemStack(Material.PAPER)); + manager.clickButton(fixture.ui.click(fixture.player, slot)); + if (slot == 16) verify(view).upgradeView(fixture.player, guild); + if (slot == 19) verify(callbacks.companyView).companyView(fixture.player, guild); + if (slot == 25) verify(callbacks.loanView).loanMainView(fixture.player, guild); + assertEquals(1, guild.getSize()); + assertEquals(0, guild.getBank().getWealth()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "base", + "disabled", + "no_target", + "missing_province", + "invalid", + "sea", + "poor", + "cross_prompt", + "cross_request", + "intra_prompt", + "intra_failed", + "intra_success" + }) + void relocationClicksValidateTheCurrentDestinationFundsAndNamingPrompt(String scenario) { + if (scenario.equals("base")) { + guild = fixture.guild("realm", 0); + fixture.factions.when(() -> FactionManager.getGuildByString("merchants")).thenReturn(guild); + } + quietRendering(); + guild.getBank().setWealth(scenario.equals("poor") ? 0.0 : 1000.0); + if (scenario.equals("disabled")) Cache.provincesEnabled = false; + Faction target = scenario.startsWith("cross") ? mock(Faction.class) : fixture.host; + if (target != fixture.host) when(target.getId()).thenReturn("other"); + if (!scenario.equals("no_target")) + when(map.getRelocationTarget(fixture.player)).thenReturn(target); + Province province = mock(Province.class); + when(province.isValid()).thenReturn(!scenario.equals("invalid")); + when(province.isSea()).thenReturn(scenario.equals("sea")); + if (!scenario.equals("missing_province")) when(fixture.provinces.get(9)).thenReturn(province); + try (MockedStatic<RestServer> rest = mockStatic(RestServer.class); + MockedStatic<RelocationPrompt> prompts = mockStatic(RelocationPrompt.class)) { + rest.when(() -> RestServer.getProvince(fixture.player)).thenReturn(9); + boolean cross = scenario.startsWith("cross"); + boolean prompted = scenario.endsWith("prompt"); + prompts + .when(() -> RelocationPrompt.begin(fixture.player, guild, target, 9, cross, 100.0)) + .thenReturn(prompted); + prompts + .when( + () -> + RelocationPrompt.completeIntraFactionRelocate( + fixture.player, guild, target, 9, null, 100.0)) + .thenReturn(scenario.equals("intra_success")); + Inventory inventory = open(); + inventory.setItem(34, new ItemStack(Material.PAPER)); + InventoryClickEvent click = fixture.ui.click(fixture.player, 34); + manager.clickButton(click); + assertTrue(click.isCancelled()); + fixture.factions.verify( + () -> FactionManager.requestRelocation(fixture.player, guild, target, 9, null), + times(scenario.equals("cross_request") ? 1 : 0)); + prompts.verify( + () -> + RelocationPrompt.completeIntraFactionRelocate( + fixture.player, guild, target, 9, null, 100.0), + times(scenario.equals("intra_failed") || scenario.equals("intra_success") ? 1 : 0)); + verify( + view, + times(scenario.equals("intra_success") || scenario.equals("cross_request") ? 1 : 0)) + .guildView(fixture.player, guild, inventory); + if (scenario.equals("poor")) + verify(fixture.player).sendMessage("§cCannot afford to relocate"); + assertEquals(scenario.equals("poor") ? 0.0 : 1000.0, guild.getBank().getWealth()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"not_leader", "ineligible", "poor", "success"}) + void elevationClicksRequireHostLeadershipEligibilityAndAdministrativePower(String scenario) { + when(fixture.host.isLeader("Leader")).thenReturn(!scenario.equals("not_leader")); + when(fixture.host.hasFactionRule(Rules.CAN_HAVE_VASSALS)) + .thenReturn(!scenario.equals("ineligible")); + when(fixture.host.getGovernment().getPower()) + .thenReturn(scenario.equals("poor") ? 0.0 : 1000.0); + Inventory inventory = open(); + inventory.setItem(43, new ItemStack(Material.PAPER)); + double cost = guild.getElevationCost(); + manager.clickButton(fixture.ui.click(fixture.player, 43)); + verify(fixture.host.getGovernment(), times(scenario.equals("success") ? 1 : 0)) + .spendPower(cost); + fixture.factions.verify( + () -> FactionManager.requestElevation(fixture.player, guild), + times(scenario.equals("success") ? 1 : 0)); + verify(fixture.player, times(scenario.equals("success") ? 1 : 0)).closeInventory(); + if (scenario.equals("poor")) verify(fixture.player).sendMessage("§cCannot afford to elevate"); + } + + @ParameterizedTest + @ValueSource(strings = {"not_host_leader", "involved_in_movement", "poor"}) + void evictionDenialsDoNotChangeGuildOwnershipOrGovernmentPower(String scenario) { + guild.setLeader("Other"); + when(fixture.host.isLeader("Leader")).thenReturn(!scenario.equals("not_host_leader")); + if (!scenario.equals("involved_in_movement")) + when(fixture.host.getGovernment().getMovementByMember("Other")).thenReturn(null); + when(fixture.host.getGovernment().getPower()) + .thenReturn(scenario.equals("poor") ? 0.0 : 1000.0); + Inventory inventory = open(); + inventory.setItem(34, new ItemStack(Material.PAPER)); + manager.clickButton(fixture.ui.click(fixture.player, 34)); + assertSame(fixture.host, guild.getFaction()); + verify(fixture.host.getGovernment(), never()).spendPower(anyDouble()); + verify(fixture.host.getGuildHandler(), never()).removeGuild(anyString()); + if (scenario.equals("poor")) verify(fixture.player).sendMessage("§cCannot afford to evict"); + } + + @Test + void foreignRenderingDelegatesToIntelligenceViewsWithoutExposingExactLedgerData() { + intelligence + .when(() -> EspionageService.canViewExact(fixture.player, fixture.host)) + .thenReturn(false); + try (MockedStatic<EspionageView> foreign = mockStatic(EspionageView.class)) { + Inventory inventory = open(); + view.guildView(fixture.player, guild, inventory); + foreign.verify(() -> EspionageView.guild(inventory, fixture.player, guild, manager)); + view.ledgerView(fixture.player, guild, null); + foreign.verify(() -> EspionageView.foreignLedger(fixture.player, guild, manager)); + ItemStack redacted = new ItemStack(Material.WRITABLE_BOOK); + foreign.when(() -> EspionageView.ledgerItem(null, guild)).thenReturn(redacted); + assertSame(redacted, creator.createLedgerItem(fixture.player, guild)); + } + view.upgradeView(fixture.player, guild); + Inventory inventory = fixture.player.getOpenInventory().getTopInventory(); + // The reported renderer owns the contents. Its delegate receives the same guild and viewer. + reported.verify(() -> ReportedMenus.upgrades(inventory, fixture.player, guild, manager)); + org.mockito.Mockito.verifyNoInteractions(fixture.provinces); + assertEquals(SFGUI.UPGRADE_VIEW, ((SFInventoryHolder) inventory.getHolder()).getType()); + } + + @Test + void renderingAdministrativeActionsAndRelocationUsesTheCurrentHostAndDestination() { + Guild display = displayGuild(guild); + when(map.getRelocationTarget(fixture.player)).thenReturn(fixture.host); + RelationType relation = mock(RelationType.class); + when(relation.getName()).thenReturn("vassal"); + try (MockedStatic<RelationLoader> definitions = mockStatic(RelationLoader.class); + MockedStatic<RestServer> rest = mockStatic(RestServer.class)) { + definitions.when(RelationLoader::getElevationTarget).thenReturn(relation); + view.guildView(fixture.player, display); + Inventory inventory = fixture.player.getOpenInventory().getTopInventory(); + assertFalse(((SFInventoryHolder) inventory.getHolder()).getFlag()); + assertEquals(Material.FILLED_MAP, inventory.getItem(34).getType()); + assertNull(inventory.getItem(43)); + when(fixture.host.isLeader("Leader")).thenReturn(true); + view.guildView(fixture.player, display, inventory); + assertTrue( + ChatColor.stripColor(inventory.getItem(34).getItemMeta().getDisplayName()) + .contains("Evict")); + assertNotNull(inventory.getItem(43)); + Guild base = displayGuild(fixture.guild("realm", 0)); + view.guildView(fixture.player, base, inventory); + assertNull(inventory.getItem(8)); + assertNull(inventory.getItem(17)); + assertNull(inventory.getItem(28)); + } + } + + @Test + void absentGuildAndUnrelatedInventoriesLeaveActionsUntouched() { + Inventory inventory = open(); + inventory.setItem(20, new ItemStack(Material.PAPER)); + fixture.factions.when(() -> FactionManager.getGuildByString("merchants")).thenReturn(null); + manager.clickButton(fixture.ui.click(fixture.player, 20)); + assertEquals(1, guild.getSize()); + Inventory upgrades = open(SFGUI.UPGRADE_VIEW, "§7Upgrade View"); + upgrades.setItem(0, new ItemStack(Material.PAPER)); + manager.clickButton(fixture.ui.click(fixture.player, 0)); + assertTrue(guild.getUpgradeQueue().isEmpty()); + Inventory foreign = fixture.ui.inventory(null, 9, "Foreign plugin"); + fixture.player.openInventory(foreign); + InventoryClickEvent click = fixture.ui.click(fixture.player, 0); + view.click(click, foreign, fixture.player); + assertFalse(click.isCancelled()); + } + + @Test + void evictionAppliesTheAdvertisedStabilityPenaltyToTheOriginalHost() { + guild.setLeader("GuildLeader"); + when(fixture.host.isLeader("Leader")).thenReturn(true); + when(fixture.host.getGovernment().getMovementByMember("GuildLeader")).thenReturn(null); + when(fixture.host.getGovernment().getPower()).thenReturn(1000.0); + double impact = guild.getStabilityEffect(); + Inventory inventory = open(); + inventory.setItem(34, new ItemStack(Material.PAPER)); + try (MockedConstruction<Faction> created = + mockConstruction( + Faction.class, withSettings().defaultAnswer(RETURNS_DEEP_STUBS), (f, context) -> {})) { + manager.clickButton(fixture.ui.click(fixture.player, 34)); + ArgumentCaptor<StabilityModifier> applied = ArgumentCaptor.forClass(StabilityModifier.class); + verify(fixture.host.getGovernment()).addStabilityModifier(applied.capture()); + assertEquals("Evicted Guild", applied.getValue().getName()); + assertEquals(-impact, applied.getValue().getModifier()); + } + } + + @Test + void configuredBranchGroupGapsDoNotHideLaterBranchesOrTheirControls() { + YamlConfiguration config = new YamlConfiguration(); + config.set("trade.group", 4); + config.set("trade.allowed-types", List.of("guild")); + config.set("trade.icon", "emerald.0"); + BranchLoader.map.clear(); + BranchLoader.map.put("trade", new Branch("trade", config.getConfigurationSection("trade"))); + GuildData data = fixture.data("guild", 2); + data.branches.getFirst().id = "trade"; + Guild display = displayGuild(new Guild(data, fixture.host)); + Inventory inventory = open(); + view.guildView(fixture.player, display, inventory); + assertNotNull(inventory.getItem(33)); + assertEquals("trade", payload(inventory.getItem(33), Keys.BRANCH_ID)); + assertEquals(true, flag(inventory.getItem(24))); + assertEquals(false, flag(inventory.getItem(42))); + } + + private void quietRendering() { + view = spy(view); + manager.guildView = view; + doNothing().when(view).guildView(any(), any(), any(Inventory.class)); + doNothing().when(view).upgradeView(any(), any(), any(Inventory.class)); + doNothing().when(view).upgradeView(any(), any()); + doNothing().when(view).ledgerView(any(), any(), any(), anyBoolean()); + } + + private InventoryManager routingBoundary() { + InventoryManager callbacks = mock(InventoryManager.class); + callbacks.companyView = mock(CompanyView.class); + callbacks.loanView = mock(LoanView.class); + view.inv = callbacks; + return callbacks; + } + + private Inventory open(SFGUI type, String title) { + Inventory inventory = fixture.ui.inventory(new SFInventoryHolder("merchants", type), 54, title); + fixture.player.openInventory(inventory); + return inventory; + } + + private Guild displayGuild(Guild original) { + Guild display = spy(original); + Ledger ledger = mock(Ledger.class); + when(ledger.getHistory()).thenReturn(new LedgerHistory()); + when(ledger.getDividendBreakdown()).thenReturn(DividendBreakdown.none()); + doReturn(ledger).when(display).getLedger(); + return display; + } + + private static String payload(ItemStack item, NamespacedKey key) { + return item.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); + } + + private static Boolean flag(ItemStack item) { + return item.getItemMeta() + .getPersistentDataContainer() + .get(Keys.BOOLEAN_FLAG, PersistentDataType.BOOLEAN); + } + + private static String lore(ItemStack item) { + return String.join( + "\n", item.getItemMeta().getLore().stream().map(ChatColor::stripColor).toList()); + } + + private static void assertLore(ItemStack item, String text, boolean present) { + assertEquals( + present, + lore(item).contains(text), + () -> "Expected " + text + " present=" + present + " in " + lore(item)); + } + + private Inventory open() { + Inventory inventory = + fixture.ui.inventory( + new SFInventoryHolder("merchants", SFGUI.GUILD_VIEW), 54, "§7Guild View"); + fixture.player.openInventory(inventory); + return inventory; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/InstallationMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/InstallationMenusCoverageTest.java new file mode 100644 index 00000000..ad2c18b5 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/InstallationMenusCoverageTest.java @@ -0,0 +1,579 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.espionage.EspionageService; +import net.tfminecraft.simplefactions.installation.Installation; +import net.tfminecraft.simplefactions.installation.InstallationConstruction; +import net.tfminecraft.simplefactions.installation.InstallationKind; +import net.tfminecraft.simplefactions.keys.Keys; +import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader; +import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.vehicles.berth.InstallationVehicleUnberthService; +import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; +import net.tfminecraft.simplefactions.war.freeze.PreparationFreeze; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.managers.VehicleManager; +import org.bukkit.ChatColor; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +class InstallationMenusCoverageTest { + private FactionDomainFixture fixture; + private Faction faction; + private Player leader; + private InventoryManager navigation; + private InstallationView view; + private PlayerVehicleRegistry registry; + private VehicleManager vehicles; + private InstallationVehicleUnberthService unberth; + private final List<MockedStatic<?>> scopes = new ArrayList<>(); + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + faction = fixture.saved("realm", "Leader"); + faction.getOrCreateMainGuild(); + faction.addProvince(10); + leader = fixture.player("Leader"); + navigation = mock(InventoryManager.class); + navigation.confirming = new HashMap<>(); + navigation.installationConfirmFromCommand = new HashMap<>(); + when(navigation.createBackButton(any())).thenAnswer(call -> new ItemStack(Material.BARRIER)); + view = new InstallationView(navigation); + registry = new PlayerVehicleRegistry(); + MockedStatic<SimpleFactions> plugin = mockStatic(SimpleFactions.class, CALLS_REAL_METHODS); + scopes.add(plugin); + plugin.when(SimpleFactions::getVehicleRegistry).thenReturn(registry); + vehicles = mock(VehicleManager.class); + when(vehicles.getOfflineLocation(anyString())).thenReturn(Optional.empty()); + when(vehicles.readStoredVehicle(anyString())).thenReturn(Optional.empty()); + scope(VehicleFramework.class).when(VehicleFramework::getVehicleManager).thenReturn(vehicles); + unberth = mock(InstallationVehicleUnberthService.class); + when(fixture.ui.plugin.getInstallationVehicleUnberthService()).thenReturn(unberth); + when(unberth.unberth(any(), anyString(), any(), anyString())) + .thenReturn( + new InstallationVehicleUnberthService.UnberthOutcome( + InstallationVehicleUnberthService.UnberthResult.NOT_BERTHED, null, null)); + MockedStatic<InstallationConfigLoader> config = scope(InstallationConfigLoader.class); + config.when(() -> InstallationConfigLoader.getMaximumLevel(any())).thenReturn(3); + config + .when(() -> InstallationConfigLoader.getDailyUpkeep(any(), anyInt())) + .thenAnswer(call -> 5.0 * (Integer) call.getArgument(1)); + config + .when(() -> InstallationConfigLoader.getConstructionTimeSeconds(any(), anyInt())) + .thenReturn(120); + config + .when(() -> InstallationConfigLoader.getCategorySlots(any(), anyInt())) + .thenReturn(Map.of("heavy_vehicles", 2)); + } + + @AfterEach + void close() { + try { + for (int i = scopes.size() - 1; i >= 0; i--) scopes.get(i).close(); + } finally { + fixture.close(); + } + } + + @Test + void deconstructButtonOpensTheConfirmationInsteadOfTreatingItsIdAsAVehicle() { + Installation fort = installation("fort", InstallationKind.FORT); + view.installationDetailView(leader, faction, fort.getId()); + Inventory menu = top(); + view.click(fixture.ui.click(leader, 11), menu, leader); + verify(navigation).confirmView(leader, faction, "installation", fort.getId()); + assertSame(faction, navigation.confirming.get(leader)); + assertEquals(false, navigation.installationConfirmFromCommand.get(leader)); + verifyNoInteractions(unberth); + assertSame(fort, faction.getInstallationHandler().getById(fort.getId())); + } + + @Test + void theReservedQueueSlotDoesNotHideAnOtherwiseVisibleInstallation() { + Set<String> expected = new HashSet<>(); + for (int i = 0; i < 29; i++) { + String id = String.format("fort_%02d", i); + faction.addProvince(10 + i); + Installation installation = + new Installation(id, id, InstallationKind.FORT, 10 + i, i * 100, 0, 1L); + faction.getInstallationHandler().acceptTransferred(installation); + expected.add(id); + } + view.installationsView(null, leader, faction, true); + Set<String> visible = new HashSet<>(); + for (ItemStack item : top().getContents()) { + if (item == null || !item.hasItemMeta()) continue; + String id = + item.getItemMeta() + .getPersistentDataContainer() + .get(Keys.STRING_KEY, PersistentDataType.STRING); + if (id != null) visible.add(id); + } + assertEquals(expected, visible); + assertEquals(Material.AIR, top().getItem(39).getType()); + } + + @Test + void aFormerLeaderCannotRequestDeconstructionFromAnOpenDetailMenu() { + Installation fort = installation("fort", InstallationKind.FORT); + view.installationDetailView(leader, faction, fort.getId()); + Inventory menu = top(); + faction.addMember("Successor"); + faction.setLeader("Successor"); + view.click(fixture.ui.click(leader, 11), menu, leader); + verify(navigation, never()).confirmView(any(), any(), anyString(), anyString()); + verifyNoInteractions(unberth); + assertSame(fort, faction.getInstallationHandler().getById(fort.getId())); + } + + @Test + void installationListSummarizesAllKindsAndSortsOperationalRecords() { + for (InstallationKind kind : InstallationKind.values()) installation(kind.name(), kind); + view.installationsView(null, leader, faction, true); + Inventory menu = top(); + assertEquals(SFGUI.INSTALLATIONS_VIEW, ((SFInventoryHolder) menu.getHolder()).getType()); + String summary = lore(menu.getItem(10)); + assertTrue(summary.contains("Total: 4")); + assertTrue(summary.contains("Forts: 1 Ports: 1 Airports: 1 Train Stations: 1")); + assertTrue(summary.contains("Total Upkeep: 20.0d/day")); + assertEquals("AIRPORT", data(menu.getItem(12), Keys.STRING_KEY)); + assertEquals("FORT", data(menu.getItem(13), Keys.STRING_KEY)); + assertEquals("PORT", data(menu.getItem(14), Keys.STRING_KEY)); + assertEquals("TRAIN_STATION", data(menu.getItem(15), Keys.STRING_KEY)); + assertEquals(Material.MINECART, menu.getItem(15).getType()); + assertEquals(Material.BARRIER, menu.getItem(53).getType()); + view.click(fixture.ui.click(leader, 12), menu, leader); + assertEquals("AIRPORT", ((SFInventoryHolder) top().getHolder()).getSecondaryId()); + } + + @Test + void listCapacityAndRefreshKeepControlsAndRemoveVanishedInstallations() { + for (int n = 0; n < 35; n++) { + faction.addProvince(10 + n); + faction + .getInstallationHandler() + .acceptTransferred( + new Installation( + String.format("fort_%02d", n), + "Fort " + n, + InstallationKind.FORT, + 10 + n, + 100 * n, + 0, + 1L)); + } + pending("port", InstallationKind.PORT, false); + view.installationsView(null, leader, faction, true); + Inventory menu = top(); + int visible = 0; + for (int slot = 12; slot <= 44; slot++) { + if (slot != 39 && data(menu.getItem(slot), Keys.STRING_KEY) != null) visible++; + } + assertEquals(32, visible); + assertEquals("port", data(menu.getItem(39), Keys.STRING_KEY)); + assertNotNull(data(menu.getItem(39), Keys.QUEUE_CANCEL)); + for (int n = 1; n < 35; n++) faction.getInstallationHandler().detachOnProvince(10 + n); + faction.getInstallationHandler().cancelPending("port"); + view.installationsView(menu, leader, faction, false); + assertEquals("fort_00", data(menu.getItem(12), Keys.STRING_KEY)); + for (int slot = 13; slot <= 44; slot++) + assertEquals(Material.AIR, menu.getItem(slot).getType()); + assertSame(menu, top()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void constructionQueueConfirmsCancellationOnlyForTheCurrentLeader(boolean stillLeader) { + InstallationConstruction queued = pending("port", InstallationKind.PORT, false); + view.installationsView(null, leader, faction, true); + Inventory menu = top(); + String payload = data(menu.getItem(39), Keys.QUEUE_CANCEL); + assertEquals(QueueCancelPayload.installation(faction.getId(), queued.getId()), payload); + assertTrue(name(menu.getItem(39)).contains("Building")); + if (!stillLeader) faction.setLeader("Successor"); + InventoryClickEvent click = fixture.ui.click(leader, 39); + view.click(click, menu, leader); + assertTrue(click.isCancelled()); + if (stillLeader) + verify(navigation).openQueueCancelConfirm(leader, faction, payload, "§eCancel construction?"); + else verify(navigation, never()).openQueueCancelConfirm(any(), any(), anyString(), anyString()); + assertSame(queued, faction.getInstallationHandler().getPendingConstruction()); + } + + @ParameterizedTest + @ValueSource(strings = {"pending", "upgrade", "maximum", "ordinary"}) + void detailsDisplayBuildStateAndApplicableLeaderActions(String state) { + Installation fort = null; + if (!state.equals("pending")) { + fort = + new Installation( + "fort", + "Fort", + InstallationKind.FORT, + 10, + 12, + 34, + 1L, + state.equals("maximum") ? 3 : 1); + faction.getInstallationHandler().acceptTransferred(fort); + } + if (state.equals("pending") || state.equals("upgrade")) + pending("fort", InstallationKind.FORT, state.equals("upgrade")); + view.installationDetailView(leader, faction, "fort"); + Inventory menu = top(); + assertEquals("fort", data(menu.getItem(49), Keys.STRING_KEY)); + assertTrue(lore(menu.getItem(49)).contains("Province: 10")); + assertTrue(lore(menu.getItem(49)).contains("Coords: 12, 34")); + assertEquals(Material.BARRIER, menu.getItem(53).getType()); + if (state.equals("pending")) { + assertEquals("Cancel Construction", name(menu.getItem(11))); + assertEquals(Material.AIR, menu.getItem(13).getType()); + assertTrue(lore(menu.getItem(49)).contains("Under construction")); + } else { + assertEquals("Deconstruct", name(menu.getItem(11))); + assertTrue( + lore(menu.getItem(49)) + .contains("Upkeep: " + (state.equals("maximum") ? "15.0" : "5.0") + "d/day")); + if (state.equals("maximum")) assertEquals(Material.AIR, menu.getItem(13).getType()); + else if (state.equals("upgrade")) { + assertEquals("Cancel Upgrade", name(menu.getItem(13))); + assertTrue(lore(menu.getItem(49)).contains("Upgrading to level 2")); + assertTrue(lore(menu.getItem(13)).contains("Target level: 2")); + } else { + assertEquals("Upgrade", name(menu.getItem(13))); + assertTrue(lore(menu.getItem(13)).contains("Next level: 2")); + assertTrue(lore(menu.getItem(13)).contains("Upkeep: 10.0d/day")); + } + } + faction.setLeader("Successor"); + view.installationDetailView(leader, faction, "fort", menu); + assertSame(menu, top()); + assertEquals(Material.AIR, menu.getItem(11).getType()); + assertEquals(Material.AIR, menu.getItem(13).getType()); + } + + @ParameterizedTest + @ValueSource(strings = {"upgrade", "cancel_upgrade", "stale_leader"}) + void upgradeClickSelectsTheCurrentConstructionActionWithoutChangingItsQueue(String action) { + Installation fort = installation("fort", InstallationKind.FORT); + InstallationConstruction queued = + action.equals("cancel_upgrade") ? pending("fort", InstallationKind.FORT, true) : null; + view.installationDetailView(leader, faction, "fort"); + Inventory menu = top(); + if (action.equals("stale_leader")) faction.setLeader("Successor"); + view.click(fixture.ui.click(leader, 13), menu, leader); + if (action.equals("stale_leader")) { + verify(navigation, never()).confirmView(any(), any(), anyString(), anyString()); + assertFalse(navigation.confirming.containsKey(leader)); + } else { + verify(navigation).confirmView(leader, faction, "installation_" + action, "fort"); + assertSame(faction, navigation.confirming.get(leader)); + } + assertEquals(1, fort.getLevel()); + assertSame(queued, faction.getInstallationHandler().getPendingConstruction()); + } + + @Test + void missingDetailsReturnToListAndOnlyNewRequestsSendAnError() { + view.installationDetailView(leader, faction, "deleted"); + assertEquals(SFGUI.INSTALLATIONS_VIEW, ((SFInventoryHolder) top().getHolder()).getType()); + verify(leader).sendMessage("§cNo installation with id §fdeleted"); + clearInvocations(leader); + view.installationDetailView(leader, faction, "deleted", top()); + verify(leader, never()).sendMessage(anyString()); + assertEquals(SFGUI.INSTALLATIONS_VIEW, ((SFInventoryHolder) top().getHolder()).getType()); + } + + @Test + void constructionAndUpgradeItemsExplainTargetsFrozenTimersAndConfiguredCategoryNames() { + Installation fort = installation("fort", InstallationKind.FORT); + InstallationConstruction upgrade = pending("fort", InstallationKind.FORT, true); + MockedStatic<PreparationFreeze> freeze = + mockStatic(PreparationFreeze.class, CALLS_REAL_METHODS); + scopes.add(freeze); + freeze + .when(() -> PreparationFreeze.frozenUntil(eq(faction), any(Instant.class))) + .thenReturn(Instant.now().plusSeconds(3600)); + scope(VehiclesConfigLoader.class) + .when(() -> VehiclesConfigLoader.getCategoryDisplayName("heavy_vehicles")) + .thenReturn("Heavy Armour"); + ItemStack queued = view.creator.createConstructionIcon(upgrade, faction); + assertTrue(lore(queued).contains("Upgrade to level: 2")); + assertTrue(lore(queued).contains("Frozen: battle postponed")); + assertEquals( + QueueCancelPayload.installation(faction.getId(), "fort"), data(queued, Keys.QUEUE_CANCEL)); + assertTrue( + lore(view.creator.createConstructionDetailItem(upgrade, fort)) + .contains("Upgrade to level: 2")); + assertTrue(lore(view.creator.createDetailItem(fort)).contains("Heavy Armour slots: 2")); + assertTrue(lore(view.creator.createUpgradeButton(fort)).contains("Heavy Armour slots: 2")); + assertEquals("Cancel Upgrade", name(view.creator.createDeconstructButton("fort", true, true))); + assertTrue( + lore(view.creator.createConstructionDetailItem(upgrade)).contains("Status: Upgrading")); + faction.getInstallationHandler().detachOnProvince(10); + ItemStack orphanedQueue = view.creator.createConstructionIcon(upgrade, faction); + assertFalse(lore(orphanedQueue).contains("Upgrade to level:")); + assertEquals("fort", data(orphanedQueue, Keys.STRING_KEY)); + } + + @Test + void detailVehicleCapacityPreservesEveryControlAndRefreshDropsRemovedRecords() { + Installation fort = installation("fort", InstallationKind.FORT); + for (int n = 0; n < 43; n++) berth("vehicle-" + n, String.format("type_%02d", n), fort); + view.installationDetailView(leader, faction, "fort"); + Inventory menu = top(); + for (int n = 0; n < 42; n++) { + ItemStack item = menu.getItem(InstallationView.berthedVehicleSlot(n)); + assertEquals("vehicle-" + n, data(item, Keys.STRING_KEY)); + assertTrue(lore(item).contains("Click to take as your vehicle")); + } + assertNull(menu.getItem(15)); + assertEquals("Deconstruct", name(menu.getItem(11))); + assertEquals("Upgrade", name(menu.getItem(13))); + registry.getAll().forEach(record -> registry.unregister(record.getVehicleUuid())); + view.installationDetailView(leader, faction, "fort", menu); + assertNull(menu.getItem(0)); + assertNull(menu.getItem(44)); + assertEquals("fort", data(menu.getItem(49), Keys.STRING_KEY)); + } + + @Test + void vehicleItemsShowStoredLocationAndHideTakeHintFromOtherMembers() { + Installation fort = installation("fort", InstallationKind.FORT); + berth("tank-1", "heavy_tank", fort); + when(vehicles.getOfflineLocation("tank-1")) + .thenReturn(Optional.of(new Location(fixture.ui.world, 12, 65, 34))); + view.installationDetailView(leader, faction, "fort"); + assertEquals("tank-1", name(top().getItem(0))); + assertTrue(lore(top().getItem(0)).contains("12, 65, 34")); + faction.setLeader("Successor"); + view.installationDetailView(leader, faction, "fort", top()); + assertFalse(lore(top().getItem(0)).contains("Click to take")); + view.click(fixture.ui.click(leader, 0), top(), leader); + verifyNoInteractions(unberth); + assertTrue(registry.isBerthed("tank-1")); + } + + @ParameterizedTest + @EnumSource( + value = InstallationVehicleUnberthService.UnberthResult.class, + names = {"OK", "NOT_BERTHED", "SAVE_FAILED"}) + void takingABerthedVehicleReflectsTheTransferResultAndConservesTheRegistry( + InstallationVehicleUnberthService.UnberthResult result) { + Installation fort = installation("fort", InstallationKind.FORT); + PlayerVehicleRecord original = berth("tank-1", "heavy_tank", fort); + InstallationVehicleUnberthService.UnberthOutcome outcome = + new InstallationVehicleUnberthService.UnberthOutcome(result, null, "heavy_tank"); + when(unberth.unberth(faction, "Leader", fort, "tank-1")) + .thenAnswer( + call -> { + if (result == InstallationVehicleUnberthService.UnberthResult.OK) + registry.register( + new PlayerVehicleRecord( + leader.getUniqueId(), + "tank-1", + "heavy_tank", + OwnershipMode.PERSONAL, + null)); + return outcome; + }); + view.installationDetailView(leader, faction, "fort"); + Inventory old = top(); + view.click(fixture.ui.click(leader, 0), old, leader); + verify(unberth).unberth(faction, "Leader", fort, "tank-1"); + verify(leader).sendMessage(InstallationVehicleUnberthService.messageFor(outcome)); + assertEquals(1, registry.getAll().size()); + if (result == InstallationVehicleUnberthService.UnberthResult.OK) { + assertEquals( + OwnershipMode.PERSONAL, registry.getByVehicleUuid("tank-1").orElseThrow().getMode()); + assertNull(top().getItem(0)); + assertNotSame(old, top()); + } else { + assertSame(original, registry.getByVehicleUuid("tank-1").orElseThrow()); + assertSame(old, top()); + } + assertSame(fort, faction.getInstallationHandler().getById("fort")); + } + + @ParameterizedTest + @ValueSource( + strings = { + "empty", + "plain", + "no_key", + "missing_detail", + "plain_detail", + "detail_no_key", + "removed_installation" + }) + void staleOrNonActionVehicleSlotsNeverCallTheTransferService(String state) { + Installation fort = installation("fort", InstallationKind.FORT); + berth("tank-1", "heavy_tank", fort); + view.installationDetailView(leader, faction, "fort"); + Inventory menu = top(); + switch (state) { + case "empty" -> menu.setItem(0, null); + case "plain" -> menu.setItem(0, new ItemStack(Material.STONE)); + case "no_key" -> menu.setItem(0, named("Decoration")); + case "missing_detail" -> menu.setItem(49, null); + case "plain_detail" -> menu.setItem(49, new ItemStack(Material.STONE)); + case "detail_no_key" -> menu.setItem(49, named("Details")); + case "removed_installation" -> faction.getInstallationHandler().detachOnProvince(10); + } + view.click(fixture.ui.click(leader, 0), menu, leader); + verifyNoInteractions(unberth); + assertTrue(registry.isBerthed("tank-1")); + } + + @ParameterizedTest + @ValueSource(ints = {11, 13}) + void clearedActionButtonsCannotStartAConfirmation(int slot) { + installation("fort", InstallationKind.FORT); + view.installationDetailView(leader, faction, "fort"); + Inventory menu = top(); + for (ItemStack item : + new ItemStack[] {null, new ItemStack(Material.STONE), named("Decoration")}) { + menu.setItem(slot, item); + view.click(fixture.ui.click(leader, slot), menu, leader); + } + verify(navigation, never()).confirmView(any(), any(), anyString(), anyString()); + assertTrue(navigation.confirming.isEmpty()); + verifyNoInteractions(unberth); + } + + @Test + void unrelatedMenusAndRemovedFactionsDoNotNavigateOrChangeInstallations() { + installation("fort", InstallationKind.FORT); + Inventory ordinary = fixture.ui.inventory(null, 9, "Other plugin"); + leader.openInventory(ordinary); + view.click(fixture.ui.click(leader, 0), ordinary, leader); + view.installationsView(null, leader, faction, true); + Inventory list = top(); + view.click(fixture.ui.click(leader, 0), list, leader); + list.setItem(0, new ItemStack(Material.STONE)); + view.click(fixture.ui.click(leader, 0), list, leader); + view.click(fixture.ui.click(leader, 10), list, leader); + assertSame(list, top()); + FactionManager.factions.remove(faction); + view.click(fixture.ui.click(leader, 12), list, leader); + assertSame(list, top()); + assertEquals(1, faction.getInstallationHandler().getAll().size()); + verifyNoInteractions(unberth); + } + + @Test + void reportedInstallationMenusMaskExactDataOnInitialOpenAndRefresh() { + installation("secret", InstallationKind.FORT); + scope(EspionageService.class) + .when(() -> EspionageService.canViewExact(any(), any())) + .thenReturn(false); + view.installationsView(null, leader, faction, true); + Inventory list = top(); + assertTrue(((SFInventoryHolder) list.getHolder()).isReported()); + assertTrue(lore(list.getItem(12)).contains("Unknown")); + assertNull(data(list.getItem(39), Keys.QUEUE_CANCEL)); + view.installationsView(list, leader, faction, false); + assertSame(list, top()); + view.installationsView(null, leader, faction, false); + assertSame(list, top()); + view.installationDetailView(leader, faction, "secret"); + Inventory detail = top(); + assertTrue(((SFInventoryHolder) detail.getHolder()).isReported()); + assertTrue(lore(detail.getItem(49)).contains("Coordinates: Unknown")); + assertNull(detail.getItem(11)); + assertNull(detail.getItem(13)); + view.installationDetailView(leader, faction, "secret", detail); + assertSame(detail, top()); + assertNull(data(detail.getItem(49), Keys.STRING_KEY)); + } + + private InstallationConstruction pending(String id, InstallationKind kind, boolean upgrade) { + InstallationConstruction construction = + new InstallationConstruction(id, id, kind, 10, 12, 34, 120, 1L, upgrade); + faction.getInstallationHandler().loadConstruction(construction.toData()); + return faction.getInstallationHandler().getPendingConstruction(); + } + + private PlayerVehicleRecord berth(String id, String type, Installation installation) { + PlayerVehicleRecord record = + new PlayerVehicleRecord( + UUID.randomUUID(), + id, + type, + OwnershipMode.INSTALLATION, + installation.getId(), + faction.getId()); + registry.register(record); + return record; + } + + private ItemStack named(String text) { + ItemStack item = new ItemStack(Material.PAPER); + var meta = item.getItemMeta(); + meta.setDisplayName(text); + item.setItemMeta(meta); + return item; + } + + private String name(ItemStack item) { + return ChatColor.stripColor(item.getItemMeta().getDisplayName()); + } + + private String lore(ItemStack item) { + return ChatColor.stripColor(String.join("\n", item.getItemMeta().getLore())); + } + + private String data(ItemStack item, org.bukkit.NamespacedKey key) { + return item.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); + } + + private Installation installation(String id, InstallationKind kind) { + Installation installation = new Installation(id, id, kind, 10, 12, 34, 1L); + faction.getInstallationHandler().acceptTransferred(installation); + return installation; + } + + private Inventory top() { + return leader.getOpenInventory().getTopInventory(); + } + + private <T> MockedStatic<T> scope(Class<T> type) { + MockedStatic<T> scope = mockStatic(type); + scopes.add(scope); + return scope; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdaterCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdaterCoverageTest.java new file mode 100644 index 00000000..7de4cfb2 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/InventoryUpdaterCoverageTest.java @@ -0,0 +1,458 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.espionage.EspionageAccess; +import net.tfminecraft.simplefactions.espionage.EspionageService; +import net.tfminecraft.simplefactions.government.election.Candidate; +import net.tfminecraft.simplefactions.government.movement.Action; +import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.government.movement.cause.Cause; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.loans.Loan; +import net.tfminecraft.simplefactions.loaders.TierLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.managers.holder.CampaignInventoryHolder; +import net.tfminecraft.simplefactions.managers.holder.CampaignRaidLaunchHolder; +import net.tfminecraft.simplefactions.managers.holder.DeclareWarHolder; +import net.tfminecraft.simplefactions.managers.holder.SFCombinedInventoryHolder; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.managers.holder.WarInventoryHolder; +import net.tfminecraft.simplefactions.mercenary.contract.MercenaryContract; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import net.tfminecraft.simplefactions.tiers.Tier; +import net.tfminecraft.simplefactions.war.battle.ui.BattleInventoryManager; +import net.tfminecraft.simplefactions.war.core.Participant; +import net.tfminecraft.simplefactions.war.core.War; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +/** Periodic refresh contract: preserve snapshot screens, refresh live state, reject stale owners. */ +class InventoryUpdaterCoverageTest { + private GuiTestFixture ui; + private Player player; + private Faction faction; + private Faction enemy; + private Guild guild; + private Movement movement; + private War war; + private Participant participant; + private InventoryManager manager; + private InventoryUpdater updater; + private MockedConstruction<BattleInventoryManager> battleMenus; + private final List<MockedStatic<?>> mocks = new ArrayList<>(); + private MockedStatic<FactionManager> factions; + private MockedStatic<WarManager> wars; + private MockedStatic<EspionageAccess> access; + private MockedStatic<EspionageService> intelligence; + private MockedStatic<TierLoader> tiers; + + private <T> MockedStatic<T> scoped(Class<T> type) { + MockedStatic<T> result = mockStatic(type); + mocks.add(result); + return result; + } + + @BeforeEach + void setup() { + ui = new GuiTestFixture(); + player = ui.player("Alice"); + when(Bukkit.getOnlinePlayers()).thenAnswer(call -> List.of(player)); + faction = mock(Faction.class); + enemy = mock(Faction.class); + guild = mock(Guild.class, RETURNS_DEEP_STUBS); + movement = mock(Movement.class); + war = mock(War.class); + participant = mock(Participant.class); + when(faction.getId()).thenReturn("home"); + when(enemy.getId()).thenReturn("enemy"); + when(guild.getId()).thenReturn("guild"); + when(guild.getName()).thenReturn("Artisans"); + when(guild.getFaction()).thenReturn(faction); + when(movement.getFaction()).thenReturn(faction); + when(war.isActive()).thenReturn(true); + when(war.isParticipating(faction)).thenReturn(true); + when(war.getParticipant(faction)).thenReturn(participant); + factions = scoped(FactionManager.class); + factions.when(() -> FactionManager.getByString("home")).thenReturn(faction); + factions.when(() -> FactionManager.getByString("enemy")).thenReturn(enemy); + factions.when(() -> FactionManager.getByMember("Alice")).thenReturn(faction); + factions.when(() -> FactionManager.getGuildByString("guild")).thenReturn(guild); + factions.when(() -> FactionManager.getMovementById("movement")).thenReturn(movement); + wars = scoped(WarManager.class); + wars.when(() -> WarManager.getById(7)).thenReturn(war); + access = scoped(EspionageAccess.class); + access.when(() -> EspionageAccess.owner(any())).thenReturn(faction); + intelligence = scoped(EspionageService.class); + intelligence.when(() -> EspionageService.canViewExact(player, faction)).thenReturn(true); + tiers = scoped(TierLoader.class); + manager = mock(InventoryManager.class); + manager.factionView = mock(FactionView.class); + manager.guildView = mock(GuildView.class); + manager.warView = mock(WarView.class); + manager.mercenaryMarketView = mock(MercenaryMarketView.class); + manager.governmentView = mock(GovernmentView.class); + manager.taxView = mock(TaxView.class); + manager.lawView = mock(LawView.class); + manager.electionView = mock(ElectionView.class); + manager.movementView = mock(MovementView.class); + manager.declareWarView = mock(DeclareWarView.class); + manager.campaignInstallationPickView = mock(CampaignInstallationPickView.class); + manager.campaignRaidLaunchView = mock(CampaignRaidLaunchView.class); + battleMenus = mockConstruction(BattleInventoryManager.class); + updater = new InventoryUpdater(manager); + } + + @AfterEach + void cleanup() { + battleMenus.close(); + for (int i = mocks.size() - 1; i >= 0; i--) mocks.get(i).close(); + ui.close(); + } + + private Inventory open(InventoryHolder holder, int size, String title) { + Inventory inventory = ui.inventory(holder, size, title); + player.openInventory(inventory); + return inventory; + } + + private Inventory sf(SFGUI type, String id, String secondary) { + return open(new SFInventoryHolder(id, type, 2, true, secondary), 54, type.name()); + } + + @ParameterizedTest + @EnumSource(value = SFGUI.class, names = {"FACTION_VIEW", "SPECIAL_POSITIONS", "SPYMASTER_VIEW", "FOREIGN_LEDGER_VIEW", "FOREIGN_POSITIONS_VIEW", "SPYMASTER_SETTINGS", "SPYMASTER_SELECT", "PLAYER_LEDGER_VIEW", "GUILD_VIEW", "GUILD_LIST", "FACTION_GUILDS", "LAW_PROPOSAL_SELECT", "LAW_SELECT", "FAVOUR_REPRESS_SELECT", "PROPOSALS", "CAUSES_VIEW", "CAUSE_VIEW", "LEDGER_VIEW", "COMPANY_VIEW"}) + void snapshotScreensAreNotRebuiltOnPeriodicRefresh(SFGUI type) { + Inventory inventory = sf(type, "home", null); + ItemStack snapshot = new ItemStack(Material.PAPER); + inventory.setItem(0, snapshot); + updater.updateInventory(); + assertSame(inventory, player.getOpenInventory().getTopInventory()); + assertSame(snapshot, inventory.getItem(0)); + verifyNoInteractions(manager, manager.factionView, manager.guildView, manager.governmentView, manager.lawView, manager.movementView); + } + + @Test + void lostViewingPermissionClosesMenusAndReportedSnapshotsRemainUnchanged() { + Inventory inventory = sf(SFGUI.MILITARY_VIEW, "home", null); + SFInventoryHolder holder = (SFInventoryHolder) inventory.getHolder(); + access.when(() -> EspionageAccess.denied(player, holder)).thenReturn(true); + updater.updateInventory(); + verify(player).closeInventory(); + verifyNoInteractions(manager); + access.when(() -> EspionageAccess.denied(player, holder)).thenReturn(false); + player.openInventory(inventory); + holder.markReported(); + intelligence.when(() -> EspionageService.canViewExact(player, faction)).thenReturn(false); + updater.updateInventory(); + assertSame(inventory, player.getOpenInventory().getTopInventory()); + verifyNoInteractions(manager); + } + + @ParameterizedTest + @EnumSource(value = SFGUI.class, names = {"FACTION_LIST", "WAR_LIST", "MERCENARY_MARKET_LIST"}) + void globalListsRefreshWithoutResolvingAFaction(SFGUI type) { + Inventory inventory = sf(type, "irrelevant", null); + updater.updateInventory(); + switch (type) { + case FACTION_LIST -> verify(manager.factionView).populateFactionList(inventory, player); + case WAR_LIST -> verify(manager.warView).populateWarList(inventory); + case MERCENARY_MARKET_LIST -> verify(manager.mercenaryMarketView).populateMarketList(inventory, player); + default -> fail(); + } + factions.verify(() -> FactionManager.getByString(anyString()), never()); + } + + @ParameterizedTest + @EnumSource(value = SFGUI.class, names = {"UPGRADE_VIEW", "COMPANY_SLOTS_VIEW", "COMPANY_ROSTER_VIEW", "COMPANY_UPGRADE_VIEW", "CONTRACT_LIST_VIEW", "LOAN_MAIN_VIEW", "LOANS_GIVEN_VIEW", "LOANS_TAKEN_VIEW"}) + void guildScreensRefreshTheirLiveGuildAndPreserveInventory(SFGUI type) { + Inventory inventory = sf(type, "guild", null); + updater.updateInventory(); + switch (type) { + case UPGRADE_VIEW -> verify(manager).upgradeView(player, guild, inventory); + case COMPANY_SLOTS_VIEW -> verify(manager).companySlotsView(player, guild, inventory); + case COMPANY_ROSTER_VIEW -> verify(manager).companyRosterView(player, guild, inventory); + case COMPANY_UPGRADE_VIEW -> verify(manager).companyUpgradeView(player, guild, inventory); + case CONTRACT_LIST_VIEW -> verify(manager).contractListView(player, guild, inventory); + case LOAN_MAIN_VIEW -> verify(manager).loanMainView(player, guild, inventory); + case LOANS_GIVEN_VIEW -> verify(manager).loansGivenView(player, guild, inventory); + case LOANS_TAKEN_VIEW -> verify(manager).loansTakenView(player, guild, inventory); + default -> fail(); + } + factions.when(() -> FactionManager.getGuildByString("guild")).thenReturn(null); + clearInvocations(manager); + updater.updateInventory(); + verifyNoInteractions(manager); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void loanDetailsRefreshTheCorrectTakenOrIssuedDirection(boolean taken) { + Loan loan = mock(Loan.class); + when(guild.getLoanHandler().getLoanById("loan")).thenReturn(loan); + Inventory inventory = sf(taken ? SFGUI.TAKEN_LOAN_DETAIL_VIEW : SFGUI.ISSUED_LOAN_DETAIL_VIEW, "guild", "loan"); + updater.updateInventory(); + verify(manager).loanDetailView(player, guild, loan, taken, inventory); + } + + @Test + void contractDetailsUseTheExactCurrentContractId() { + MercenaryContract contract = mock(MercenaryContract.class); + when(guild.getCompany().getContractHandler().getById("contract")).thenReturn(contract); + Inventory inventory = sf(SFGUI.CONTRACT_DETAIL_VIEW, "guild", "contract"); + updater.updateInventory(); + verify(manager).contractDetailView(player, guild, inventory, "contract"); + } + + @ParameterizedTest + @EnumSource(value = SFGUI.class, names = {"CONTRACT_DETAIL_VIEW", "TAKEN_LOAN_DETAIL_VIEW", "ISSUED_LOAN_DETAIL_VIEW"}) + void detailsWithoutAnIdentifierAreIgnored(SFGUI type) { + sf(type, "guild", null); + updater.updateInventory(); + verifyNoInteractions(manager); + } + + @ParameterizedTest + @EnumSource(value = SFGUI.class, names = {"CONTRACT_DETAIL_VIEW", "TAKEN_LOAN_DETAIL_VIEW", "ISSUED_LOAN_DETAIL_VIEW"}) + void deletedDetailsOpenAFreshFullSizeListInsteadOfReusingTheDetailInventory(SFGUI type) { + when(guild.getCompany().getContractHandler().getById("gone")).thenReturn(null); + when(guild.getLoanHandler().getLoanById("gone")).thenReturn(null); + InventoryManager realManager = new InventoryManager(); + InventoryUpdater realUpdater = new InventoryUpdater(realManager); + Inventory detail = open(new SFInventoryHolder("guild", type, "gone"), 27, "Detail"); + assertDoesNotThrow(realUpdater::updateInventory); + Inventory list = player.getOpenInventory().getTopInventory(); + assertNotSame(detail, list); + assertEquals(54, list.getSize()); + SFGUI expected = type == SFGUI.CONTRACT_DETAIL_VIEW ? SFGUI.CONTRACT_LIST_VIEW + : type == SFGUI.TAKEN_LOAN_DETAIL_VIEW ? SFGUI.LOANS_TAKEN_VIEW : SFGUI.LOANS_GIVEN_VIEW; + assertEquals(expected, ((SFInventoryHolder) list.getHolder()).getType()); + assertEquals(Material.BARRIER, list.getItem(53).getType()); + } + + @ParameterizedTest + @EnumSource(value = SFGUI.class, names = {"GOVERNMENT_VIEW", "COUNCIL_VIEW", "PROPOSAL_VIEW", "POLITICAL_PROPOSAL_VIEW", "TAX_PROPOSAL_VIEW", "LAW_PROPOSAL_VIEW", "FAVOUR_REPRESS_MAIN", "FAVOUR_REPRESS_TYPE", "MILITARY_VIEW", "INSTALLATIONS_VIEW", "DIPLOMACY_VIEW", "DIPLOMACY_LIST", "ATTITUDE_VIEW", "RELATION_VIEW", "TAX_VIEW", "LAW_VIEW", "ELECTION_VIEW", "TIER_VIEW", "TITLE_VIEW"}) + void factionScreensRefreshTheOwnerAndTheirExactView(SFGUI type) { + Inventory inventory = sf(type, "home", null); + updater.updateInventory(); + switch (type) { + case GOVERNMENT_VIEW -> verify(manager).governmentView(player, faction, inventory); + case COUNCIL_VIEW -> verify(manager.governmentView).councilView(player, faction, inventory); + case PROPOSAL_VIEW -> verify(manager).proposalView(player, faction, inventory); + case POLITICAL_PROPOSAL_VIEW -> verify(manager.governmentView).politicalProposalView(player, faction, inventory); + case TAX_PROPOSAL_VIEW -> verify(manager.governmentView).taxProposalView(player, faction, inventory); + case LAW_PROPOSAL_VIEW -> verify(manager.governmentView).lawProposalView(player, faction, inventory); + case FAVOUR_REPRESS_MAIN -> verify(manager.governmentView).favourRepressMainView(player, faction, inventory); + case FAVOUR_REPRESS_TYPE -> verify(manager.governmentView).favourRepressTypeView(player, faction, true, inventory); + case MILITARY_VIEW -> verify(manager).militaryView(inventory, player, faction, false); + case INSTALLATIONS_VIEW -> verify(manager).installationsView(inventory, player, faction, false); + case DIPLOMACY_VIEW -> verify(manager).diplomacyView(inventory, player, faction, false); + case DIPLOMACY_LIST -> verify(manager).diplomacyListView(inventory, player, faction, false); + case ATTITUDE_VIEW -> verify(manager).attitudeView(inventory, player, faction, false); + case RELATION_VIEW -> verify(manager).relationView(inventory, player, faction, false); + case TAX_VIEW -> verify(manager.taxView).taxView(player, faction, inventory); + case LAW_VIEW -> verify(manager).lawView(player, faction, inventory); + case ELECTION_VIEW -> verify(manager.electionView).electionView(player, faction, inventory); + case TIER_VIEW -> verify(manager).tierView(inventory, player, faction, false); + case TITLE_VIEW -> verify(manager).titleView(inventory, player, faction, false); + default -> fail(); + } + } + + @ParameterizedTest + @CsvSource({"WAR_PEACE_SELECT,WHITE_PEACE", "SPECIFIC_TAX_PROPOSAL_VIEW,CITIZENS", "TAX_VIEW_SPECIFIC,VASSALS", "ELECTION_VOTING_VIEW,COUNCIL"}) + void enumSelectionsRefreshTheirSnapshotArgumentsAndIgnoreRemovedValues(SFGUI type, String selected) { + Inventory inventory = type == SFGUI.WAR_PEACE_SELECT + ? open(new SFInventoryHolder("home", type, 2, false, selected), 54, type.name()) + : sf(type, "home", selected); + updater.updateInventory(); + switch (type) { + case WAR_PEACE_SELECT -> verify(manager.governmentView).warPeaceSelectView(player, faction, Action.WHITE_PEACE, false, 2, inventory); + case SPECIFIC_TAX_PROPOSAL_VIEW -> verify(manager.governmentView).specificTaxProposalView(player, faction, inventory, TaxTarget.CITIZENS); + case TAX_VIEW_SPECIFIC -> verify(manager.taxView).specificTaxView(player, faction, TaxTarget.VASSALS, inventory); + case ELECTION_VOTING_VIEW -> verify(manager.electionView).votingView(player, faction, Candidate.COUNCIL, inventory); + default -> fail(); + } + clearInvocations(manager.governmentView, manager.taxView, manager.electionView); + for (String stale : new String[] {null, "REMOVED_OPTION"}) { + sf(type, "home", stale); + assertDoesNotThrow(updater::updateInventory); + verifyNoInteractions(manager.governmentView, manager.taxView, manager.electionView); + } + } + + @Test + void installationAndTitleSelectionsRequireIdsAndCurrentDefinitions() { + Inventory installation = sf(SFGUI.INSTALLATION_DETAIL_VIEW, "home", "mill"); + updater.updateInventory(); + verify(manager).installationDetailView(player, faction, "mill", installation); + Tier tier = mock(Tier.class); + tiers.when(() -> TierLoader.getByString("county")).thenReturn(tier); + Inventory titles = sf(SFGUI.TITLE_TYPE_VIEW, "home", "county"); + updater.updateInventory(); + verify(manager).titleTypeView(titles, player, faction, tier, false, 2); + clearInvocations(manager); + for (SFGUI type : List.of(SFGUI.INSTALLATION_DETAIL_VIEW, SFGUI.TITLE_TYPE_VIEW)) { + sf(type, "home", null); + updater.updateInventory(); + verifyNoInteractions(manager); + } + sf(SFGUI.TITLE_TYPE_VIEW, "home", "removed"); + updater.updateInventory(); + verifyNoInteractions(manager); + } + + @ParameterizedTest + @EnumSource(value = SFGUI.class, names = {"MOVEMENT_LIST", "MOVEMENT_VIEW", "MOVEMENT_DEMANDS", "MOVEMENT_CRACKDOWN"}) + void movementMenusRefreshTheCurrentMovementOrFactionList(SFGUI type) { + Inventory inventory = sf(type, type == SFGUI.MOVEMENT_LIST ? "home" : "movement", null); + updater.updateInventory(); + switch (type) { + case MOVEMENT_LIST -> verify(manager).movementListView(player, faction, inventory); + case MOVEMENT_VIEW -> verify(manager).movementView(player, faction, movement, inventory); + case MOVEMENT_DEMANDS -> verify(manager.movementView).demandsView(player, faction, movement, inventory); + case MOVEMENT_CRACKDOWN -> verify(manager.movementView).crackdownView(player, faction, movement, inventory); + default -> fail(); + } + } + + @Test + void removedOwnersAreIgnoredAndTargetPickersRetainTheDisplayedCauseIdentity() { + for (SFGUI type : List.of(SFGUI.MILITARY_VIEW, SFGUI.MOVEMENT_LIST, SFGUI.MOVEMENT_VIEW)) { + sf(type, "deleted", null); + updater.updateInventory(); + } + verifyNoInteractions(manager, manager.movementView); + when(movement.getFaction()).thenReturn(null); + sf(SFGUI.MOVEMENT_VIEW, "movement", null); + updater.updateInventory(); + verifyNoInteractions(manager); + when(movement.getFaction()).thenReturn(faction); + Cause displayed = mock(Cause.class); + Inventory inventory = sf(SFGUI.TARGET_SELECT, "movement", null); + when(manager.movementView.displayedCause(inventory, movement)).thenReturn(displayed); + updater.updateInventory(); + verify(manager.movementView).targetSelectionView(player, faction, movement, displayed, inventory); + when(manager.movementView.displayedCause(inventory, movement)).thenReturn(null); + updater.updateInventory(); + verify(manager).causesView(player, faction, movement, null); + } + + @Test + void warAndParticipantScreensRefreshCurrentWarContextAndIgnoreDeletedWars() { + Inventory main = open(new WarInventoryHolder(7, SFGUI.WAR_VIEW), 54, "War"); + updater.updateInventory(); + verify(manager).warView(main, player, war, false); + Inventory engagements = open(new SFCombinedInventoryHolder(7, "home", SFGUI.MERCENARY_ENGAGEMENT_LIST), 54, "Engagements"); + updater.updateInventory(); + verify(manager.warView).populateEngagementList(engagements, player, war, "home"); + Inventory side = open(new SFCombinedInventoryHolder(7, "home", SFGUI.PARTICIPANT_VIEW), 54, "Participant"); + updater.updateInventory(); + verify(manager).participantView(side, player, war, participant, false); + clearInvocations(manager, manager.warView); + wars.when(() -> WarManager.getById(7)).thenReturn(null); + for (Inventory inventory : List.of(main, engagements, side)) { + player.openInventory(inventory); + updater.updateInventory(); + } + verifyNoInteractions(manager, manager.warView); + } + + @ParameterizedTest + @EnumSource(value = SFGUI.class, names = {"WAR_DECLARE_GOAL", "WAR_DECLARE_RELATION_TYPE", "WAR_DECLARE_TITLE", "WAR_DECLARE_SUBJECT", "WAR_DECLARE_SETTLEMENT", "WAR_DECLARE_GOVERNMENT"}) + void declarationPickersRetainBothFactionIdsAndGovernmentSelections(SFGUI step) { + Inventory inventory = open(new DeclareWarHolder("home", "enemy", step, "government", "leadership"), 54, "Declare War"); + updater.updateInventory(); + switch (step) { + case WAR_DECLARE_GOAL -> verify(manager.declareWarView).openGoalPicker(player, faction, enemy, inventory); + case WAR_DECLARE_RELATION_TYPE -> verify(manager.declareWarView).openRelationTypePicker(player, faction, enemy, inventory); + case WAR_DECLARE_TITLE -> verify(manager.declareWarView).openTitlePicker(player, faction, enemy, inventory); + case WAR_DECLARE_SUBJECT -> verify(manager.declareWarView).openSubjectPicker(player, faction, enemy, inventory); + case WAR_DECLARE_SETTLEMENT -> verify(manager.declareWarView).openSettlementPicker(player, faction, enemy, inventory); + case WAR_DECLARE_GOVERNMENT -> verify(manager.declareWarView).openGovernmentPicker(player, faction, enemy, "government", "leadership", inventory); + default -> fail(); + } + clearInvocations(manager.declareWarView); + factions.when(() -> FactionManager.getByString("enemy")).thenReturn(null); + updater.updateInventory(); + verifyNoInteractions(manager.declareWarView); + } + + @ParameterizedTest + @ValueSource(strings = {"pick", "raidSource", "raidTarget"}) + void campaignPickersRefreshActiveParticipantAndSourceContext(String kind) { + Inventory inventory = open(kind.equals("pick") + ? new CampaignInventoryHolder(7, SFGUI.CAMPAIGN_INSTALLATION_PICK_VIEW) + : new CampaignRaidLaunchHolder(7, kind.equals("raidSource") ? null : "fort"), 54, "Campaign"); + updater.updateInventory(); + if (kind.equals("pick")) verify(manager.campaignInstallationPickView).open(player, war, faction, false, inventory); + else if (kind.equals("raidSource")) verify(manager.campaignRaidLaunchView).openSourcePage(player, war, faction, false, inventory); + else verify(manager.campaignRaidLaunchView).openTargetPage(player, war, faction, "fort", false, inventory); + clearInvocations(manager.campaignInstallationPickView, manager.campaignRaidLaunchView); + factions.when(() -> FactionManager.getByMember("Alice")).thenReturn(null); + updater.updateInventory(); + factions.when(() -> FactionManager.getByMember("Alice")).thenReturn(faction); + when(war.isActive()).thenReturn(false); + updater.updateInventory(); + wars.when(() -> WarManager.getById(7)).thenReturn(null); + updater.updateInventory(); + verifyNoInteractions(manager.campaignInstallationPickView, manager.campaignRaidLaunchView); + } + + @Test + void campaignInstallationPickerDoesNotRefreshForNonParticipantsOrOtherCampaignPages() { + open(new CampaignInventoryHolder(7, SFGUI.CAMPAIGN_INSTALLATION_PICK_VIEW), 54, "Campaign"); + when(war.isParticipating(faction)).thenReturn(false); + updater.updateInventory(); + open(new CampaignInventoryHolder(7, SFGUI.CAMPAIGN_VIEW), 54, "Campaign"); + updater.updateInventory(); + verifyNoInteractions(manager.campaignInstallationPickView); + } + + @Test + void onlyTheBattleListTitleRefreshesAnUnownedInventory() { + Inventory ordinary = open(null, 27, "Chest"); + updater.updateInventory(); + BattleInventoryManager battle = battleMenus.constructed().getFirst(); + verifyNoInteractions(battle); + Inventory list = open(null, 27, "§7Battle List"); + updater.updateInventory(); + verify(battle).populateBattleList(list); + assertNotSame(ordinary, list); + } + + @Test + void inventorySoundsOnlyReachPlayersWithTheRequestedMenuType() { + Player other = ui.player("Bob"); + when(Bukkit.getOnlinePlayers()).thenAnswer(call -> List.of(player, other)); + sf(SFGUI.GOVERNMENT_VIEW, "home", null); + updater.inventorySound("block.note_block.pling", SFGUI.GOVERNMENT_VIEW); + verify(player).playSound(player, "block.note_block.pling", 1f, 1f); + verify(other, never()).playSound(any(org.bukkit.entity.Entity.class), anyString(), anyFloat(), anyFloat()); + other.openInventory(ui.inventory(new SFInventoryHolder("home", SFGUI.MILITARY_VIEW), 54, "Military")); + updater.inventorySound("block.note_block.bit", SFGUI.GOVERNMENT_VIEW); + verify(player).playSound(player, "block.note_block.bit", 1f, 1f); + verify(other, never()).playSound(any(org.bukkit.entity.Entity.class), anyString(), anyFloat(), anyFloat()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MercenaryMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MercenaryMenusCoverageTest.java new file mode 100644 index 00000000..4f50ed0a --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MercenaryMenusCoverageTest.java @@ -0,0 +1,804 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.espionage.SpecialPositionAssignment; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.upgrade.Upgrade; +import net.tfminecraft.simplefactions.keys.Keys; +import net.tfminecraft.simplefactions.loaders.CompanyUpgradeLoader; +import net.tfminecraft.simplefactions.loaders.RegimentLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryEligibility; +import net.tfminecraft.simplefactions.mercenary.contract.ContractBook; +import net.tfminecraft.simplefactions.mercenary.contract.ContractStatus; +import net.tfminecraft.simplefactions.mercenary.contract.ContractTerms; +import net.tfminecraft.simplefactions.mercenary.contract.MercenaryContract; +import net.tfminecraft.simplefactions.mercenary.contract.MercenaryMarket; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.rest.RestServer; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemCreator; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BookMeta; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +class MercenaryMenusCoverageTest { + private FactionDomainFixture fixture; + private Guild guild; + private Faction host; + private Faction hirer; + private Player captain; + private Player customer; + private MercenaryCompany company; + private Regiment prototype; + private InventoryManager navigation; + private CompanyView companies; + private ContractView contracts; + private MercenaryMarketView market; + private MockedStatic<RestServer> rest; + private MockedStatic<TLibs> tlibs; + private Map<String, Upgrade> previousUpgrades; + private Map<Player, Integer> previousPages; + private final Map<Field, Object> globals = new LinkedHashMap<>(); + + @BeforeEach + void setup() throws Exception { + fixture = new FactionDomainFixture(); + save(Cache.class, "baseYear", "322 AE"); + save(Cache.class, "baseIrlYear", 2026); + save(Cache.class, "mercenaryFormationCost", 100.0); + save(Cache.class, "mercenaryFormationSeconds", 2); + save(Cache.class, "mercenarySlotUpkeep", 8.0); + save(Cache.class, "mercenaryMinPricePerBattle", 50.0); + save(Cache.class, "mercenaryMinPricePerDay", 10.0); + save(Cache.class, "mercenaryMaxContractDays", 14); + save(Cache.class, "mercenaryDefaultBreachRefund", 500.0); + save(MercenaryEligibility.class, "probe", MercenaryEligibility.Probe.OPEN); + previousUpgrades = new LinkedHashMap<>(CompanyUpgradeLoader.get()); + CompanyUpgradeLoader.get().clear(); + upgrade("company_health", 2); + upgrade("company_mana", 3); + upgrade("company_unlimited", 0); + previousPages = new LinkedHashMap<>(MercenaryMarketView.currentPage); + MercenaryMarketView.currentPage.clear(); + host = fixture.saved("host", "Host"); + host.addProvince(10); + guild = fixture.guild(host, "blades", "Captain"); + guild.setCapital(10, false); + guild.getBank().setWealth(1000.0); + hirer = fixture.saved("hirer", "Hirer"); + hirer.getOrCreateMainGuild(); + captain = fixture.player("Captain"); + when(captain.getWorld()).thenReturn(fixture.ui.world); + customer = fixture.player("Hirer"); + YamlConfiguration config = new YamlConfiguration(); + config.set("mercenary.name", "Mercenary"); + config.set("mercenary.mercenary", true); + config.set("mercenary.item.material", "IRON_SWORD"); + config.set("mercenary.upkeep", 8.0); + config.set("mercenary.expansion-time", 2); + prototype = new Regiment("mercenary", config.getConfigurationSection("mercenary")); + RegimentLoader.oList.add(prototype); + company = form(guild, "Hired Blades"); + navigation = mock(InventoryManager.class); + when(navigation.createBackButton(any())).thenAnswer(call -> new ItemStack(Material.BARRIER)); + companies = new CompanyView(navigation); + contracts = new ContractView(navigation); + navigation.contractView = contracts; + market = new MercenaryMarketView(navigation); + rest = mockStatic(RestServer.class); + rest.when(() -> RestServer.getProvince(any(Player.class))).thenReturn(10); + tlibs = mockStatic(TLibs.class); + ItemAPI items = mock(ItemAPI.class); + ItemCreator creator = mock(ItemCreator.class); + when(items.getCreator()).thenReturn(creator); + when(creator.getItemsAdderItem(anyString())).thenAnswer(call -> new ItemStack(Material.PAPER)); + tlibs.when(TLibs::getItemAPI).thenReturn(items); + } + + @AfterEach + void close() throws Exception { + try { + if (tlibs != null) tlibs.close(); + if (rest != null) rest.close(); + if (previousPages != null) { + MercenaryMarketView.currentPage.clear(); + MercenaryMarketView.currentPage.putAll(previousPages); + } + if (previousUpgrades != null) { + CompanyUpgradeLoader.get().clear(); + CompanyUpgradeLoader.get().putAll(previousUpgrades); + } + for (var entry : globals.entrySet()) entry.getKey().set(null, entry.getValue()); + } finally { + fixture.close(); + } + } + + @Test + void aHiringGovernmentMemberCannotAcceptAfterLeavingTheCompanyHall() { + MercenaryContract contract = offer(); + contracts.detailView(customer, guild, contract.getId()); + Inventory menu = top(customer); + rest.when(() -> RestServer.getProvince(customer)).thenReturn(99); + contracts.click(fixture.ui.click(customer, 11), menu, customer); + assertTrue(contract.isOffered(), "Signing must still happen at the company's hall"); + assertEquals(1000.0, guild.getBank().getWealth()); + } + + @Test + void refreshingTheMarketAfterCompaniesDisappearReturnsToAValidPage() { + List<Guild> others = new ArrayList<>(); + for (int n = 0; n < 48; n++) { + Guild other = fixture.guild(host, "guild_" + n, "Captain_" + n); + form(other, "Blades " + n); + others.add(other); + } + market.marketList(customer); + Inventory menu = top(customer); + assertNotNull(menu.getItem(53)); + market.click(fixture.ui.click(customer, 53), menu, customer); + assertEquals(1, MercenaryMarketView.currentPage.get(customer)); + others.forEach(g -> g.setCompany(null)); + market.marketList(customer, menu); + assertEquals(0, MercenaryMarketView.currentPage.get(customer)); + assertNotNull(menu.getItem(0)); + assertNull(menu.getItem(53)); + } + + @Test + void aDraftFromTheContractMenuIsNotLostWhenTheInventoryIsFull() { + for (int slot = 0; slot < 41; slot++) + captain.getInventory().setItem(slot, new ItemStack(Material.STONE, 64)); + contracts.listView(captain, guild); + contracts.click(fixture.ui.click(captain, 4), top(captain), captain); + ArgumentCaptor<ItemStack> dropped = ArgumentCaptor.forClass(ItemStack.class); + verify(fixture.ui.world).dropItemNaturally(eq(captain.getLocation()), dropped.capture()); + assertEquals( + ContractBook.STAGE_DRAFT, ContractBook.stage((BookMeta) dropped.getValue().getItemMeta())); + assertEquals( + guild.getId(), ContractBook.companyGuildId((BookMeta) dropped.getValue().getItemMeta())); + assertTrue(company.getContractHandler().getAll().isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = {"unfounded", "forming", "formed"}) + void companyHomeShowsItsActualFormationStageAndKeepsGuildBannerUntouched(String state) { + if (state.equals("unfounded")) guild.setCompany(null); + if (state.equals("forming")) { + company = new MercenaryCompany(guild, "Hired Blades", new Regiment(prototype), 2); + guild.setCompany(company); + } + List<String> banner = new ArrayList<>(guild.getBannerPatterns()); + companies.companyView(captain, guild); + Inventory menu = top(captain); + assertEquals(SFGUI.COMPANY_VIEW, ((SFInventoryHolder) menu.getHolder()).getType()); + assertEquals(Material.BARRIER, menu.getItem(26).getType()); + if (state.equals("unfounded")) { + assertTrue(name(menu.getItem(4)).contains("Found a Mercenary Company")); + assertTrue(lore(menu.getItem(4)).contains("Cost: 100.00d")); + } else { + assertEquals("Hired Blades", name(menu.getItem(4))); + ItemStack entry = companies.creator.createCompanyEntryItem(guild); + assertEquals("Hired Blades", name(entry)); + if (state.equals("forming")) { + assertTrue(lore(menu.getItem(4)).contains("Being founded")); + assertTrue(lore(entry).contains("Being founded")); + companies.click(fixture.ui.click(captain, 11), menu, captain); + assertSame(menu, top(captain)); + company.tick(); + company.tick(); + companies.companyView(captain, guild, menu); + assertNotNull(menu.getItem(11)); + } else { + assertTrue(lore(menu.getItem(4)).contains("Slots: 1/1")); + assertEquals("Slots", name(menu.getItem(11))); + assertEquals("Roster", name(menu.getItem(13))); + assertEquals("Company Upgrades", name(menu.getItem(15))); + assertEquals("Contracts", name(menu.getItem(22))); + assertTrue(lore(entry).contains("Leader: Captain")); + } + } + assertEquals(banner, guild.getBannerPatterns()); + } + + @ParameterizedTest + @ValueSource(ints = {11, 13, 15, 22}) + void companyButtonsOpenTheirCorrespondingPublicMenus(int slot) { + companies.companyView(captain, guild); + companies.click(fixture.ui.click(captain, slot), top(captain), captain); + SFGUI expected = + switch (slot) { + case 11 -> SFGUI.COMPANY_SLOTS_VIEW; + case 13 -> SFGUI.COMPANY_ROSTER_VIEW; + case 15 -> SFGUI.COMPANY_UPGRADE_VIEW; + default -> SFGUI.CONTRACT_LIST_VIEW; + }; + assertEquals(expected, ((SFInventoryHolder) top(captain).getHolder()).getType()); + assertEquals(guild.getId(), ((SFInventoryHolder) top(captain).getHolder()).getId()); + assertEquals(Material.BARRIER, top(captain).getItem(53).getType()); + } + + @Test + void companyFoundingConfirmationQuotesBankAndCostWithoutSpending() { + ItemStack confirmation = companies.creator.createFoundConfirmItem(guild, "New Company"); + assertEquals("Found New Company?", name(confirmation)); + assertTrue(lore(confirmation).contains("Cost: 100.00d")); + assertTrue(lore(confirmation).contains("Guild bank: 1000.00d")); + assertTrue(lore(confirmation).contains("not refunded")); + assertEquals(1000, guild.getBank().getWealth()); + } + + @Test + void companyOverviewWarnsWhenRealRetainerCostsExceedTheGuildsIncome() { + company.getWageSettings().setPeacetimePerDay(1000000.0); + assertTrue(guild.getLedger().getNetIncome() < 0); + companies.companyView(captain, guild); + String summary = lore(top(captain).getItem(4)); + assertTrue(summary.contains("Burn exceeds what this guild earns")); + assertTrue(summary.contains("voids every contract")); + assertEquals(1000, guild.getBank().getWealth()); + } + + @Test + void largeCompanySlotsShowTheLastThirtySixAndCanRemoveAnUnusedSlot() { + assertTrue(company.adminAdjustSlots(39).ok()); + companies.slotsView(captain, guild); + Inventory menu = top(captain); + assertEquals("Slot 5", name(menu.getItem(9))); + assertEquals("Slot 40", name(menu.getItem(44))); + assertTrue(lore(menu.getItem(9)).contains("Empty")); + companies.click(fixture.ui.click(captain, 44), menu, captain); + assertEquals(39, company.getSlots()); + assertEquals("Slot 39", name(menu.getItem(44))); + assertEquals(List.of("Captain"), company.getEnlisted()); + assertEquals(1000, guild.getBank().getWealth()); + } + + @Test + void slotsExpandThroughTheMenuAndFilledSlotsCannotBeRemoved() { + companies.slotsView(captain, guild); + Inventory menu = top(captain); + assertTrue(lore(menu.getItem(9)).contains("Mercenary: Captain")); + assertEquals(Material.LIME_DYE, menu.getItem(4).getType()); + companies.click(fixture.ui.click(captain, 9), menu, captain); + assertEquals(1, company.getSlots()); + companies.click(fixture.ui.click(captain, 4), menu, captain); + assertEquals(1, company.getSlotQueue().size()); + assertEquals( + QueueCancelPayload.companySlot(guild.getId(), 0), + data(menu.getItem(45), Keys.QUEUE_CANCEL)); + assertEquals(Material.GRAY_DYE, menu.getItem(4).getType()); + assertTrue(lore(menu.getItem(4)).contains("Fill every slot")); + company.tick(); + company.tick(); + companies.slotsView(captain, guild, menu); + assertEquals(2, company.getSlots()); + assertNull(menu.getItem(45)); + assertTrue(lore(menu.getItem(10)).contains("Empty")); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void onlyTheCurrentLeaderMayExpandRemoveSlotsOrCancelQueuedSlots(boolean leaderStill) { + assertTrue(company.adminAdjustSlots(1).ok()); + company.addQueuedExpansion(60); + companies.slotsView(captain, guild); + Inventory menu = top(captain); + if (!leaderStill) { + guild.addMember("Successor"); + guild.setLeader("Successor"); + } + companies.click(fixture.ui.click(captain, 45), menu, captain); + if (leaderStill) + verify(navigation) + .openQueueCancelConfirm( + captain, + host, + QueueCancelPayload.companySlot(guild.getId(), 0), + "§eCancel queued slot expansion?"); + else verify(navigation, never()).openQueueCancelConfirm(any(), any(), anyString(), anyString()); + assertEquals(1, company.getSlotQueue().size()); + companies.click(fixture.ui.click(captain, 10), menu, captain); + assertEquals(leaderStill ? 1 : 2, company.getSlots()); + if (!leaderStill) { + companies.click(fixture.ui.click(captain, 4), menu, captain); + assertEquals(1, company.getSlotQueue().size()); + companies.slotsView(captain, guild, menu); + assertNull(menu.getItem(4)); + } + } + + @Test + void bottomInventorySlotsCannotRemoveCompanyCapacity() { + assertTrue(company.adminAdjustSlots(1).ok()); + companies.slotsView(captain, guild); + Inventory menu = top(captain); + InventoryClickEvent playerSlot = fixture.ui.click(captain, 54 + 10); + companies.click(playerSlot, menu, captain); + assertTrue(playerSlot.isCancelled()); + assertEquals(2, company.getSlots()); + } + + @Test + void restoredQueuesShowFrozenFirstTimersAndQueuedLaterEntries() { + for (int n = 0; n < 3; n++) { + company.addQueuedExpansion(60 + n); + company.addQueuedUpgrade(company.getUpgrade("company_health"), 60 + n); + } + War war = new War(1, host, hirer); + war.setPreparationFrozenUntil(Instant.now().plusSeconds(3600)); + WarManager.get().add(war); + companies.slotsView(captain, guild); + assertTrue(lore(top(captain).getItem(45)).contains("Frozen: battle postponed")); + assertTrue(lore(top(captain).getItem(46)).contains("Queued...")); + assertNotNull(top(captain).getItem(47)); + companies.companyUpgradeView(captain, guild); + assertTrue(lore(top(captain).getItem(39)).contains("Frozen: battle postponed")); + assertTrue(lore(top(captain).getItem(40)).contains("Queued...")); + assertEquals( + QueueCancelPayload.companyUpgrade(guild.getId(), 2), + data(top(captain).getItem(41), Keys.QUEUE_CANCEL)); + assertNull(top(captain).getItem(42)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void rosterClickDismissesOnlyForTheCurrentLeaderAndRefreshesTheSlot(boolean currentLeader) { + assertTrue(company.adminAdjustSlots(1).ok()); + assertTrue(company.enlist("Recruit")); + companies.rosterView(captain, guild); + Inventory menu = top(captain); + assertEquals("Recruit", data(menu.getItem(10), Keys.STRING_KEY)); + assertTrue(lore(menu.getItem(10)).contains("Click to dismiss")); + if (!currentLeader) { + guild.addMember("Successor"); + guild.setLeader("Successor"); + } + companies.click(fixture.ui.click(captain, 10), menu, captain); + assertEquals(!currentLeader, company.isEnlisted("Recruit")); + assertEquals(2, company.getSlots()); + if (currentLeader) assertNull(menu.getItem(10)); + else assertFalse(lore(menu.getItem(10)).contains("Click to dismiss")); + } + + @Test + void rosterAndUpgradeMenusRespectTheirDisplayCapacity() { + assertTrue(company.adminAdjustSlots(40).ok()); + for (int n = 0; n < 40; n++) assertTrue(company.enlist("Recruit" + n)); + companies.rosterView(captain, guild); + assertEquals("Recruit34", data(top(captain).getItem(44), Keys.STRING_KEY)); + assertNull(top(captain).getItem(45)); + for (int n = 0; n < 8; n++) upgrade("additional_" + n, 2); + company = form(guild, "Hired Blades"); + companies.companyUpgradeView(captain, guild); + for (int slot = 9; slot <= 17; slot++) + assertNotNull(data(top(captain).getItem(slot), Keys.STRING_KEY)); + assertNull(top(captain).getItem(18)); + assertEquals(Material.BARRIER, top(captain).getItem(53).getType()); + } + + @ParameterizedTest + @ValueSource(strings = {"order", "cancel", "former_leader", "maxed"}) + void companyUpgradeActionsUseCurrentAuthorityAndTheRealQueue(String action) { + Upgrade health = company.getUpgrade("company_health"); + if (action.equals("maxed")) health.setLevel(health.getMaxLevel()); + if (action.equals("cancel")) company.addQueuedUpgrade(health, 10); + companies.companyUpgradeView(captain, guild); + Inventory menu = top(captain); + int slot = find(menu, Keys.STRING_KEY, health.getId()); + assertTrue(slot >= 9 && slot <= 17); + assertTrue(lore(menu.getItem(slot)).contains("Only while fighting as a hired mercenary")); + assertTrue(lore(menu.getItem(slot)).contains("per level")); + if (action.equals("former_leader")) { + guild.addMember("Successor"); + guild.setLeader("Successor"); + } + companies.click(fixture.ui.click(captain, action.equals("cancel") ? 39 : slot), menu, captain); + if (action.equals("cancel")) { + verify(navigation) + .openQueueCancelConfirm( + captain, + host, + QueueCancelPayload.companyUpgrade(guild.getId(), 0), + "§eCancel queued company upgrade?"); + assertEquals(1, company.getUpgradeQueue().size()); + } else if (action.equals("order")) { + assertEquals(1, company.getUpgradeQueue().size()); + assertEquals(0, health.getLevel()); + company.tick(); + company.tick(); + assertEquals(1, health.getLevel()); + } else { + assertTrue(company.getUpgradeQueue().isEmpty()); + if (action.equals("maxed")) + assertTrue(lore(menu.getItem(slot)).contains("Maximum level reached")); + } + assertEquals(1000, guild.getBank().getWealth()); + } + + @Test + void spymasterProtectedCompanyOverviewContainsOnlyReportedInformation() { + SpecialPositionAssignment assignment = new SpecialPositionAssignment(); + assignment.playerName = "Host"; + assignment.playerId = fixture.player("Host").getUniqueId(); + assignment.characterId = "host_character"; + host.getEspionage().appoint(assignment, 50); + companies.companyView(customer, guild); + Inventory menu = top(customer); + assertTrue(((SFInventoryHolder) menu.getHolder()).isReported()); + assertTrue(lore(menu.getItem(4)).contains("Unknown")); + for (ItemStack item : menu.getContents()) { + if (item != null && item.hasItemMeta()) { + assertNull(data(item, Keys.QUEUE_CANCEL)); + assertNull(data(item, Keys.CONTRACT_ID)); + } + } + companies.companyView(customer, guild, menu); + assertSame(menu, top(customer)); + } + + @ParameterizedTest + @ValueSource(strings = {"slots", "roster", "upgrades", "contracts", "detail"}) + void removedCompaniesClearPreviouslyRenderedMenusWithoutChangingDetachedState(String target) { + MercenaryContract offered = offer(); + Inventory menu; + switch (target) { + case "slots" -> companies.slotsView(captain, guild); + case "roster" -> companies.rosterView(captain, guild); + case "upgrades" -> companies.companyUpgradeView(captain, guild); + case "contracts" -> contracts.listView(captain, guild); + default -> contracts.detailView(captain, guild, offered.getId()); + } + menu = top(captain); + guild.setCompany(null); + switch (target) { + case "slots" -> companies.slotsView(captain, guild, menu); + case "roster" -> companies.rosterView(captain, guild, menu); + case "upgrades" -> companies.companyUpgradeView(captain, guild, menu); + case "contracts" -> contracts.listView(captain, guild, menu); + default -> contracts.detailView(captain, guild, menu, offered.getId()); + } + for (ItemStack item : menu.getContents()) assertNull(item); + companies.click(fixture.ui.click(captain, 9), menu, captain); + contracts.click(fixture.ui.click(captain, 11), menu, captain); + assertTrue(offered.isOffered()); + assertEquals(List.of("Captain"), company.getEnlisted()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void localHiringGovernmentCanAcceptOrDeclineWithoutAnUpfrontPayment(boolean accepts) { + MercenaryContract contract = offer(); + contracts.detailView(customer, guild, contract.getId()); + Inventory menu = top(customer); + assertTrue(name(menu.getItem(11)).contains("Accept the contract")); + assertTrue(name(menu.getItem(15)).contains("Decline the offer")); + double wealth = guild.getBank().getWealth() + hirer.getBank().getWealth(); + contracts.click(fixture.ui.click(customer, accepts ? 11 : 15), menu, customer); + assertEquals(accepts ? ContractStatus.ACTIVE : ContractStatus.TERMINATED, contract.getStatus()); + assertNull(menu.getItem(11)); + assertNull(menu.getItem(15)); + assertEquals(wealth, guild.getBank().getWealth() + hirer.getBank().getWealth()); + assertEquals(accepts ? 0 : 1, MercenaryMarket.availableToday(company)); + } + + @Test + void nullSignerCannotActivateAContractOrConsumeItsReservation() { + MercenaryContract contract = offer(); + assertFalse(company.getContractHandler().acceptAtHall(contract.getId(), hirer, null).ok()); + assertTrue(contract.isOffered()); + assertEquals(0, MercenaryMarket.availableToday(company)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void clickingContractInformationDoesNotAcceptAnOfferOrItsPendingAmendment(boolean active) { + assertTrue(company.adminAdjustSlots(1).ok()); + MercenaryContract contract = offer(); + if (active) { + assertTrue(company.getContractHandler().acceptAtHall(contract.getId(), hirer, customer).ok()); + assertTrue( + company + .getContractHandler() + .proposeSlots(contract.getId(), "Captain", 2, System.currentTimeMillis()) + .ok()); + } + contracts.detailView(customer, guild, contract.getId()); + Inventory menu = top(customer); + contracts.click(fixture.ui.click(customer, 13), menu, customer); + assertEquals(active ? ContractStatus.ACTIVE : ContractStatus.OFFERED, contract.getStatus()); + assertEquals(1, contract.getSlots()); + assertEquals(active, contract.hasPendingSlots(System.currentTimeMillis())); + assertSame(menu, top(customer)); + } + + @Test + void closedCompaniesAndUnaffiliatedViewersCannotSignAtTheMarket() { + Player outsider = fixture.player("Outsider"); + assertFalse(MercenaryMarket.canSign(company, outsider).ok()); + assertTrue(MercenaryMarket.canSign(company, outsider).message().contains("not in a faction")); + MercenaryCompany forming = + new MercenaryCompany(guild, "New Company", new Regiment(prototype), 2); + assertFalse(MercenaryMarket.canSign(forming, customer).ok()); + assertFalse(MercenaryMarket.canSign(null, customer).ok()); + assertTrue(company.getContractHandler().getAll().isEmpty()); + } + + @ParameterizedTest + @EnumSource(ContractStatus.class) + void contractCardsUseStatusMaterialsAndExplainBothPrices(ContractStatus status) { + MercenaryContract contract = offer(); + if (status != ContractStatus.OFFERED) assertTrue(contract.activate()); + if (status.isFinished()) assertTrue(contract.finish(status)); + contracts.listView(captain, guild); + ItemStack card = top(captain).getItem(9); + Material expected = + switch (status) { + case OFFERED -> Material.WRITABLE_BOOK; + case ACTIVE -> Material.WRITTEN_BOOK; + case COMPLETED -> Material.BOOK; + case BREACHED -> Material.SHIELD; + case TERMINATED -> Material.PAPER; + }; + assertEquals(expected, card.getType()); + assertEquals(contract.getId(), data(card, Keys.CONTRACT_ID)); + assertTrue(lore(card).contains("Status: " + ContractCreator.label(status))); + assertTrue(lore(card).contains("day price AND the battle price")); + contracts.click(fixture.ui.click(captain, 9), top(captain), captain); + Inventory detail = top(captain); + assertTrue(lore(detail.getItem(13)).contains("Company: Hired Blades")); + assertTrue(lore(detail.getItem(13)).contains("Per slot per day: 10.00d")); + assertTrue(lore(detail.getItem(13)).contains("Breach refund: 500.00d")); + if (status.isFinished()) { + contracts.click(fixture.ui.click(captain, 11), detail, captain); + assertEquals(status, contract.getStatus()); + } + } + + @Test + void contractListReservesControlRowsAndRefreshesDeletedRecords() { + assertTrue(company.adminAdjustSlots(39).ok()); + for (int n = 0; n < 37; n++) offer(); + contracts.listView(captain, guild); + Inventory list = top(captain); + assertNotNull(data(list.getItem(44), Keys.CONTRACT_ID)); + assertNull(list.getItem(45)); + assertEquals("Draft a contract", name(list.getItem(4))); + company + .getContractHandler() + .getAll() + .forEach(c -> company.getContractHandler().remove(c.getId())); + contracts.listView(captain, guild, list); + assertNull(list.getItem(9)); + assertNull(list.getItem(44)); + assertEquals(Material.BARRIER, list.getItem(53).getType()); + } + + @ParameterizedTest + @ValueSource( + strings = {"propose", "withdraw", "accept", "decline", "former_captain", "former_hirer"}) + void activeContractSlotChangesRespectBothPartiesAndPreserveSignedPrices(String action) { + assertTrue(company.adminAdjustSlots(2).ok()); + MercenaryContract contract = offer(); + assertTrue(company.getContractHandler().acceptAtHall(contract.getId(), hirer, customer).ok()); + boolean pending = !action.equals("propose"); + if (pending) + assertTrue( + company + .getContractHandler() + .proposeSlots(contract.getId(), "Captain", 2, System.currentTimeMillis()) + .ok()); + Player actor = + List.of("accept", "decline", "former_hirer").contains(action) ? customer : captain; + contracts.detailView(actor, guild, contract.getId()); + Inventory menu = top(actor); + long due = contract.getDueDate(); + if (action.equals("former_captain")) { + guild.addMember("Successor"); + guild.setLeader("Successor"); + } + if (action.equals("former_hirer")) { + hirer.addMember("Successor"); + hirer.setLeader("Successor"); + } + if (pending) { + assertTrue(lore(menu.getItem(13)).contains("Slot change waiting: 1 → 2")); + assertTrue( + lore(contracts.creator.createContractItem(contract)).contains("Slot change waiting: 2")); + } + int slot = actor == captain ? 11 : action.equals("decline") ? 16 : 15; + contracts.click(fixture.ui.click(actor, slot), menu, actor); + if (action.equals("propose")) verify(navigation).beginSlotChange(captain, guild, contract); + else if (action.startsWith("former")) + assertEquals(2, contract.getPendingSlots(System.currentTimeMillis())); + else assertFalse(contract.hasPendingSlots(System.currentTimeMillis())); + assertEquals(action.equals("accept") ? 2 : 1, contract.getSlots()); + assertEquals(10, contract.getPricePerSlotPerDay()); + assertEquals(50, contract.getPricePerSlotPerBattle()); + assertEquals(due, contract.getDueDate()); + assertEquals(1000, guild.getBank().getWealth()); + } + + @Test + void contractMenusIgnoreOutsidersStaleLeadersRemovedContractsAndDecorationClicks() { + MercenaryContract contract = offer(); + Player outsider = fixture.player("Outsider"); + contracts.detailView(outsider, guild, contract.getId()); + assertNull(top(outsider).getItem(11)); + contracts.click(fixture.ui.click(outsider, 11), top(outsider), outsider); + assertTrue(contract.isOffered()); + contracts.listView(captain, guild); + Inventory menu = top(captain); + guild.addMember("Successor"); + guild.setLeader("Successor"); + contracts.click(fixture.ui.click(captain, 4), menu, captain); + assertNull(captain.getInventory().getItem(0)); + contracts.click(fixture.ui.click(captain, 0), menu, captain); + menu.setItem(0, new ItemStack(Material.STONE)); + contracts.click(fixture.ui.click(captain, 0), menu, captain); + assertSame(menu, top(captain)); + company.getContractHandler().remove(contract.getId()); + contracts.detailView(customer, guild, contract.getId()); + contracts.click(fixture.ui.click(customer, 11), top(customer), customer); + assertTrue(contract.isOffered()); + assertEquals(1000, guild.getBank().getWealth()); + } + + @Test + void marketCardsShowRealAvailabilityAndBothNavigationDirectionsWork() { + List<Guild> others = new ArrayList<>(); + for (int n = 0; n < 48; n++) { + Guild other = fixture.guild(host, "guild_" + n, "Captain_" + n); + MercenaryCompany created = form(other, "Blades " + n); + created.setReputation(n); + others.add(other); + } + company.setReputation(100); + market.marketList(customer); + Inventory menu = top(customer); + assertEquals(guild.getId(), data(menu.getItem(0), Keys.STRING_KEY)); + assertTrue(lore(menu.getItem(0)).contains("Free today: 1")); + assertTrue(lore(menu.getItem(0)).contains("Home: Province 10")); + market.click(fixture.ui.click(customer, 0), menu, customer); + verify(navigation).contractListView(customer, guild); + market.click(fixture.ui.click(customer, 53), menu, customer); + assertEquals(1, MercenaryMarketView.currentPage.get(customer)); + assertNotNull(menu.getItem(45)); + assertNull(menu.getItem(53)); + market.click(fixture.ui.click(customer, 45), menu, customer); + assertEquals(0, MercenaryMarketView.currentPage.get(customer)); + assertEquals(guild.getId(), data(menu.getItem(0), Keys.STRING_KEY)); + assertTrue( + market.buildListingLore(null, company).stream() + .noneMatch(line -> line.contains("You may hire"))); + } + + @Test + void menuDispatchGuardsIgnoreOtherInventoriesMissingGuildsAndUnkeyedItems() { + Inventory ordinary = fixture.ui.inventory(null, 9, "Other plugin"); + captain.openInventory(ordinary); + InventoryClickEvent other = fixture.ui.click(captain, 0); + companies.click(other, ordinary, captain); + contracts.click(other, ordinary, captain); + market.click(other, ordinary, captain); + assertFalse(other.isCancelled()); + companies.companyView(captain, guild); + market.click(fixture.ui.click(captain, 0), top(captain), captain); + companies.click(fixture.ui.click(captain, 0), top(captain), captain); + contracts.click(fixture.ui.click(captain, 0), top(captain), captain); + companies.rosterView(captain, guild); + companies.click(fixture.ui.click(captain, 0), top(captain), captain); + top(captain).setItem(0, new ItemStack(Material.STONE)); + companies.click(fixture.ui.click(captain, 0), top(captain), captain); + companies.companyUpgradeView(captain, guild); + companies.click(fixture.ui.click(captain, 0), top(captain), captain); + market.marketList(captain); + Inventory listing = top(captain); + market.click(fixture.ui.click(captain, 8), listing, captain); + listing.setItem(8, new ItemStack(Material.STONE)); + market.click(fixture.ui.click(captain, 8), listing, captain); + listing.setItem(8, companies.creator.createSlotsButton(guild)); + market.click(fixture.ui.click(captain, 8), listing, captain); + FactionManager.factions.remove(host); + market.click(fixture.ui.click(captain, 0), listing, captain); + Inventory missing = + fixture.ui.inventory(new SFInventoryHolder(guild.getId(), SFGUI.COMPANY_VIEW), 27, "Stale"); + captain.openInventory(missing); + companies.click(fixture.ui.click(captain, 11), missing, captain); + contracts.click(fixture.ui.click(captain, 11), missing, captain); + verify(navigation, never()).contractListView(any(), any()); + assertEquals(List.of("Captain"), company.getEnlisted()); + } + + private Upgrade upgrade(String id, int maximum) { + YamlConfiguration config = new YamlConfiguration(); + config.set(id + ".name", id); + config.set(id + ".icon", "writable_book.0"); + config.set(id + ".upkeep", 10.0); + config.set(id + ".expansion-time", 2); + config.set(id + ".description", List.of("A trained company discipline")); + config.set(id + ".modifiers", List.of("MAX_HEALTH 0 0.5")); + if (maximum > 0) config.set(id + ".max-level", maximum); + Upgrade result = new Upgrade(id, config.getConfigurationSection(id)); + CompanyUpgradeLoader.get().put(id, result); + return result; + } + + private int find(Inventory inventory, org.bukkit.NamespacedKey key, String id) { + for (int slot = 0; slot < inventory.getSize(); slot++) { + ItemStack item = inventory.getItem(slot); + if (item != null && item.hasItemMeta() && id.equals(data(item, key))) return slot; + } + return -1; + } + + private String name(ItemStack item) { + return ChatColor.stripColor(item.getItemMeta().getDisplayName()); + } + + private String lore(ItemStack item) { + return ChatColor.stripColor(String.join("\n", item.getItemMeta().getLore())); + } + + private String data(ItemStack item, org.bukkit.NamespacedKey key) { + return item.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); + } + + private MercenaryContract offer() { + var offered = company.getContractHandler().offer(hirer, ContractTerms.defaults()); + assertTrue(offered.ok(), offered.message()); + return offered.contract(); + } + + private MercenaryCompany form(Guild owner, String name) { + MercenaryCompany created = new MercenaryCompany(owner, name, new Regiment(prototype), 0); + owner.setCompany(created); + created.enlistLeader(); + return created; + } + + private Inventory top(Player player) { + return player.getOpenInventory().getTopInventory(); + } + + private void save(Class<?> type, String name, Object value) throws Exception { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + globals.put(field, field.get(null)); + field.set(null, value); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MovementMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MovementMenusCoverageTest.java new file mode 100644 index 00000000..eedf4a74 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/MovementMenusCoverageTest.java @@ -0,0 +1,783 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import net.tfminecraft.simplefactions.enums.Member; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.government.Government; +import net.tfminecraft.simplefactions.government.movement.Action; +import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.government.movement.MovementCrackdownQueries; +import net.tfminecraft.simplefactions.government.movement.MovementOutcomeService; +import net.tfminecraft.simplefactions.government.movement.MovementOutcomeSource; +import net.tfminecraft.simplefactions.government.movement.Phase; +import net.tfminecraft.simplefactions.government.movement.PoliticalAction; +import net.tfminecraft.simplefactions.government.movement.Pool; +import net.tfminecraft.simplefactions.government.movement.cause.Cause; +import net.tfminecraft.simplefactions.government.proposal.Proposal; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.keys.Keys; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.managers.LogManager; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import net.tfminecraft.simplefactions.utils.LoreWriter; +import net.tfminecraft.simplefactions.war.civilwar.CivilWarStartService; +import net.tfminecraft.simplefactions.war.resolution.CouncilPeaceQueries; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemCreator; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +/** Real menus, proposals, causes and support pools; faction/domain services are scoped boundaries. */ +class MovementMenusCoverageTest { + private GuiTestFixture ui; + private Player player; + private Player ruler; + private Faction faction; + private Government government; + private Movement movement; + private Cause cause; + private final List<Cause> causes = new ArrayList<>(); + private final List<Movement> movements = new ArrayList<>(); + private final List<Faction> backers = new ArrayList<>(); + private final Map<String, Player> online = new HashMap<>(); + private final Pool supporters = new Pool(); + private final AtomicReference<String> leader = new AtomicReference<>("Alice"); + private final AtomicReference<Phase> phase = new AtomicReference<>(Phase.REBELLIOUS); + private InventoryManager manager; + private MovementCreator creator; + private MovementView view; + private final List<MockedStatic<?>> mocks = new ArrayList<>(); + private MockedStatic<FactionManager> lookup; + private MockedStatic<MovementCrackdownQueries> crackdown; + private MockedStatic<MovementOutcomeService> outcomes; + private MockedStatic<CivilWarStartService> civilWars; + private MockedStatic<LoreWriter> lore; + private ItemCreator itemCreator; + private List<Faction> previousFactions; + + private <T> MockedStatic<T> scoped(Class<T> type) { + MockedStatic<T> result = mockStatic(type); + mocks.add(result); + return result; + } + + @BeforeEach + void setup() { + ui = new GuiTestFixture(); + previousFactions = FactionManager.factions; + FactionManager.factions = new ArrayList<>(); + player = ui.player("Alice"); + ruler = ui.player("Bob"); + online.put("Alice", player); + online.put("Bob", ruler); + when(Bukkit.getPlayer(anyString())).thenAnswer(call -> online.get(call.getArgument(0))); + faction = mock(Faction.class); + when(faction.getId()).thenReturn("home"); + when(faction.getName()).thenReturn("Homeland"); + when(faction.getLeader()).thenReturn("Bob"); + when(faction.getMembers()).thenAnswer(call -> new ArrayList<>(List.of("Alice", "Bob", "Cara"))); + when(faction.getVassalMembers()).thenReturn(List.of("Dan")); + when(faction.canBecomeLeader(anyString())).thenReturn(true); + when(faction.getRelationToFaction(anyString())).thenReturn(Member.MEMBER); + government = mock(Government.class); + when(government.getFaction()).thenReturn(faction); + when(faction.getGovernment()).thenReturn(government); + when(government.getMovements()).thenReturn(movements); + FactionManager.factions.add(faction); + lookup = scoped(FactionManager.class); + lookup.when(() -> FactionManager.getByString("home")).thenReturn(faction); + lookup.when(() -> FactionManager.getByMember(anyString())).thenReturn(faction); + movement = mock(Movement.class); + when(movement.getId()).thenReturn("movement-1"); + when(movement.getFaction()).thenReturn(faction); + when(movement.getLeader()).thenAnswer(call -> leader.get()); + when(movement.hasLeader()).thenAnswer(call -> leader.get() != null); + when(movement.isLeader(nullable(String.class))).thenAnswer(call -> leader.get() != null && leader.get().equalsIgnoreCase(call.getArgument(0))); + doAnswer(call -> { leader.set(call.getArgument(0)); return null; }).when(movement).setLeader(nullable(String.class)); + when(movement.canBeLeader("Alice")).thenReturn(true); + when(movement.getPhase()).thenAnswer(call -> phase.get()); + doAnswer(call -> { phase.set(call.getArgument(0)); return null; }).when(movement).setPhase(any()); + when(movement.getOrganization()).thenReturn(100.0); + when(movement.getMaxOrganization()).thenReturn(100.0); + when(movement.getOrganizationGain()).thenReturn(5.5); + when(movement.getPower()).thenReturn(60.0); + when(movement.getStabilityEffect()).thenReturn(12.5); + when(movement.getCauses()).thenReturn(causes); + when(movement.getSupporters()).thenReturn(supporters); + when(movement.getForeignBackers()).thenReturn(backers); + when(movement.getAllMembers()).thenAnswer(call -> { + List<String> names = new ArrayList<>(supporters.getAllMembers()); + for (Cause entry : causes) if (entry != null) names.addAll(entry.getFullMemberList()); + return names; + }); + when(movement.quickJoinCheck(any())).thenReturn(true); + when(movement.getJoiningAs(any())).thenAnswer(call -> ((Player) call.getArgument(0)).getName()); + doAnswer(call -> { + Object joining = call.getArgument(0); + Cause selected = call.getArgument(1); + if (selected != null) selected.leave(joining); + else if (joining instanceof String citizen) supporters.removeCitizen(citizen); + else if (joining instanceof Guild guild) supporters.removeGuild(guild); + else if (joining instanceof Faction supporter) supporters.removeFaction(supporter); + return null; + }).when(movement).leave(any(), nullable(Cause.class)); + doAnswer(call -> { backers.remove(call.getArgument(0)); return null; }).when(movement).leaveAsForeignBacker(any()); + lookup.when(() -> FactionManager.getMovementById("movement-1")).thenReturn(movement); + when(government.getMovementById("movement-1")).thenReturn(movement); + movements.add(movement); + cause = cause(Action.CHANGE_LEADER); + cause.getProposal().setTarget("Cara"); + crackdown = scoped(MovementCrackdownQueries.class); + crackdown.when(() -> MovementCrackdownQueries.canCrush(faction, movement)).thenReturn(true); + crackdown.when(() -> MovementCrackdownQueries.denyReason(faction, movement)).thenReturn("Movement is too strong"); + outcomes = scoped(MovementOutcomeService.class); + civilWars = scoped(CivilWarStartService.class); + scoped(LogManager.class); + lore = scoped(LoreWriter.class); + lore.when(() -> LoreWriter.applyProposalLore(any(), anyList(), any(), any(), any())).thenAnswer(call -> { + List<String> lines = call.getArgument(1); + lines.add("Proposal effects preview"); + return null; + }); + // The real query is pure for this action classification; all other war work stays at its service boundary. + scoped(CouncilPeaceQueries.class).when(() -> CouncilPeaceQueries.isWarEndAction(any())).thenAnswer(call -> + call.getArgument(0) == Action.WHITE_PEACE || call.getArgument(0) == Action.SURRENDER); + ItemAPI api = mock(ItemAPI.class); + itemCreator = mock(ItemCreator.class); + when(api.getCreator()).thenReturn(itemCreator); + when(itemCreator.getItemFromPath(anyString())).thenAnswer(call -> new ItemStack(Material.PAPER)); + when(itemCreator.getItemsAdderItem(anyString())).thenAnswer(call -> new ItemStack(Material.PAPER)); + scoped(TLibs.class).when(TLibs::getItemAPI).thenReturn(api); + manager = mock(InventoryManager.class); + manager.confirming = new HashMap<>(); + manager.governmentView = mock(GovernmentView.class); + when(manager.createBackButton(any())).thenAnswer(call -> new ItemStack(Material.BARRIER)); + creator = new MovementCreator(); + view = new MovementView(manager); + } + + @AfterEach + void cleanup() { + for (int i = mocks.size() - 1; i >= 0; i--) mocks.get(i).close(); + FactionManager.factions = previousFactions; + ui.close(); + } + + private Cause cause(Action action) { + YamlConfiguration config = new YamlConfiguration(); + config.set("name", action.name()); + config.set("description", List.of("Apply the requested political change")); + config.set("pools", List.of("citizens", "guilds", "factions")); + PoliticalAction political = new PoliticalAction(action.name(), config); + Proposal proposal = new Proposal("Alice", government); + proposal.setPoliticalActionProposal(political); + Cause created = new Cause(movement, proposal, "Alice"); + causes.add(created); + return created; + } + + private Inventory top(Player viewer) { return viewer.getOpenInventory().getTopInventory(); } + private Inventory top() { return top(player); } + private SFGUI menu(Player viewer) { return ((SFInventoryHolder) top(viewer).getHolder()).getType(); } + private void click(Player viewer, int slot) { + Inventory inventory = top(viewer); + InventoryClickEvent event = ui.click(viewer, slot); + view.click(event, inventory, viewer); + assertTrue(event.isCancelled()); + } + private void click(int slot) { click(player, slot); } + private static String text(ItemStack item) { + ItemMeta meta = item.getItemMeta(); + return ChatColor.stripColor(meta.getDisplayName() + "\n" + String.join("\n", meta.getLore() == null ? List.of() : meta.getLore())); + } + private String id(ItemStack item) { return item.getItemMeta().getPersistentDataContainer().get(Keys.STRING_KEY, PersistentDataType.STRING); } + + @Test + void leaderlessMovementStillOpensAndAllowsEligiblePlayerToTakeLeadership() { + leader.set(null); + assertDoesNotThrow(() -> view.movementView(player, faction, movement, null)); + assertEquals(Material.GREEN_CONCRETE, top().getItem(10).getType()); + assertTrue(text(top().getItem(28)).contains("Only the movement leader")); + click(10); + assertEquals("Alice", leader.get()); + assertEquals(Material.PLAYER_HEAD, top().getItem(10).getType()); + } + + @Test + void removedCauseDoesNotCrashAnAlreadyOpenDetailsMenu() { + view.causeView(player, faction, movement, cause, null); + causes.clear(); + assertDoesNotThrow(() -> click(14)); + verify(movement, never()).leave(any(), any()); + lookup.verify(() -> FactionManager.requestMovementJoin(any(), any(), any(), any()), never()); + } + + @ParameterizedTest + @ValueSource(strings = {"details", "list", "target"}) + void aRemovedDisplayedCauseCannotBeReplacedByTheCauseThatMovesIntoItsIndex(String screen) { + Cause replacement = cause(Action.SNAP_ELECTIONS); + if (screen.equals("details")) view.causeView(player, faction, movement, cause, null); + else if (screen.equals("list")) view.causesView(player, faction, movement, null); + else view.targetSelectionView(player, faction, movement, cause, null); + Inventory original = top(); + causes.remove(cause); + click(screen.equals("details") ? 14 : 10); + verify(movement, never()).leave(any(), eq(replacement)); + lookup.verify(() -> FactionManager.requestMovementLeaderTarget(any(), any(), eq(replacement), anyString()), never()); + assertSame(original, top()); + assertTrue(replacement.getFullMemberList().contains("Alice")); + } + + @Test + void removingAnEarlierCauseDoesNotRedirectTheSurvivingCauseDetailsAction() { + Cause displayed = cause(Action.SNAP_ELECTIONS); + Cause later = cause(Action.NATIONHOOD); + view.causeView(player, faction, movement, displayed, null); + causes.remove(cause); + click(14); + verify(movement).leave("Alice", displayed); + verify(movement, never()).leave("Alice", later); + assertFalse(displayed.getFullMemberList().contains("Alice")); + assertTrue(later.getFullMemberList().contains("Alice")); + } + + @Test + void joiningCauseRequestsTheSelectedCauseRatherThanGeneralSupport() { + cause.getPool().getCitizens().clear(); + view.causeView(player, faction, movement, cause, null); + click(14); + lookup.verify(() -> FactionManager.requestMovementJoin(player, movement, "member", cause)); + } + + @Test + void overviewRendersStateAndNavigatesFromFactionListThroughCauseDetails() { + supporters.addCitizen("Cara"); + view.movementListView(player, faction, null); + assertTrue(text(top().getItem(10)).contains("Organization: 100")); + assertEquals("movement-1", id(top().getItem(10))); + click(10); + assertEquals(SFGUI.MOVEMENT_VIEW, menu(player)); + assertTrue(text(top().getItem(11)).contains("+5.5")); + assertTrue(text(top().getItem(15)).contains("Cara")); + assertNotNull(top().getItem(19)); + assertNotNull(top().getItem(34)); + assertNull(top().getItem(25)); + click(13); + assertEquals(SFGUI.CAUSES_VIEW, menu(player)); + assertTrue(text(top().getItem(10)).contains("Proposal effects preview")); + click(10); + assertEquals(SFGUI.CAUSE_VIEW, menu(player)); + assertEquals(0, ((SFInventoryHolder) top().getHolder()).getPage()); + assertTrue(text(top().getItem(12)).contains("Proposal effects preview")); + assertTrue(text(top().getItem(14)).contains("Leave Cause")); + assertTrue(text(top().getItem(28)).contains("Cara")); + lore.verify(() -> LoreWriter.applyProposalLore(eq(cause.getProposal()), anyList(), eq(player), eq(faction), any()), atLeastOnce()); + } + + @Test + void allPublicViewsRedrawInPlaceAndKeepTheRightActionMetadata() { + view.movementView(ruler, faction, movement, null); + Inventory overview = top(ruler); + view.movementView(ruler, faction, movement, overview); + assertSame(overview, top(ruler)); + assertNotNull(overview.getItem(25)); + assertNull(overview.getItem(19)); + view.causesView(player, faction, movement, null); + Inventory causesInventory = top(); + view.causesView(player, faction, movement, causesInventory); + assertSame(causesInventory, top()); + assertEquals("movement-1", id(top().getItem(10))); + view.causeView(player, faction, movement, cause, null); + Inventory causeInventory = top(); + view.causeView(player, faction, movement, cause, causeInventory); + assertSame(causeInventory, top()); + view.targetSelectionView(player, faction, movement, cause, null); + Inventory targets = top(); + view.targetSelectionView(player, faction, movement, cause, targets); + assertSame(targets, top()); + assertEquals("Alice", id(targets.getItem(10))); + view.movementListView(player, faction, null); + Inventory list = top(); + view.movementListView(player, faction, list); + assertSame(list, top()); + view.demandsView(ruler, faction, movement, null); + Inventory demands = top(ruler); + view.demandsView(ruler, faction, movement, demands); + assertSame(demands, top(ruler)); + assertTrue(text(demands.getItem(29)).contains("12.5")); + view.crackdownView(player, faction, movement, null); + Inventory disband = top(); + view.crackdownView(player, faction, movement, disband); + assertSame(disband, top()); + assertTrue(text(disband.getItem(22)).contains("Civil War")); + assertEquals(Material.RED_CONCRETE, disband.getItem(33).getType()); + } + + @ParameterizedTest + @ValueSource(strings = {"citizen", "guild", "vassal"}) + void leavingGeneralSupportUsesTheRepresentedEntityAndDropsMovementLeadership(String kind) { + Object represented = player.getName(); + if (kind.equals("citizen")) supporters.addCitizen("Alice"); + if (kind.equals("guild")) { + Guild guild = mock(Guild.class); + when(guild.getMembers()).thenReturn(List.of("Alice")); + when(guild.getName()).thenReturn("Artisans"); + when(faction.getRelationToFaction("Alice")).thenReturn(Member.GUILD_MEMBER); + lookup.when(() -> FactionManager.getGuildByMember("Alice")).thenReturn(guild); + supporters.addGuild(guild); + represented = guild; + } + if (kind.equals("vassal")) { + when(faction.getRelationToFaction("Alice")).thenReturn(Member.VASSAL_LEADER); + supporters.addFaction(faction); + represented = faction; + } + view.movementView(player, faction, movement, null); + click(15); + verify(movement).leave(represented, null); + assertTrue(supporters.getAllMembers().isEmpty()); + assertNull(leader.get()); + assertTrue(text(top().getItem(15)).contains("Join as Supporter")); + } + + @ParameterizedTest + @ValueSource(strings = {"quick", "identity", "blocked", "allowed"}) + void generalSupportRequestsRecheckEligibilityAndExplainDenials(String scenario) { + if (scenario.equals("quick")) when(movement.quickJoinCheck(player)).thenReturn(false); + if (scenario.equals("identity")) when(movement.getJoiningAs(player)).thenReturn(null); + if (scenario.equals("blocked")) when(movement.joinBlockReason("Alice", null, false)).thenReturn("Already committed elsewhere"); + view.movementView(player, faction, movement, null); + click(15); + if (scenario.equals("allowed")) lookup.verify(() -> FactionManager.requestMovementJoin(player, movement, "supporter", null)); + else lookup.verify(() -> FactionManager.requestMovementJoin(any(), any(), anyString(), nullable(Cause.class)), never()); + if (scenario.equals("blocked")) verify(player).sendMessage("Already committed elsewhere"); + } + + @ParameterizedTest + @ValueSource(strings = {"join", "blocked", "leave", "supporter", "own", "factionless"}) + void foreignBackersCannotLeaveWhileSupportingAndNewRequestsRespectRules(String scenario) { + Faction foreign = mock(Faction.class); + when(foreign.getId()).thenReturn("foreign"); + when(foreign.getName()).thenReturn("Overseas"); + lookup.when(() -> FactionManager.getByMember("Alice")).thenReturn(scenario.equals("own") ? faction : scenario.equals("factionless") ? null : foreign); + if (scenario.equals("leave") || scenario.equals("supporter")) backers.add(foreign); + if (scenario.equals("supporter")) supporters.addCitizen("Alice"); + if (scenario.equals("blocked")) when(movement.foreignBackerBlockReason(foreign, false)).thenReturn("Backer prohibited"); + view.movementView(player, faction, movement, null); + click(16); + if (scenario.equals("join")) lookup.verify(() -> FactionManager.requestMovementJoin(player, movement, "foreign_backer", null)); + else lookup.verify(() -> FactionManager.requestMovementJoin(any(), any(), anyString(), nullable(Cause.class)), never()); + if (scenario.equals("leave")) { verify(movement).leaveAsForeignBacker(foreign); assertTrue(backers.isEmpty()); } + if (scenario.equals("supporter")) { assertEquals(List.of(foreign), backers); verify(player).sendMessage(contains("while you are a supporter")); } + if (scenario.equals("blocked")) verify(player).sendMessage("Backer prohibited"); + } + + @ParameterizedTest + @ValueSource(strings = {"online", "missingTarget", "offline", "lowOrganization", "notLeader"}) + void sendingDemandsRequiresLeadershipOrganizationTargetsAndAnOnlineRuler(String scenario) { + view.movementView(player, faction, movement, null); + if (scenario.equals("missingTarget")) cause.getProposal().setTarget(null); + if (scenario.equals("offline")) when(ruler.isOnline()).thenReturn(false); + if (scenario.equals("lowOrganization")) when(movement.getOrganization()).thenReturn(99.0); + if (scenario.equals("notLeader")) leader.set("Cara"); + click(19); + if (scenario.equals("online")) { + assertEquals(SFGUI.MOVEMENT_DEMANDS, menu(ruler)); + verify(player).sendMessage(contains("Demands sent to Bob")); + } else { + verify(ruler, never()).openInventory(any(Inventory.class)); + if (scenario.equals("missingTarget")) verify(player).sendMessage(contains("lack a target")); + if (scenario.equals("offline")) verify(player).sendMessage(contains("must be online")); + } + } + + @ParameterizedTest + @ValueSource(strings = {"allowed", "blocked", "leaderless", "offline", "notRuler"}) + void disbandDemandsRecheckCrackdownRulesAndCurrentLeaders(String scenario) { + view.movementView(ruler, faction, movement, null); + if (scenario.equals("blocked")) crackdown.when(() -> MovementCrackdownQueries.canCrush(faction, movement)).thenReturn(false); + if (scenario.equals("leaderless")) leader.set(null); + if (scenario.equals("offline")) online.remove("Alice"); + if (scenario.equals("notRuler")) when(faction.getLeader()).thenReturn("Cara"); + click(ruler, 25); + if (scenario.equals("allowed")) { + assertEquals(SFGUI.MOVEMENT_CRACKDOWN, menu(player)); + verify(ruler).sendMessage(contains("Disband demand sent")); + } else { + verify(player, never()).openInventory(any(Inventory.class)); + if (!scenario.equals("notRuler")) verify(ruler).sendMessage(anyString()); + } + } + + @Test + void phaseCardsShowCurrentPreviousNextAndUnavailableStatesAndOnlyAllowedClickChangesPhase() { + phase.set(Phase.PRESSURING); + when(movement.getOrganization()).thenReturn(40.0); + when(movement.canChangeToPhase(Phase.AGITATED)).thenReturn(true); + view.movementView(player, faction, movement, null); + assertEquals(Material.YELLOW_CONCRETE, top().getItem(28).getType()); + assertEquals(Material.GREEN_CONCRETE, top().getItem(29).getType()); + assertEquals(Material.YELLOW_CONCRETE, top().getItem(30).getType()); + assertEquals(Material.RED_CONCRETE, top().getItem(31).getType()); + click(30); + assertEquals(Phase.AGITATED, phase.get()); + verify(player).sendMessage(contains("Phase changed")); + when(movement.getOrganization()).thenReturn(20.0); + assertTrue(text(creator.createPhaseItem(player, Phase.REBELLIOUS, movement)).contains("Organization 20.0/75")); + assertTrue(text(creator.createPhaseItem(ruler, Phase.REBELLIOUS, movement)).contains("Only the movement leader")); + view.movementView(player, faction, movement, null); + click(29); + verify(movement, times(1)).setPhase(any()); + } + + @Test + void endMovementUsesConfirmationAndRechecksLeadershipAtConfirmTime() { + view.movementView(player, faction, movement, null); + click(34); + assertSame(faction, manager.confirming.get(player)); + verify(manager).confirmEndMovementView(player, movement); + assertTrue(text(creator.createEndMovementConfirmItem(movement)).contains("remove all supporters and causes")); + view.handleEndConfirm(player, "movement-1", false); + assertEquals(SFGUI.MOVEMENT_VIEW, menu(player)); + verify(government, never()).endMovement(any()); + leader.set("Cara"); + view.handleEndConfirm(player, "movement-1", true); + verify(government, never()).endMovement(any()); + leader.set("Alice"); + view.handleEndConfirm(player, "movement-1", true); + verify(government).endMovement(movement); + assertEquals(SFGUI.MOVEMENT_LIST, menu(player)); + verify(player).sendMessage(contains("has been disbanded")); + } + + @Test + void removedMovementConfirmationReturnsToFactionListOrClosesWithoutMutation() { + manager.confirming.put(player, faction); + view.handleEndConfirm(player, "removed", true); + assertEquals(SFGUI.MOVEMENT_LIST, menu(player)); + assertFalse(manager.confirming.containsKey(player)); + view.handleEndConfirm(player, "removed", true); + verify(player).closeInventory(); + verify(government, never()).endMovement(any()); + } + + @ParameterizedTest + @ValueSource(strings = {"supporter", "notSupporter", "frozen", "full"}) + void newCauseSlotsAppearOnlyForEligibleSupportersWithRoom(String scenario) { + if (!scenario.equals("notSupporter")) supporters.addCitizen("Alice"); + if (scenario.equals("frozen")) when(movement.isFrozen()).thenReturn(true); + if (scenario.equals("full")) { cause(Action.NATIONHOOD); cause(Action.INDEPENDENCE); } + view.causesView(player, faction, movement, null); + if (scenario.equals("supporter")) { + assertEquals(Material.YELLOW_CONCRETE, top().getItem(11).getType()); + click(11); + verify(manager).proposalView(player, faction, null); + } else if (!scenario.equals("full")) { + assertEquals(Material.GRAY_CONCRETE, top().getItem(11).getType()); + click(11); + verify(manager, never()).proposalView(any(), any(), any()); + } else assertEquals(Material.WRITABLE_BOOK, top().getItem(12).getType()); + } + + @Test + void causeLeadershipJoinAndLeaveControlsShowAndApplyTheCurrentMembership() { + cause.setLeader(null); + assertEquals(Material.GREEN_CONCRETE, creator.createCauseLeaderItem(player, cause).getType()); + view.causeView(player, faction, movement, cause, null); + click(10); + assertEquals("Alice", cause.getLeader()); + click(14); + verify(movement).leave("Alice", cause); + assertTrue(cause.getFullMemberList().isEmpty()); + assertNull(cause.getLeader()); + assertNull(leader.get()); + assertEquals(Material.RED_CONCRETE, top().getItem(10).getType()); + assertTrue(text(creator.createCauseItem(cause, player, faction)).contains("No Leader")); + assertTrue(text(creator.createDemandItem(cause, movement)).contains("Leaderless")); + } + + @ParameterizedTest + @ValueSource(strings = {"identity", "quick", "blocked"}) + void causeJoinStopsAtEachEligibilityBoundary(String scenario) { + cause.getPool().getCitizens().clear(); + if (scenario.equals("identity")) when(movement.getJoiningAs(player)).thenReturn(null); + if (scenario.equals("quick")) when(movement.quickJoinCheck(player)).thenReturn(false); + if (scenario.equals("blocked")) when(movement.joinBlockReason("Alice", cause, false)).thenReturn("Cause membership denied"); + view.causeView(player, faction, movement, cause, null); + click(14); + lookup.verify(() -> FactionManager.requestMovementJoin(any(), any(), anyString(), nullable(Cause.class)), never()); + if (scenario.equals("blocked")) verify(player).sendMessage("Cause membership denied"); + } + + @Test + void leaderTargetPickerFiltersCandidatesAndRequestsTheSelectedEligibleMember() { + when(faction.canBecomeLeader("Bob")).thenReturn(false); + view.causeView(player, faction, movement, cause, null); + click(28); + assertEquals(SFGUI.TARGET_SELECT, menu(player)); + assertEquals("Alice", id(top().getItem(10))); + assertEquals("Cara", id(top().getItem(11))); + assertEquals("Dan", id(top().getItem(12))); + click(11); + lookup.verify(() -> FactionManager.requestMovementLeaderTarget(player, movement, cause, "Cara")); + assertEquals(SFGUI.CAUSE_VIEW, menu(player)); + assertEquals(List.of("Alice", "Bob", "Cara"), faction.getMembers()); + } + + @ParameterizedTest + @ValueSource(strings = {"ineligible", "lostLeadership", "removedCause"}) + void targetPickerRevalidatesTheCauseLeaderAndTarget(String scenario) { + view.targetSelectionView(player, faction, movement, cause, null); + if (scenario.equals("ineligible")) when(faction.canBecomeLeader("Alice")).thenReturn(false); + if (scenario.equals("lostLeadership")) cause.setLeader("Bob"); + if (scenario.equals("removedCause")) causes.clear(); + click(10); + lookup.verify(() -> FactionManager.requestMovementLeaderTarget(any(), any(), any(), anyString()), never()); + if (scenario.equals("ineligible")) verify(player).sendMessage(contains("cannot become")); + } + + @Test + void warEndCausesUseTheWarPickerAndExplainMissingTargets() { + Cause peace = cause(Action.WHITE_PEACE); + view.causeView(player, faction, movement, peace, null); + assertTrue(text(top().getItem(28)).contains("Choose War")); + assertTrue(text(top().getItem(28)).contains("No Target Selected")); + click(28); + verify(manager.governmentView).warPeaceSelectView(player, faction, Action.WHITE_PEACE, true, 1, null); + assertTrue(text(creator.createTargetButton(ruler, peace)).contains("Only the cause leader")); + Cause elections = cause(Action.SNAP_ELECTIONS); + view.causeView(player, faction, movement, elections, null); + assertNull(top().getItem(28)); + } + + @ParameterizedTest + @CsvSource({"demands,accept", "demands,reject", "demands,error", "demands,unauthorized", "crackdown,accept", "crackdown,reject", "crackdown,error", "crackdown,unauthorized"}) + void demandAndCrackdownDecisionsApplyTheRightOutcomeAndNotifyTheOtherLeader(String kind, String decision) { + boolean demands = kind.equals("demands"); + Player decider = demands ? ruler : player; + Player notified = demands ? player : ruler; + if (demands) view.demandsView(decider, faction, movement, null); + else view.crackdownView(decider, faction, movement, null); + Inventory original = top(decider); + if (decision.equals("error")) civilWars.when(() -> CivilWarStartService.start(movement)).thenReturn("War cannot begin now"); + if (decision.equals("unauthorized")) { + if (demands) when(faction.getLeader()).thenReturn("Cara"); + else leader.set("Cara"); + } + click(decider, decision.equals("accept") ? 29 : 33); + if (decision.equals("unauthorized")) { + outcomes.verifyNoInteractions(); + civilWars.verifyNoInteractions(); + verify(government, never()).endMovement(any()); + assertSame(original, top(decider)); + } else if (decision.equals("error")) { + verify(decider).sendMessage("War cannot begin now"); + assertSame(original, top(decider)); + verify(notified, never()).sendMessage(anyString()); + } else { + verify(decider).closeInventory(); + verify(notified, atLeastOnce()).sendMessage(anyString()); + if (decision.equals("accept")) { + if (demands) outcomes.verify(() -> MovementOutcomeService.apply(movement, MovementOutcomeSource.ACCEPTED)); + else verify(government).endMovement(movement); + civilWars.verifyNoInteractions(); + } else { + civilWars.verify(() -> CivilWarStartService.start(movement)); + verify(decider).sendMessage(contains("civil war has begun")); + } + } + } + + @Test + void listAndTargetMenusLimitContentsToFourteenCards() { + List<String> names = new ArrayList<>(); + for (int i = 0; i < 20; i++) { names.add("Person" + i); movements.add(movement); } + when(faction.getMembers()).thenAnswer(call -> new ArrayList<>(names)); + view.targetSelectionView(player, faction, movement, cause, null); + assertEquals("Person13", id(top().getItem(25))); + assertNull(top().getItem(26)); + view.movementListView(player, faction, null); + assertNotNull(top().getItem(25)); + assertNull(top().getItem(26)); + assertTrue(text(creator.createSendDemandsItem(movement)).contains("Click to Send")); + when(movement.getOrganization()).thenReturn(50.0); + when(movement.getOrganizationGain()).thenReturn(-5.0); + assertTrue(text(creator.createSendDemandsItem(movement)).contains("Unavailable")); + assertTrue(text(creator.createOrganizationItem(movement)).contains("-5.0")); + crackdown.when(() -> MovementCrackdownQueries.canCrush(faction, movement)).thenReturn(false); + assertTrue(text(creator.createDemandDisbandItem(faction, movement)).contains("Movement is too strong")); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void unavailablePoliticalActionIconsUseAUsableFallback(boolean missing) { + ItemStack unavailable = missing ? null : new ItemStack(Material.AIR); + if (unavailable != null) when(unavailable.getItemMeta()).thenReturn(null); + when(itemCreator.getItemFromPath("v.paper")).thenReturn(unavailable); + ItemStack item = assertDoesNotThrow(() -> creator.createCauseProposalItem(cause, player, faction)); + assertEquals(Material.PAPER, item.getType()); + assertTrue(text(item).contains("Proposal effects preview")); + assertEquals("movement-1", id(item)); + } + + @Test + void providerItemsWithoutMetadataUseAnIndependentUsableProposalCard() { + // The item provider is external. A nonempty custom stack may still fail to supply metadata. + ItemStack providerItem = mock(ItemStack.class); + ItemStack providerCopy = mock(ItemStack.class); + when(providerItem.getType()).thenReturn(Material.PAPER); + when(providerCopy.getType()).thenReturn(Material.PAPER); + when(providerItem.clone()).thenReturn(providerCopy); + when(itemCreator.getItemFromPath("v.paper")).thenReturn(providerItem); + ItemStack result = creator.createCauseProposalItem(cause, player, faction); + assertNotSame(providerItem, result); + assertNotSame(providerCopy, result); + assertEquals(Material.PAPER, result.getType()); + assertTrue(text(result).contains("Proposal effects preview")); + assertEquals("movement-1", id(result)); + verify(providerItem, never()).setItemMeta(any()); + verify(providerCopy, never()).setItemMeta(any()); + } + + @Test + void leaderlessIneligiblePlayersSeeAnExplanationAndCannotClaimLeadership() { + leader.set(null); + when(movement.canBeLeader("Alice")).thenReturn(false); + view.movementView(player, faction, movement, null); + assertEquals(Material.RED_CONCRETE, top().getItem(10).getType()); + assertTrue(text(top().getItem(10)).contains("lead a cause")); + click(10); + assertNull(leader.get()); + } + + @Test + void stalePhaseAndDisbandControlsRecheckLeadershipAndUnknownPhaseMetadataIsHarmless() { + phase.set(Phase.PRESSURING); + when(movement.getOrganization()).thenReturn(40.0); + view.movementView(player, faction, movement, null); + leader.set("Cara"); + click(34); + assertFalse(manager.confirming.containsKey(player)); + verify(manager, never()).confirmEndMovementView(any(), any()); + leader.set(null); + assertDoesNotThrow(() -> click(30)); + verify(movement, never()).setPhase(any()); + leader.set("Alice"); + ItemStack phaseItem = top().getItem(30); + ItemMeta meta = phaseItem.getItemMeta(); + meta.getPersistentDataContainer().set(Keys.SECONDARY_STRING_KEY, PersistentDataType.STRING, "REMOVED_PHASE"); + phaseItem.setItemMeta(meta); + assertDoesNotThrow(() -> click(30)); + assertEquals(Phase.PRESSURING, phase.get()); + } + + @Test + void obsoleteMovementListCardsAndUnrelatedCauseItemsDoNotDispatchActions() { + view.movementListView(player, faction, null); + when(government.getMovementById("movement-1")).thenReturn(null); + Inventory original = top(); + click(10); + assertSame(original, top()); + view.causeView(player, faction, movement, cause, null); + click(53); + assertEquals(SFGUI.CAUSE_VIEW, menu(player)); + lookup.verify(() -> FactionManager.requestMovementJoin(any(), any(), anyString(), nullable(Cause.class)), never()); + } + + @Test + void emptyAndUnusableClicksHaveNoDomainEffects() { + view.movementView(player, faction, movement, null); + Inventory inventory = top(); + InventoryClickEvent empty = ui.click(player, 0); + view.click(empty, inventory, player); + assertFalse(empty.isCancelled()); + ItemStack air = new ItemStack(Material.AIR); + when(air.getItemMeta()).thenReturn(null); + inventory.setItem(0, air); + InventoryClickEvent invalid = ui.click(player, 0); + view.click(invalid, inventory, player); + assertTrue(invalid.isCancelled()); + Inventory unowned = ui.inventory(null, 9, "Unowned inventory"); + unowned.setItem(0, new ItemStack(Material.PAPER)); + player.openInventory(unowned); + InventoryClickEvent unownedClick = ui.click(player, 0); + view.click(unownedClick, unowned, player); + assertFalse(unownedClick.isCancelled()); + verify(movement, never()).setPhase(any()); + verify(movement, never()).leave(any(), nullable(Cause.class)); + } + + @Test + void periodicTargetRefreshKeepsTheDisplayedCauseAfterEarlierCauseRemoval() { + Cause displayed = cause(Action.CHANGE_LEADER); + Cause later = cause(Action.CHANGE_LEADER); + InventoryManager router = new InventoryManager(); + router.movementView = view; + when(Bukkit.getOnlinePlayers()).thenAnswer(call -> List.of(player)); + view.targetSelectionView(player, faction, movement, displayed, null); + causes.remove(cause); + new InventoryUpdater(router).updateInventory(); + click(10); + lookup.verify(() -> FactionManager.requestMovementLeaderTarget(player, movement, displayed, "Alice")); + lookup.verify(() -> FactionManager.requestMovementLeaderTarget(player, movement, later, "Alice"), never()); + } + + @Test + void periodicRefreshOfARemovedTargetCauseOpensTheCauseListWithItsOwnHolder() { + cause(Action.CHANGE_LEADER); + InventoryManager router = new InventoryManager(); + router.movementView = view; + when(Bukkit.getOnlinePlayers()).thenAnswer(call -> List.of(player)); + view.targetSelectionView(player, faction, movement, cause, null); + causes.remove(cause); + new InventoryUpdater(router).updateInventory(); + assertEquals(SFGUI.CAUSES_VIEW, menu(player)); + assertEquals("movement-1", ((SFInventoryHolder) top().getHolder()).getId()); + lookup.verify(() -> FactionManager.requestMovementLeaderTarget(any(), any(), any(), anyString()), never()); + } + + @Test + void targetPickerBackButtonReturnsToTheSameSurvivingCauseWithACauseHolder() { + Cause displayed = cause(Action.CHANGE_LEADER); + Cause later = cause(Action.NATIONHOOD); + displayed.getProposal().setTarget("Cara"); + InventoryManager router = new InventoryManager(); + router.movementView = view; + view.targetSelectionView(player, faction, movement, displayed, null); + causes.remove(cause); + InventoryClickEvent back = ui.click(player, 53); + router.clickButton(back); + assertTrue(back.isCancelled()); + assertEquals(SFGUI.CAUSE_VIEW, menu(player)); + assertTrue(text(top().getItem(12)).contains("Change Leader")); + click(14); + verify(movement).leave("Alice", displayed); + verify(movement, never()).leave("Alice", later); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/PlayerLedgerBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/PlayerLedgerBoundaryCoverageTest.java new file mode 100644 index 00000000..3f3642f9 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/PlayerLedgerBoundaryCoverageTest.java @@ -0,0 +1,105 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.managers.*; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.player.PlayerEconomyManager; +import net.tfminecraft.simplefactions.player.income.*; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import org.bukkit.Material; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.*; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +@SuppressWarnings("deprecation") +class PlayerLedgerBoundaryCoverageTest { + private GuiTestFixture gui; + private MockedStatic<SimpleFactions> pluginApi; + private PlayerEconomyManager economy; + private Player player; + + @BeforeEach + void setup() { + gui = new GuiTestFixture(); + player = gui.player("Bookkeeper"); + when(org.bukkit.Bukkit.getOfflinePlayer(player.getUniqueId())).thenReturn(player); + economy = new PlayerEconomyManager(); + pluginApi = mockStatic(SimpleFactions.class); + pluginApi.when(SimpleFactions::getPlayerEconomyManager).thenReturn(economy); + } + + @AfterEach + void close() { + pluginApi.close(); + gui.close(); + } + + @Test + void ledgerCommandOpensRealLedgerAndRepaintingDoesNotOpenAnotherWindow() { + PlayerLedger ledger = economy.getLedger(player.getUniqueId()); + ledger.add(PlayerCashflow.WAGES, 17); + ledger.add(PlayerCashflow.CITIZEN_TAX, -2); + InventoryManager manager = new InventoryManager(); + Command command = mock(Command.class); + when(command.getName()).thenReturn("LeDgEr"); + assertTrue( + new LedgerCommandManager(manager).onCommand(player, command, "ledger", new String[0])); + ArgumentCaptor<Inventory> opened = ArgumentCaptor.forClass(Inventory.class); + verify(player).openInventory(opened.capture()); + Inventory inventory = opened.getValue(); + assertEquals(27, inventory.getSize()); + assertInstanceOf(SFInventoryHolder.class, inventory.getHolder()); + ItemStack book = inventory.getItem(13); + assertEquals(Material.WRITABLE_BOOK, book.getType()); + assertTrue(book.getItemMeta().getLore().stream().anyMatch(x -> x.contains("+17.00"))); + for (int slot = 0; slot < 27; slot++) + if (slot != 13) + assertEquals(Material.GRAY_STAINED_GLASS_PANE, inventory.getItem(slot).getType()); + ledger.clearDaily(); + manager.playerLedgerView.open(player, inventory); + verify(player, times(1)).openInventory(inventory); + assertTrue( + inventory.getItem(13).getItemMeta().getLore().stream() + .anyMatch(x -> x.contains("No income sources."))); + } + + @Test + void unrelatedCommandsAndConsoleNeverCreateAPlayerInventory() { + LedgerCommandManager command = new LedgerCommandManager(new InventoryManager()); + Command other = mock(Command.class); + when(other.getName()).thenReturn("other"); + assertFalse(command.onCommand(player, other, "other", new String[0])); + Command ledger = mock(Command.class); + when(ledger.getName()).thenReturn("ledger"); + CommandSender console = mock(CommandSender.class); + assertTrue(command.onCommand(console, ledger, "ledger", new String[0])); + verify(console).sendMessage("§cThis command can only be used by players."); + verify(player, never()).openInventory(any(Inventory.class)); + } + + @Test + void explicitLedgerBookMatchesAmountsAndCashflowClassification() { + PlayerLedger ledger = new PlayerLedger(); + ledger.add(null, 100); + ledger.add(PlayerCashflow.WAGES, 0); + assertEquals(0, ledger.getAmount(null)); + ledger.add(PlayerCashflow.WAGES, 5); + ledger.add(PlayerCashflow.WAGES, 2); + ledger.add(PlayerCashflow.CITIZEN_TAX, -9); + ItemStack book = new PlayerLedgerCreator().createLedgerBook(ledger, player.getUniqueId()); + assertEquals(Material.WRITABLE_BOOK, book.getType()); + assertEquals(-2, ledger.getNetDaily()); + assertEquals(java.util.List.of(PlayerCashflow.CITIZEN_TAX), ledger.getExpenseFlows()); + assertTrue(book.getItemMeta().getLore().stream().anyMatch(x -> x.contains("-2.00"))); + assertNotNull(PlayerCashflow.WAGES.getDisplay()); + assertTrue(PlayerCashflow.WAGES.isIncome()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/PolicyMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/PolicyMenusCoverageTest.java new file mode 100644 index 00000000..c0c73605 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/PolicyMenusCoverageTest.java @@ -0,0 +1,328 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.simplefactions.enums.*; +import net.tfminecraft.simplefactions.government.proposal.*; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.keys.Keys; +import net.tfminecraft.simplefactions.laws.*; +import net.tfminecraft.simplefactions.loaders.LawLoader; +import net.tfminecraft.simplefactions.managers.*; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.bukkit.*; +import org.bukkit.entity.Player; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class PolicyMenusCoverageTest { + private org.mockito.MockedStatic<net.tfminecraft.tlibs.TLibs> items; + private FactionDomainFixture fixture; + private InventoryManager inventory; + private Player alice; + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + inventory = new InventoryManager(); + items = mockStatic(net.tfminecraft.tlibs.TLibs.class); + var api = mock(net.tfminecraft.tlibs.objects.api.ItemAPI.class); + var creator = mock(net.tfminecraft.tlibs.objects.api.subapi.ItemCreator.class); + when(api.getCreator()).thenReturn(creator); + items.when(net.tfminecraft.tlibs.TLibs::getItemAPI).thenReturn(api); + when(creator.getItemFromPath(anyString())) + .thenAnswer(call -> new ItemStack(org.bukkit.Material.PAPER)); + when(creator.getItemsAdderItem(anyString())) + .thenAnswer(call -> new ItemStack(org.bukkit.Material.PAPER)); + when(fixture.ui.plugin.getProvinceManager()) + .thenReturn(new net.tfminecraft.simplefactions.managers.ProvinceManager()); + alice = fixture.player("Alice"); + } + + @AfterEach + void close() { + if (items != null) items.close(); + fixture.close(); + } + + private Inventory top() { + return alice.getOpenInventory().getTopInventory(); + } + + private String key(ItemStack item) { + return item == null || item.getItemMeta() == null + ? null + : item.getItemMeta() + .getPersistentDataContainer() + .get(Keys.STRING_KEY, PersistentDataType.STRING); + } + + private int named(Inventory menu, String text) { + for (int slot = 0; slot < menu.getSize(); slot++) { + ItemStack item = menu.getItem(slot); + if (item != null + && item.getItemMeta() != null + && item.getItemMeta().getDisplayName().contains(text)) return slot; + } + return -1; + } + + private net.tfminecraft.simplefactions.diplomacy.Relation relation(String type) { + var r = new net.tfminecraft.simplefactions.diplomacy.Relation(); + r.setType(net.tfminecraft.simplefactions.loaders.RelationLoader.getType(type)); + return r; + } + + private Set<String> collect(boolean laws) { + Set<String> ids = new LinkedHashSet<>(); + for (int page = 0; page < 8; page++) { + Inventory menu = top(); + for (ItemStack item : menu.getContents()) { + String id = key(item); + if (id != null && !id.contains("VIEW") && !id.contains("SELECT")) ids.add(id); + } + int next = named(menu, "Next"); + if (next < 0) break; + if (laws) inventory.lawView.click(fixture.ui.click(alice, next), menu, alice); + else inventory.taxView.click(fixture.ui.click(alice, next), menu, alice); + } + return ids; + } + + @Test + void allConfiguredLawGroupsRemainAccessibleBeyondFourteen() { + for (int n = 0; n < 17; n++) fixture.lawGroup("group" + n, Map.of()); + Faction home = fixture.saved("home", "Alice"); + assertDoesNotThrow(() -> inventory.lawView.lawView(alice, home, null)); + assertEquals(LawLoader.map.keySet(), collect(true)); + } + + @Test + void allPoliciesRemainAccessibleBeyondTheSelectionInventory() { + fixture.lawGroup("government", Map.of()); + for (int n = 0; n < 30; n++) fixture.law("government", "policy" + n, Map.of()); + Faction home = fixture.saved("home", "Alice"); + LawGroup group = home.getLawHandler().getGroup("government"); + assertDoesNotThrow(() -> inventory.lawView.lawSelect(alice, home, group, null)); + assertEquals(group.getLaws().keySet(), collect(true)); + assertEquals("current", group.getCurrent().getId(), "Browsing must never enact a policy"); + } + + @ParameterizedTest + @EnumSource( + value = TaxTarget.class, + names = {"GUILD_ID", "VASSAL_ID", "TARIFF_ID"}) + void everySpecificTaxTargetIsAccessibleIncludingTheOldBackButtonSlot(TaxTarget target) { + fixture.lawGroup( + "tax", + Map.of( + "effects.faction.rules", List.of("GUILD_TAX true", "VASSAL_TAX true", "TARIFFS true"))); + Faction home = fixture.saved("home", "Alice"); + Set<String> expected = new LinkedHashSet<>(); + for (int n = 0; n < 55; n++) { + if (target == TaxTarget.GUILD_ID) { + Guild guild = fixture.guild(home, "guild" + n, "Leader" + n); + expected.add(guild.getId()); + } else { + Faction next = fixture.saved("foreign" + n, "Leader" + n); + expected.add(next.getId()); + if (target == TaxTarget.VASSAL_ID) { + home.getDiplomacyHandler().setRelation(next, relation("vassal")); + next.getDiplomacyHandler().setRelation(home, relation("overlord")); + } + } + } + inventory.taxView.specificTaxView(alice, home, target, null); + assertEquals(expected, collect(false)); + } + + @Test + void lawGroupAndPolicyLoreDescribeCurrentEffectsAndProposalCostWithoutMutation() { + fixture.regiment("guards", false, 1, 2); + fixture.lawGroup( + "government", + Map.of( + "description", + List.of("Current charter"), + "effects.faction.rules", + List.of("CITIZEN_TAX true", "GUILD_TAX false"), + "effects.faction.council-size", + 2)); + Law alternative = + fixture.law( + "government", + "democracy", + Map.ofEntries( + Map.entry("description", List.of("Proposed charter")), + Map.entry("cost", 30), + Map.entry("upkeep", 2), + Map.entry("effects.faction.rules", List.of("CITIZEN_TAX false", "GUILD_TAX true")), + Map.entry("effects.faction.council-size", 3), + Map.entry("effects.faction.brackets.citizen_tax", "2-8"), + Map.entry("effects.faction.regiments", List.of("guards 2")), + Map.entry("effects.faction.modifiers", List.of("PRESTIGE(4)")), + Map.entry("effects.faction.our_territory", List.of("PRESTIGE(3)")), + Map.entry("effects.domestic_guilds.rules", List.of("CITIZEN_TAX true")))); + Faction home = fixture.saved("home", "Alice"); + LawGroup group = home.getLawHandler().getGroup("government"); + LawCreator creator = new LawCreator(); + ItemStack current = creator.createLawGroupItem(alice, home, group); + assertEquals(group.getId(), key(current)); + assertTrue(String.join(" ", current.getItemMeta().getLore()).contains("Current charter")); + ItemStack proposal = creator.createLawItem(alice, home, group, alternative, true); + String lore = String.join(" ", proposal.getItemMeta().getLore()); + assertTrue(lore.contains("30")); + assertTrue(lore.contains("Upkeep")); + assertTrue(lore.contains("Council Size")); + assertTrue(lore.contains("Proposed charter")); + assertTrue(lore.contains("State Power Expectation")); + assertEquals("democracy", key(proposal)); + assertEquals( + "government", + proposal + .getItemMeta() + .getPersistentDataContainer() + .get(Keys.SECONDARY_STRING_KEY, PersistentDataType.STRING)); + assertEquals("current", group.getCurrent().getId()); + assertTrue(!home.getGovernment().getCouncil().hasProposals()); + } + + @Test + void groupsOpenPolicyChoicesAndPreviousNavigationClampsAfterPoliciesAreRemoved() { + fixture.lawGroup("charter", Map.of()); + for (int n = 0; n < 30; n++) fixture.law("charter", "policy" + n, Map.of()); + Faction home = fixture.saved("home", "Alice"); + inventory.lawView.lawView(alice, home, null); + Inventory groups = top(); + inventory.lawView.click(fixture.ui.click(alice, 10), groups, alice); + Inventory choices = top(); + SFInventoryHolder holder = (SFInventoryHolder) choices.getHolder(); + assertEquals(SFGUI.LAW_SELECT, holder.getType()); + assertEquals("charter", holder.getSecondaryId()); + inventory.lawView.click(fixture.ui.click(alice, 25), choices, alice); + assertEquals(1, holder.getPage()); + inventory.lawView.click(fixture.ui.click(alice, 24), choices, alice); + assertEquals(0, holder.getPage()); + inventory.lawView.click(fixture.ui.click(alice, 25), choices, alice); + home.getLawHandler() + .getGroup("charter") + .getLaws() + .keySet() + .removeIf(id -> !id.equals("current")); + inventory.lawView.lawSelect(alice, home, home.getLawHandler().getGroup("charter"), choices); + assertEquals(0, holder.getPage()); + assertEquals("current", key(choices.getItem(0))); + assertNull(choices.getItem(25)); + inventory.lawView.lawView(alice, home, groups); + alice.openInventory(groups); + groups.setItem(10, new ItemStack(Material.PAPER)); + inventory.lawView.click(fixture.ui.click(alice, 10), groups, alice); + assertSame(groups, top()); + } + + @Test + void lawLoreExplainsRequirementsNoUpkeepAndPendingProposalWithoutEnactingIt() { + fixture.lawGroup("charter", Map.of()); + Law alternative = + fixture.law("charter", "reform", Map.of("requirements", List.of("has_law prerequisite"))); + Faction home = fixture.saved("home", "Alice"); + LawGroup group = home.getLawHandler().getGroup("charter"); + group.getDescription().add("Charter group description"); + LawCreator creator = new LawCreator(); + assertTrue( + String.join(" ", creator.createLawGroupItem(alice, home, group).getItemMeta().getLore()) + .contains("Charter group description")); + Proposal proposal = new Proposal("Alice", home.getGovernment()); + proposal.setLawProposal(alternative); + home.getGovernment().propose(proposal); + String lore = + String.join( + " ", + creator.createLawItem(alice, home, group, alternative, true).getItemMeta().getLore()); + assertTrue(lore.contains("Requires law: prerequisite")); + assertTrue(lore.contains("None")); + assertTrue(lore.contains("already a proposal")); + assertEquals("current", group.getCurrent().getId()); + assertEquals( + List.of(proposal), home.getGovernment().getCouncil().getProposalHandler().getProposals()); + } + + @Test + void topTaxMenuRoutesEachSpecificTypeAndIgnoresStaleItemKeys() { + fixture.lawGroup( + "tax", + Map.of( + "effects.faction.rules", + List.of( + "CITIZEN_TAX true", + "GUILD_TAX true", + "VASSAL_TAX true", + "DIVIDEND_TAX true", + "TARIFFS true"))); + Faction home = fixture.saved("home", "Alice"); + fixture.guild(home, "traders", "Trader"); + Faction foreign = fixture.saved("foreign", "Foreign"); + home.getDiplomacyHandler().setRelation(foreign, relation("vassal")); + foreign.getDiplomacyHandler().setRelation(home, relation("overlord")); + for (TaxTarget target : TaxTarget.values()) { + inventory.taxView.taxView(alice, home); + Inventory menu = top(); + int slot = -1; + for (int n = 0; n < 17; n++) if (target.name().equals(key(menu.getItem(n)))) slot = n; + assertTrue(slot >= 0, target.name()); + inventory.taxView.click(fixture.ui.click(alice, slot), menu, alice); + if (target.name().endsWith("_ID")) { + assertEquals(SFGUI.TAX_VIEW_SPECIFIC, ((SFInventoryHolder) top().getHolder()).getType()); + assertEquals(target.name(), ((SFInventoryHolder) top().getHolder()).getSecondaryId()); + } else assertSame(menu, top()); + } + inventory.taxView.taxView(alice, home); + Inventory menu = top(); + menu.setItem(0, new ItemStack(Material.PAPER)); + inventory.taxView.click(fixture.ui.click(alice, 0), menu, alice); + var item = menu.getItem(0); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer() + .set(Keys.STRING_KEY, PersistentDataType.STRING, "removed-tax-type"); + item.setItemMeta(meta); + inventory.taxView.click(fixture.ui.click(alice, 0), menu, alice); + assertSame(menu, top()); + assertEquals(5.0, home.getTaxHandler().getCitizenTax()); + FactionManager.factions.remove(home); + inventory.taxView.click(fixture.ui.click(alice, 0), menu, alice); + verify(alice).closeInventory(); + } + + @Test + void foreignLawAndTaxViewsRemainReportedDuringRefresh() { + fixture.lawGroup("charter", Map.of()); + Faction target = fixture.saved("foreign", "Foreign"); + Player foreign = fixture.player("Foreign"); + var spymaster = new net.tfminecraft.simplefactions.espionage.SpecialPositionAssignment(); + spymaster.playerName = foreign.getName(); + spymaster.playerId = foreign.getUniqueId(); + spymaster.characterId = "character-Foreign"; + target.getEspionage().appoint(spymaster, 80); + fixture.saved("home", "Alice"); + inventory.lawView.lawView(alice, target, null); + Inventory laws = top(); + assertTrue(((SFInventoryHolder) laws.getHolder()).isReported()); + inventory.lawView.lawView(alice, target, laws); + assertSame(laws, top()); + inventory.taxView.taxView(alice, target); + Inventory taxes = top(); + assertTrue(((SFInventoryHolder) taxes.getHolder()).isReported()); + inventory.taxView.taxView(alice, target, taxes); + assertSame(taxes, top()); + inventory.taxView.taxView(alice, null); + assertSame(taxes, top()); + assertFalse(target.getGovernment().getCouncil().hasProposals()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/RelationMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/RelationMenusCoverageTest.java new file mode 100644 index 00000000..4236a52a --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/RelationMenusCoverageTest.java @@ -0,0 +1,668 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.diplomacy.Attitude; +import net.tfminecraft.simplefactions.diplomacy.DiplomacyHandler; +import net.tfminecraft.simplefactions.diplomacy.Relation; +import net.tfminecraft.simplefactions.diplomacy.RelationType; +import net.tfminecraft.simplefactions.diplomacy.Threshold; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.loaders.RelationLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.managers.RelationManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import net.tfminecraft.simplefactions.tiers.Tier; +import net.tfminecraft.simplefactions.utils.EconomicImpact; +import net.tfminecraft.simplefactions.war.declare.DeclareCodePrompt; +import net.tfminecraft.simplefactions.war.declare.WarDeclareCodeService; +import net.tfminecraft.simplefactions.war.resolution.WarReparationsObligation; +import net.tfminecraft.simplefactions.war.resolution.WarReparationsService; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemCreator; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +/** Menu outputs and actions are exercised together; capacity/war services are scoped boundaries. */ +class RelationMenusCoverageTest { + private GuiTestFixture ui; + private Player player; + private Faction origin; + private Faction target; + private Relation outgoing; + private Relation incoming; + private RelationType neutral; + private Attitude indifferent; + private InventoryManager manager; + private RelationCreator creator; + private RelationView view; + private List<Faction> previousFactions; + private List<RelationType> previousTypes; + private List<Attitude> previousAttitudes; + private HashMap<String, String> previousIcons; + private boolean previousCodeRequired; + private int previousWarOpinion; + private final List<MockedStatic<?>> mocks = new ArrayList<>(); + private MockedStatic<FactionManager> lookup; + private MockedStatic<RelationManager> diplomacy; + private MockedStatic<WarManager> wars; + private MockedStatic<WarDeclareCodeService> codes; + private MockedStatic<DeclareCodePrompt> prompts; + private MockedStatic<WarReparationsService> reparations; + private MockedStatic<EconomicImpact> economics; + + private <T> MockedStatic<T> scoped(Class<T> type) { + MockedStatic<T> result = mockStatic(type); + mocks.add(result); + return result; + } + + @BeforeEach + void setup() { + ui = new GuiTestFixture(); + previousFactions = FactionManager.factions; + previousTypes = RelationLoader.types; + previousAttitudes = RelationLoader.attitudes; + previousIcons = Cache.icons; + previousCodeRequired = Cache.warRequireDeclareCode; + previousWarOpinion = Cache.warDeclareOpinionThreshold; + FactionManager.factions = new ArrayList<>(); + RelationLoader.types = new ArrayList<>(); + RelationLoader.attitudes = new ArrayList<>(); + Cache.icons = new HashMap<>(); + Cache.icons.put("war", "IRON_SWORD.1"); + Cache.warDeclareOpinionThreshold = 0; + Cache.warRequireDeclareCode = false; + player = ui.player("Alice"); + neutral = type("neutral", "default", true); + indifferent = attitude("indifferent", "target", 0); + origin = faction("origin", "Origin", "Alice"); + target = faction("target", "Target", "Bob"); + outgoing = new Relation(neutral, indifferent, -20); + incoming = new Relation(neutral, indifferent, 5); + when(origin.getRelation("target")).thenReturn(outgoing); + when(target.getRelation("origin")).thenReturn(incoming); + lookup = scoped(FactionManager.class); + lookup.when(() -> FactionManager.getByString(anyString())).thenAnswer(call -> + FactionManager.factions.stream().filter(f -> f.getId().equals(call.getArgument(0))).findFirst().orElse(null)); + lookup.when(() -> FactionManager.getByMember(anyString())).thenAnswer(call -> + FactionManager.factions.stream().filter(f -> f.getMembers().contains(call.getArgument(0))).findFirst().orElse(null)); + lookup.when(() -> FactionManager.getByLeader(anyString())).thenAnswer(call -> + FactionManager.factions.stream().filter(f -> f.getLeader().equals(call.getArgument(0))).findFirst().orElse(null)); + diplomacy = scoped(RelationManager.class); + wars = scoped(WarManager.class); + codes = scoped(WarDeclareCodeService.class); + prompts = scoped(DeclareCodePrompt.class); + reparations = scoped(WarReparationsService.class); + economics = scoped(EconomicImpact.class); + ItemAPI api = mock(ItemAPI.class); + ItemCreator items = mock(ItemCreator.class); + when(api.getCreator()).thenReturn(items); + when(items.getItemsAdderItem(anyString())).thenAnswer(call -> new ItemStack(Material.PAPER)); + scoped(TLibs.class).when(TLibs::getItemAPI).thenReturn(api); + manager = mock(InventoryManager.class); + when(manager.createBackButton(any())).thenAnswer(call -> new ItemStack(Material.BARRIER)); + creator = new RelationCreator(); + view = new RelationView(manager); + } + + @AfterEach + void cleanup() { + for (int i = mocks.size() - 1; i >= 0; i--) mocks.get(i).close(); + FactionManager.factions = previousFactions; + RelationLoader.types = previousTypes; + RelationLoader.attitudes = previousAttitudes; + Cache.icons = previousIcons; + Cache.warRequireDeclareCode = previousCodeRequired; + Cache.warDeclareOpinionThreshold = previousWarOpinion; + ui.close(); + } + + private static YamlConfiguration config(Object... values) { + YamlConfiguration config = new YamlConfiguration(); + for (int i = 0; i < values.length; i += 2) config.set((String) values[i], values[i + 1]); + return config; + } + + private RelationType type(String id, Object... values) { + YamlConfiguration config = config(values); + config.set("name", id); + RelationType result = new RelationType(id, config); + RelationLoader.types.add(result); + return result; + } + + private Attitude attitude(String id, Object... values) { + YamlConfiguration config = config(values); + config.set("name", id); + Attitude result = new Attitude(id, config); + RelationLoader.attitudes.add(result); + return result; + } + + private Faction faction(String id, String name, String leader) { + Faction faction = mock(Faction.class); + when(faction.getId()).thenReturn(id); + when(faction.getName()).thenReturn(name); + when(faction.getLeader()).thenReturn(leader); + when(faction.getMembers()).thenReturn(new ArrayList<>(List.of(leader))); + when(faction.numOnline()).thenReturn(1); + when(faction.getBanner()).thenAnswer(call -> new ItemStack(Material.WHITE_BANNER)); + DiplomacyHandler handler = mock(DiplomacyHandler.class); + when(faction.getDiplomacyHandler()).thenReturn(handler); + when(handler.getAvailableCapacity()).thenReturn(10.0); + when(faction.getRelation(anyString())).thenAnswer(call -> new Relation(neutral, indifferent)); + Tier tier = mock(Tier.class); + when(tier.getTier()).thenReturn(2); + when(faction.getTier()).thenReturn(tier); + when(faction.canHaveVassals()).thenReturn(true); + FactionManager.factions.add(faction); + return faction; + } + + private static String text(ItemStack item) { + ItemMeta meta = item.getItemMeta(); + return ChatColor.stripColor(meta.getDisplayName() + "\n" + String.join("\n", meta.getLore() == null ? List.of() : meta.getLore())); + } + + private Inventory top() { return player.getOpenInventory().getTopInventory(); } + private SFGUI menu() { return ((SFInventoryHolder) top().getHolder()).getType(); } + private void click(int slot) { + Inventory inventory = top(); + InventoryClickEvent event = ui.click(player, slot); + view.click(event, inventory, player); + assertTrue(event.isCancelled()); + } + + @Test + void renderingDiplomacyDoesNotRewriteTheStoredFactionBanner() { + ItemStack banner = new ItemStack(Material.BLUE_BANNER); + ItemMeta meta = banner.getItemMeta(); + meta.setDisplayName("Original standard"); + meta.setLore(List.of("Guild heraldry")); + banner.setItemMeta(meta); + when(target.getBanner()).thenReturn(banner); + view.diplomacyView(null, player, target, true); + assertNotSame(banner, top().getItem(21)); + assertEquals("Original standard", banner.getItemMeta().getDisplayName()); + assertEquals(List.of("Guild heraldry"), banner.getItemMeta().getLore()); + assertTrue(text(top().getItem(21)).contains("Target")); + } + + @Test + void aRemovedAttitudeOptionIsIgnoredAfterReload() { + attitude("friendly", "target", 10); + view.attitudeView(null, player, target, true); + RelationLoader.attitudes.clear(); + assertDoesNotThrow(() -> click(11)); + diplomacy.verify(() -> RelationManager.setAttitude(any(), any(), any(), any()), never()); + } + + @Test + void reorderingAttitudesDoesNotChangeTheClickedOption() { + Attitude friendly = attitude("friendly", "target", 10); + view.attitudeView(null, player, target, true); + Collections.reverse(RelationLoader.attitudes); + click(11); + diplomacy.verify(() -> RelationManager.setAttitude(player, friendly, target, origin)); + diplomacy.verify(() -> RelationManager.setAttitude(player, indifferent, target, origin), never()); + } + + @ParameterizedTest + @ValueSource(strings = {"relation", "trade", "treaty"}) + void aRemovedDefinitionIsIgnoredAfterReload(String kind) { + RelationType choice = type("choice", "trade-agreement", kind.equals("trade"), "treaty", kind.equals("treaty")); + openPicker(kind); + int slot = kind.equals("relation") ? 10 : 9; + RelationLoader.types.remove(choice); + assertDoesNotThrow(() -> click(slot)); + assertNoDiplomaticMutation(); + } + + @ParameterizedTest + @ValueSource(strings = {"relation", "trade", "treaty"}) + void losingFactionMembershipWhileMenuIsOpenDoesNotMutateDiplomacy(String kind) { + type("choice", "trade-agreement", kind.equals("trade"), "treaty", kind.equals("treaty")); + openPicker(kind); + when(origin.getMembers()).thenReturn(List.of()); + assertDoesNotThrow(() -> click(kind.equals("relation") ? 10 : 9)); + assertNoDiplomaticMutation(); + } + + private void assertNoDiplomaticMutation() { + diplomacy.verify(() -> RelationManager.setRelation(any(), any(), any(), any(), anyBoolean()), never()); + diplomacy.verify(() -> RelationManager.setTradeRelation(any(), any(), any(), any(), anyBoolean()), never()); + diplomacy.verify(() -> RelationManager.setTreatyRelation(any(), any(), any(), any(), anyBoolean()), never()); + } + + private void openPicker(String kind) { + switch (kind) { + case "relation" -> view.relationView(null, player, target, true); + case "trade" -> view.tradeAgreementView(null, player, target, true); + case "treaty" -> view.treatyView(null, player, target, true); + default -> throw new AssertionError(kind); + } + } + + @Test + void directoryShowsOfficialPartnersSeparatorsOtherFactionsAndPaging() { + RelationType allied = type("ally"); + outgoing.setType(allied); + for (int i = 0; i < 35; i++) faction("f" + i, String.format("F%02d", i), "member" + i); + view.diplomacyListView(null, player, origin, true, -1); + assertEquals("target", ui.data(top().getItem(0), "id")); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, top().getItem(1).getType()); + assertEquals("f0", ui.data(top().getItem(19), "id")); + assertNotNull(top().getItem(52)); + click(52); + assertEquals(1, ((SFInventoryHolder) top().getHolder()).getPage()); + assertNotNull(top().getItem(45)); + Inventory secondPage = top(); + view.diplomacyListView(secondPage, player, origin, false); + assertSame(secondPage, top()); + assertNotNull(top().getItem(0)); + click(45); + assertEquals(0, ((SFInventoryHolder) top().getHolder()).getPage()); + click(0); + assertEquals(SFGUI.DIPLOMACY_VIEW, menu()); + assertEquals("target", ((SFInventoryHolder) top().getHolder()).getId()); + } + + @Test + void foreignDirectoryIsReadOnlyAndHandlesNoOfficialRelations() { + view.diplomacyListView(null, player, target, true); + assertTrue(text(top().getItem(0)).contains("No official relations")); + incoming.setType(type("ally")); + view.diplomacyListView(top(), player, target, false); + assertFalse(text(top().getItem(0)).contains("Click to open")); + Inventory inventory = top(); + click(0); + assertSame(inventory, top()); + } + + @Test + void directoryIgnoresSeparatorMissingIdsRemovedFactionsAndObsoleteNextPage() { + for (int i = 0; i < 30; i++) faction("f" + i, "Faction" + i, "Person" + i); + view.diplomacyListView(null, player, origin, true); + Inventory original = top(); + click(0); + assertSame(original, top()); + top().setItem(40, new ItemStack(Material.PAPER)); + click(40); + FactionManager.factions.removeIf(f -> f != origin); + click(18); + click(52); + assertSame(original, top()); + FactionManager.factions.clear(); + click(53); + assertSame(original, top()); + } + + @Test + void summaryUsesBannerFallbacksAndShowsBothDirectionalRelationsAndOverlays() { + RelationType trade = type("trade", "trade-agreement", true); + RelationType treaty = type("treaty", "treaty", true); + incoming.setType(type("rival", "visible", false)); + when(target.getDiplomacyHandler().hasTradeRelation("origin")).thenReturn(true); + when(target.getDiplomacyHandler().getTradeRelation("origin")).thenReturn(trade); + when(target.getDiplomacyHandler().hasTreatyRelation("origin")).thenReturn(true); + when(target.getDiplomacyHandler().getTreatyRelation("origin")).thenReturn(treaty); + String summary = text(creator.createRelationItem(target, origin)); + assertTrue(summary.contains("outgoing")); + assertTrue(summary.contains("incoming")); + assertTrue(summary.contains("Trade: trade")); + assertTrue(summary.contains("Treaty: treaty")); + when(origin.getDiplomacyHandler().hasTradeRelation("target")).thenReturn(true); + when(origin.getDiplomacyHandler().getTradeRelation("target")).thenReturn(trade); + when(origin.getDiplomacyHandler().hasTreatyRelation("target")).thenReturn(true); + when(origin.getDiplomacyHandler().getTreatyRelation("target")).thenReturn(treaty); + for (ItemStack banner : new ItemStack[] {null, new ItemStack(Material.AIR)}) { + when(target.getBanner()).thenReturn(banner); + ItemStack card = creator.createDiplomacyListFactionItem(origin, target, true); + assertEquals(Material.WHITE_BANNER, card.getType()); + assertEquals("target", ui.data(card, "id")); + assertTrue(text(card).contains("Trade: trade")); + assertTrue(text(card).contains("Treaty: treaty")); + } + } + + @Test + void detailAndSelectionMenusRenderOnlyRelevantDefinitionsAndSupportRedraw() { + type("hidden", "settable", false); + RelationType trade = type("trade", "trade-agreement", true); + RelationType treaty = type("treaty", "treaty", true); + when(origin.getDiplomacyHandler().getTradeRelation("target")).thenReturn(trade); + when(origin.getDiplomacyHandler().getTreatyRelation("target")).thenReturn(treaty); + view.diplomacyView(null, player, target, true); + Inventory detail = top(); + assertTrue(text(detail.getItem(20)).contains("trade")); + assertTrue(text(detail.getItem(22)).contains("treaty")); + view.diplomacyView(detail, player, target, false); + assertSame(detail, top()); + for (int slot : new int[] {30, 12, 20, 22}) { + player.openInventory(detail); + click(slot); + Inventory picker = top(); + switch (slot) { + case 30 -> { assertEquals(SFGUI.ATTITUDE_VIEW, menu()); view.attitudeView(picker, player, target, false); } + case 12 -> { assertEquals(SFGUI.RELATION_VIEW, menu()); assertEquals("neutral", ui.data(picker.getItem(9), "id")); assertNull(picker.getItem(10)); view.relationView(picker, player, target, false); } + case 20 -> { assertEquals(SFGUI.TRADE_AGREEMENT_VIEW, menu()); assertEquals("trade", ui.data(picker.getItem(9), "id")); view.tradeAgreementView(picker, player, target, false); } + case 22 -> { assertEquals(SFGUI.TREATY_VIEW, menu()); assertEquals("treaty", ui.data(picker.getItem(9), "id")); view.treatyView(picker, player, target, false); } + } + assertSame(picker, top()); + assertEquals(Material.BARRIER, picker.getItem(26).getType()); + } + } + + @Test + void ownFactionAndFactionlessVisitorsSeeOnlyBackButton() { + view.diplomacyView(null, player, origin, true); + assertNull(top().getItem(21)); + assertNotNull(top().getItem(53)); + when(origin.getMembers()).thenReturn(List.of()); + view.diplomacyView(null, player, target, true); + assertNull(top().getItem(21)); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void attitudeSelectionUsesServiceResultAndOnlySuccessfulChangesReturnToDetails(boolean allowed) { + Attitude friendly = attitude("friendly", "target", 10); + diplomacy.when(() -> RelationManager.setAttitude(player, friendly, target, origin)).thenReturn(allowed); + view.attitudeView(null, player, target, true); + click(11); + assertEquals(allowed ? SFGUI.DIPLOMACY_VIEW : SFGUI.ATTITUDE_VIEW, menu()); + diplomacy.verify(() -> RelationManager.setAttitude(player, friendly, target, origin)); + } + + @ParameterizedTest + @CsvSource({"leader,leader", "already,already in a war", "opinion,opinion", "offline,online", "code,code", "goal,goal"}) + void warDeclarationRechecksLiveConditionsBeforePromptOrGoalSelection(String scenario, String expected) { + type("war", "threshold.amount", -10); + view.diplomacyView(null, player, target, true); + if (scenario.equals("leader")) when(origin.getLeader()).thenReturn("NewLeader"); + if (scenario.equals("already")) wars.when(() -> WarManager.exists(origin, target)).thenReturn(true); + if (scenario.equals("opinion")) when(origin.getRelation("target")).thenReturn(new Relation(neutral, indifferent, 20)); + if (scenario.equals("offline")) when(target.numOnline()).thenReturn(0); + if (scenario.equals("code")) codes.when(() -> WarDeclareCodeService.isRequired(player)).thenReturn(true); + click(24); + if (scenario.equals("code")) prompts.verify(() -> DeclareCodePrompt.begin(player, origin, target)); + else if (scenario.equals("goal")) verify(manager).openDeclareWarGoalPicker(player, origin, target); + else { + prompts.verifyNoInteractions(); + verify(manager, never()).openDeclareWarGoalPicker(any(), any(), any()); + if (!scenario.equals("leader")) verify(player).sendMessage(contains(expected)); + } + } + + @ParameterizedTest + @ValueSource(strings = {"locked", "vassal", "tier", "limit", "war", "capacity", "success"}) + void relationChangesRespectLocksVassalHierarchyLimitsWarAndBothCapacityChecks(String scenario) { + RelationType choice = type("choice", "vassal", scenario.equals("vassal") || scenario.equals("tier")); + if (scenario.equals("locked")) outgoing.setType(type("bound", "lock", true)); + if (scenario.equals("vassal")) when(origin.canHaveVassals()).thenReturn(false); + if (scenario.equals("tier")) when(origin.getTier().getTier()).thenReturn(1); + if (scenario.equals("limit")) diplomacy.when(() -> RelationManager.atLimit(origin, choice)).thenReturn(true); + if (scenario.equals("war")) diplomacy.when(() -> RelationManager.wartimeBlock(choice, target, origin)).thenReturn("War blocks changes"); + if (scenario.equals("capacity")) { + diplomacy.when(() -> RelationManager.actorLacksCapacity(origin, target, choice, neutral)).thenReturn(true); + diplomacy.when(() -> RelationManager.partnerLacksRelationCapacity(origin, target, choice)).thenReturn(true); + } + view.relationView(null, player, target, true); + click(10); + if (scenario.equals("success")) { + diplomacy.verify(() -> RelationManager.setRelation(player, choice, target, origin, true)); + assertEquals(SFGUI.DIPLOMACY_VIEW, menu()); + } else { + assertNoDiplomaticMutation(); + verify(player, atLeastOnce()).sendMessage(anyString()); + assertEquals(SFGUI.RELATION_VIEW, menu()); + } + } + + @ParameterizedTest + @CsvSource({"trade,locked", "trade,capacity", "trade,success", "treaty,locked", "treaty,capacity", "treaty,success", "treaty,clear"}) + void overlayChangesRespectLocksCapacityAndTreatyClearing(String kind, String scenario) { + RelationType choice = type("choice", "trade-agreement", kind.equals("trade"), "treaty", kind.equals("treaty"), "clear", scenario.equals("clear")); + RelationType current = scenario.equals("locked") ? type("bound", "lock", true) : neutral; + if (kind.equals("trade")) when(origin.getDiplomacyHandler().getTradeRelation("target")).thenReturn(current); + else when(origin.getDiplomacyHandler().getTreatyRelation("target")).thenReturn(current); + if (scenario.equals("capacity") || scenario.equals("clear")) { + diplomacy.when(() -> RelationManager.actorLacksCapacity(origin, target, choice, current)).thenReturn(true); + diplomacy.when(() -> RelationManager.partnerLacksOverlayCapacity(origin, target, choice, null)).thenReturn(true); + } + openPicker(kind); + click(9); + if (scenario.equals("success") || scenario.equals("clear")) { + if (kind.equals("trade")) diplomacy.verify(() -> RelationManager.setTradeRelation(player, choice, target, origin, true)); + else diplomacy.verify(() -> RelationManager.setTreatyRelation(player, choice, target, origin, true)); + assertEquals(SFGUI.DIPLOMACY_VIEW, menu()); + } else { + assertNoDiplomaticMutation(); + verify(player, atLeastOnce()).sendMessage(anyString()); + } + } + + @Test + void warThresholdAndReparationCardsExplainCostsDurationAndTicketRequirements() { + assertEquals(Material.AIR, creator.createWarButton(target, origin).getType()); + type("war", "threshold.amount", -10, "threshold.mutual", true); + Cache.warRequireDeclareCode = true; + assertTrue(text(creator.createWarButton(target, origin)).contains("approved War Ticket")); + Cache.warRequireDeclareCode = false; + assertTrue(text(creator.createWarButton(target, origin)).contains("Select a war goal")); + List<String> lines = new ArrayList<>(); + creator.addThreshold(lines, new Threshold(config("amount", 20, "mutual", false))); + assertTrue(ChatColor.stripColor(String.join("\n", lines)).contains("+20")); + assertTrue(text(creator.createWarReparationsItem(origin, target)).contains("No active")); + reparations.when(() -> WarReparationsService.findObligation(origin, target)).thenReturn(new WarReparationsObligation("target", 12.5, 3)); + reparations.when(() -> WarReparationsService.findObligation(target, origin)).thenReturn(new WarReparationsObligation("origin", 4, 2)); + String copy = text(creator.createWarReparationsItem(origin, target)); + assertTrue(copy.contains("Paying Target")); + assertTrue(copy.contains("Receiving from Target")); + assertTrue(copy.contains("12.5%")); + assertTrue(copy.contains("3 day(s)")); + assertTrue(copy.contains("2 day(s)")); + } + + @Test + void attitudeCardsShowModifiersCostsCurrentSelectionAndCapacityDenial() { + Attitude friendly = attitude("friendly", "target", 15, "cost", 2, "recieve-modifiers", List.of("PRESTIGE(2)")); + Cache.icons.put("friendly", "DIAMOND.3"); + diplomacy.when(() -> RelationManager.getDiplomaticCost(origin, target, friendly)).thenReturn(2.0); + ItemStack selection = creator.createAttitudeItem(friendly, origin, target, true); + assertEquals(Material.DIAMOND, selection.getType()); + assertTrue(text(selection).contains("+15")); + assertTrue(text(selection).contains("Diplomatic Cost")); + assertTrue(text(selection).contains("recieve modifiers")); + diplomacy.when(() -> RelationManager.lacksCapacityForChange(10, 2, 0)).thenReturn(true); + assertTrue(text(creator.createAttitudeItem(friendly, origin, target, true)).contains("Unavailable")); + ItemStack current = creator.createAttitudeItem(indifferent, origin, target, true); + assertTrue(text(current).contains("Current")); + assertEquals(1, current.getItemMeta().getEnchantLevel(Enchantment.UNBREAKING)); + assertTrue(text(creator.createAttitudeItem(indifferent)).contains("target by: 0")); + } + + @ParameterizedTest + @ValueSource(strings = {"locked", "war", "capacity", "current", "mutual", "reset", "linked", "plain"}) + void relationCardsDescribeSelectionConsequences(String scenario) { + RelationType initial = type("choice", "mutual", scenario.equals("mutual"), "limit", 3, "target", 10, "threshold.amount", 5, + "recieve-modifiers", List.of("PRESTIGE(2)"), "give-modifiers", List.of("PRESTIGE(1)")); + RelationType choice = scenario.equals("linked") ? replaceChoiceWithLinked(initial) : initial; + Cache.icons.put("choice", "GOLD_INGOT.4"); + diplomacy.when(() -> RelationManager.getDiplomaticCost(origin, target, choice)).thenReturn(2.0); + diplomacy.when(() -> RelationManager.getDiplomaticCost(target, origin, choice)).thenReturn(3.0); + if (scenario.equals("locked")) outgoing.setType(type("bound", "lock", true)); + if (scenario.equals("current")) outgoing.setType(choice); + if (scenario.equals("war")) diplomacy.when(() -> RelationManager.wartimeBlock(choice, target, origin)).thenReturn("Wartime rule"); + if (scenario.equals("capacity")) { + diplomacy.when(() -> RelationManager.actorLacksCapacity(origin, target, choice, neutral)).thenReturn(true); + diplomacy.when(() -> RelationManager.partnerLacksRelationCapacity(origin, target, choice)).thenReturn(true); + } + if (scenario.equals("reset") || scenario.equals("linked")) { + diplomacy.when(() -> RelationManager.reverseChange(target, origin, choice)).thenReturn(true); + } + ItemStack item = creator.createRelationTypeItem(choice, target, origin, true); + String copy = text(item); + assertEquals("choice", ui.data(item, "id")); + assertTrue(copy.contains("We recieve modifiers")); + assertTrue(copy.contains("They recieve modifiers")); + switch (scenario) { + case "locked", "war", "capacity" -> assertTrue(copy.contains("Unavailable")); + case "current" -> assertEquals(1, item.getItemMeta().getEnchantLevel(Enchantment.UNBREAKING)); + case "mutual" -> { assertTrue(copy.contains("Click to request")); assertTrue(copy.contains("(them)")); } + case "reset" -> assertTrue(copy.contains("reset their relationship")); + case "linked" -> assertTrue(copy.contains("set their relationship")); + default -> assertTrue(copy.contains("Click to change")); + } + } + + private RelationType replaceChoiceWithLinked(RelationType old) { + RelationLoader.types.remove(old); + type("overlord"); + RelationType linked = type("choice", "link", "overlord", "vassal", true, "recieve-modifiers", List.of("PRESTIGE(2)"), "give-modifiers", List.of("PRESTIGE(1)")); + diplomacy.when(() -> RelationManager.reverseChange(target, origin, linked)).thenReturn(true); + return linked; + } + + @ParameterizedTest + @CsvSource({"trade,locked", "trade,capacity", "trade,current", "trade,mutual", "trade,plain", "trade,summary", "treaty,locked", "treaty,capacity", "treaty,current", "treaty,mutual", "treaty,plain", "treaty,summary", "treaty,clear", "treaty,clearEmpty"}) + void overlayCardsExplainEffectsRestrictionsAndAvailableActions(String kind, String scenario) { + boolean trade = kind.equals("trade"); + boolean mutual = scenario.equals("mutual"); + boolean clear = scenario.startsWith("clear"); + RelationType choice = type("choice", "trade-agreement", trade, "treaty", !trade, "mutual", mutual, + "clear", clear, "target", mutual ? 15 : -5, "blocks-war", !trade, "threshold.amount", 5, + "trade-effects-us", List.of("TRADE_POWER(2)"), "trade-effects-them", List.of("TRADE_POWER(1)"), "installation-access", 1.0); + Cache.icons.put("choice", "DIAMOND.7"); + RelationType current = scenario.equals("locked") ? type("bound", "lock", true) : scenario.equals("current") ? choice : scenario.equals("clear") ? neutral : null; + if (trade) when(origin.getDiplomacyHandler().getTradeRelation("target")).thenReturn(current); + else when(origin.getDiplomacyHandler().getTreatyRelation("target")).thenReturn(current); + diplomacy.when(() -> RelationManager.getDiplomaticCost(origin, target, choice)).thenReturn(2.0); + diplomacy.when(() -> RelationManager.getDiplomaticCost(target, origin, choice)).thenReturn(3.0); + if (scenario.equals("capacity")) { + diplomacy.when(() -> RelationManager.actorLacksCapacity(origin, target, choice, current)).thenReturn(true); + diplomacy.when(() -> RelationManager.partnerLacksOverlayCapacity(origin, target, choice, null)).thenReturn(true); + } + boolean full = !scenario.equals("summary"); + ItemStack item = trade ? creator.createTradeAgreementTypeItem(player, choice, target, origin, full) + : creator.createTreatyTypeItem(player, choice, target, origin, full); + String copy = text(item); + assertEquals(Material.DIAMOND, item.getType()); + assertTrue(copy.contains("Diplomatic Cost")); + if (trade) { assertTrue(copy.contains("Our Guilds")); assertTrue(copy.contains("Their Guilds")); assertTrue(copy.contains("Installations")); } + else assertTrue(copy.contains("Blocks declaring war")); + switch (scenario) { + case "locked", "capacity" -> assertTrue(copy.contains("Unavailable")); + case "current" -> { assertTrue(copy.contains("Current")); assertEquals(1, item.getItemMeta().getEnchantLevel(Enchantment.UNBREAKING)); } + case "mutual" -> assertTrue(copy.contains("Click to request")); + case "summary" -> assertTrue(copy.contains("more information")); + case "clear" -> assertTrue(copy.contains("Click to clear treaty")); + case "clearEmpty" -> assertTrue(copy.contains("Current")); + default -> assertTrue(copy.contains("Click to set")); + } + if (trade && List.of("current", "mutual", "plain").contains(scenario)) { + RelationType proposed = scenario.equals("current") ? null : choice; + economics.verify(() -> EconomicImpact.applyTradeAgreementChange(anyList(), eq(player), eq(origin), eq(target), eq(proposed), eq(false), any(), eq(false))); + } + } + + @ParameterizedTest + @ValueSource(strings = {"attitude", "relation", "trade", "treaty", "detail"}) + void removingTheTargetWhileAMenuIsOpenSafelyIgnoresTheOldCards(String kind) { + type("choice", "trade-agreement", kind.equals("trade"), "treaty", kind.equals("treaty")); + int slot; + if (kind.equals("attitude")) { view.attitudeView(null, player, target, true); slot = 10; } + else if (kind.equals("detail")) { view.diplomacyView(null, player, target, true); slot = 12; } + else { openPicker(kind); slot = kind.equals("relation") ? 10 : 9; } + FactionManager.factions.remove(target); + Inventory original = top(); + assertDoesNotThrow(() -> click(slot)); + assertSame(original, top()); + assertNoDiplomaticMutation(); + diplomacy.verify(() -> RelationManager.setAttitude(any(), any(), any(), any()), never()); + } + + @ParameterizedTest + @ValueSource(strings = {"attitude", "detail"}) + void staleActorMembershipIsRejectedBeforeOpeningOrApplyingAnAttitude(String kind) { + if (kind.equals("attitude")) view.attitudeView(null, player, target, true); + else view.diplomacyView(null, player, target, true); + when(origin.getMembers()).thenReturn(List.of()); + Inventory original = top(); + assertDoesNotThrow(() -> click(kind.equals("attitude") ? 10 : 30)); + assertSame(original, top()); + diplomacy.verify(() -> RelationManager.setAttitude(any(), any(), any(), any()), never()); + } + + @ParameterizedTest + @ValueSource(strings = {"attitude", "relation", "trade", "treaty"}) + void navigationButtonsNeverDispatchDiplomacyChanges(String kind) { + if (kind.equals("attitude")) view.attitudeView(null, player, target, true); + else openPicker(kind); + click(26); + assertNoDiplomaticMutation(); + diplomacy.verify(() -> RelationManager.setAttitude(any(), any(), any(), any()), never()); + } + + @Test + void summaryCardsFallbackToWhiteBannerAndHideInvisibleRelationLabels() { + outgoing.setType(type("hidden", "visible", false)); + for (ItemStack banner : new ItemStack[] {null, new ItemStack(Material.AIR)}) { + when(target.getBanner()).thenReturn(banner); + ItemStack item = creator.createRelationItem(target, origin); + assertEquals(Material.WHITE_BANNER, item.getType()); + assertTrue(text(item).contains("Our opinion of them: -20")); + } + assertTrue(text(creator.createNoTradeAgreementItem()).contains("No Trade Agreement")); + assertTrue(text(creator.createNoTreatyItem()).contains("No Treaty")); + } + + @Test + void emptyUnusableAndUnownedInventoryClicksDoNotReachDiplomacyServices() { + view.attitudeView(null, player, target, true); + Inventory inventory = top(); + InventoryClickEvent empty = ui.click(player, 0); + view.click(empty, inventory, player); + assertFalse(empty.isCancelled()); + ItemStack air = new ItemStack(Material.AIR); + when(air.getItemMeta()).thenReturn(null); + inventory.setItem(0, air); + InventoryClickEvent invalid = ui.click(player, 0); + view.click(invalid, inventory, player); + assertTrue(invalid.isCancelled()); + Inventory unowned = ui.inventory(null, 9, "Unowned inventory"); + unowned.setItem(0, new ItemStack(Material.PAPER)); + player.openInventory(unowned); + InventoryClickEvent unownedClick = ui.click(player, 0); + view.click(unownedClick, unowned, player); + assertFalse(unownedClick.isCancelled()); + assertNoDiplomaticMutation(); + diplomacy.verify(() -> RelationManager.setAttitude(any(), any(), any(), any()), never()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenusCoverageTest.java new file mode 100644 index 00000000..73dd550c --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ReportedMenusCoverageTest.java @@ -0,0 +1,384 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.*; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.enums.*; +import net.tfminecraft.simplefactions.espionage.*; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.branch.Branch; +import net.tfminecraft.simplefactions.guild.upgrade.Upgrade; +import net.tfminecraft.simplefactions.loaders.UpgradeLoader; +import net.tfminecraft.simplefactions.managers.*; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemCreator; +import org.bukkit.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.*; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +/** + * Actual report snapshots and faction state; only Paper and external item creation are isolated. + */ +class ReportedMenusCoverageTest { + private FactionDomainFixture fixture; + private Faction target, observer; + private Guild guild; + private Player viewer; + private InventoryManager manager; + private IntelligenceReport report; + private MockedStatic<TLibs> items; + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + fixture.lawGroup( + "government", + Map.of( + "effects.faction.rules", + List.of( + "LEADER_ELECTIONS true", + "CITIZEN_TAX true", + "GUILD_TAX true", + "VASSAL_TAX true", + "DIVIDEND_TAX true", + "TARIFFS true"))); + fixture.law("government", "other", Map.of("name", "Other policy")); + fixture.regiment("levies", true, 4, 0); + fixture.regiment("guards", false, 8, 20); + for (int i = 0; i < 11; i++) { + YamlConfiguration y = new YamlConfiguration(); + y.set("u.name", "Upgrade " + i); + y.set("u.allowed-types", List.of("guild")); + y.set("u.description", List.of("A public upgrade description")); + UpgradeLoader.map.put( + "upgrade" + i, new Upgrade("upgrade" + i, y.getConfigurationSection("u"))); + } + target = fixture.saved("target", "ForeignLeader"); + target.getOrCreateMainGuild(); + guild = fixture.guild(target, "merchants", "GuildLeader"); + observer = fixture.saved("observer", "Viewer"); + viewer = fixture.player("Viewer"); + appoint(target, fixture.player("ForeignLeader")); + appoint(observer, viewer); + report = new IntelligenceReport(); + report.quality = "detailed"; + observer + .getEspionage() + .report( + target.getId(), + target.getFoundedAt(), + LocalDate.now(ZoneOffset.UTC).toEpochDay(), + () -> report); + manager = spy(new InventoryManager()); + doNothing().when(manager).installationDetailView(any(), any(), anyString()); + items = mockStatic(TLibs.class); + ItemAPI api = mock(ItemAPI.class); + ItemCreator creator = mock(ItemCreator.class); + when(api.getCreator()).thenReturn(creator); + items.when(TLibs::getItemAPI).thenReturn(api); + when(creator.getItemFromPath(anyString())).thenAnswer(call -> new ItemStack(Material.PAPER)); + EspionageConfig.load(new YamlConfiguration()); + assertSame(report, EspionageService.report(viewer, target)); + } + + @AfterEach + void close() { + items.close(); + EspionageConfig.load(new YamlConfiguration()); + fixture.close(); + } + + private void appoint(Faction faction, Player player) { + SpecialPositionAssignment spy = new SpecialPositionAssignment(); + spy.playerName = player.getName(); + spy.playerId = player.getUniqueId(); + spy.characterId = "character-" + player.getName(); + faction.getEspionage().appoint(spy, 80); + } + + private Inventory menu(SFGUI type, String id, int size) { + return ReportedMenus.open(viewer, id, type, size, "Foreign records"); + } + + private Inventory menu(SFGUI type) { + return menu(type, target.getId(), 54); + } + + private String text(ItemStack item) { + if (item == null) return ""; + var meta = item.getItemMeta(); + return ChatColor.stripColor( + meta.getDisplayName() + + "\n" + + String.join("\n", meta.getLore() == null ? List.of() : meta.getLore())); + } + + private void estimate(String key, long lower, long upper) { + report.estimates.put(key, new EspionageMath.Estimate(lower, upper)); + } + + private void click(Inventory inventory, int slot) { + viewer.openInventory(inventory); + InventoryClickEvent event = fixture.ui.click(viewer, slot); + ReportedMenus.navigate(event, viewer, (SFInventoryHolder) inventory.getHolder(), manager); + } + + private Inventory opened() { + return viewer.getOpenInventory().getTopInventory(); + } + + @Test + void maskingCopiesAppearanceAndStripsEveryMutationPayload() { + ItemStack template = new ItemStack(Material.DIAMOND); + var meta = template.getItemMeta(); + NamespacedKey key = new NamespacedKey(SimpleFactions.plugin, "secret-action"); + meta.getPersistentDataContainer().set(key, PersistentDataType.STRING, "withdraw"); + meta.setDisplayName("Old label"); + template.setItemMeta(meta); + ItemStack result = + ReportedMenus.mask(template, "Read-only report", List.of("Estimated figures")); + assertNotSame(template, result); + assertEquals(Material.DIAMOND, result.getType()); + assertTrue(result.getItemMeta().getPersistentDataContainer().getKeys().isEmpty()); + assertEquals( + "withdraw", + template.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING)); + assertEquals("Old label", template.getItemMeta().getDisplayName()); + assertTrue(text(result).contains("Estimated figures")); + } + + @Test + void reportedGuildLeaderUsesTheCachedNameWhileThePublicRealmLeaderRemainsVisible() { + assertTrue(ReportedMenus.guildLeader(viewer, guild).contains("Unknown")); + report.details.put("guild-leader:" + guild.getId(), List.of("Recorded Baron")); + assertEquals("Recorded Baron", ReportedMenus.guildLeader(viewer, guild)); + assertEquals("ForeignLeader", ReportedMenus.guildLeader(viewer, target.getOrCreateMainGuild())); + assertEquals("GuildLeader", ReportedMenus.guildLeader(fixture.player("GuildLeader"), guild)); + } + + @Test + void reportedBranchUsesEstimatedLevelsAndNeverTheCurrentLiveLevel() { + Branch branch = new Branch(guild.getBranch(0), 9876); + assertTrue(text(ReportedMenus.branch(viewer, guild, branch)).contains("Unknown")); + estimate(IntelligenceLedger.key(guild, "Branch:" + branch.getId()), 2, 4); + String rendered = text(ReportedMenus.branch(viewer, guild, branch)); + assertFalse(rendered.contains("9876")); + assertTrue(rendered.contains("2 to 4"), rendered); + assertTrue(rendered.contains("Effects:")); + assertTrue( + ReportedMenus.branch(viewer, guild, branch) + .getItemMeta() + .getPersistentDataContainer() + .getKeys() + .isEmpty()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void militaryAndUpgradeQueuesDiscloseOnlyRecordedEntries(boolean known) { + if (known) { + report.details.put("training", List.of("Recorded guards", "Recorded levies")); + estimate("Training:0", 50, 60); + report.details.put( + "upgrading:" + guild.getId(), List.of("Recorded fortification", "Recorded storehouse")); + estimate("Regiment:levies:Levies", 20, 30); + estimate("Regiment:guards:Soldiers", 5, 8); + } else observer.getEspionage().resetReportsAndRolls(); + Inventory military = menu(SFGUI.MILITARY_VIEW); + ReportedMenus.military(military, viewer, target, manager); + assertTrue(((SFInventoryHolder) military.getHolder()).isReported()); + assertEquals(Material.BARRIER, military.getItem(53).getType()); + assertTrue(text(military.getItem(39)).contains(known ? "Recorded guards" : "Unknown")); + assertEquals( + known ? null : Material.CLOCK, + military.getItem(41) == null ? null : military.getItem(41).getType()); + Inventory upgrades = menu(SFGUI.UPGRADE_VIEW, guild.getId(), 54); + ReportedMenus.upgrades(upgrades, viewer, guild, manager); + assertNotNull(upgrades.getItem(9)); + assertNotNull(upgrades.getItem(17)); + assertNull(upgrades.getItem(18)); + assertTrue(text(upgrades.getItem(39)).contains(known ? "Recorded fortification" : "Unknown")); + assertEquals( + known ? null : Material.CLOCK, + upgrades.getItem(41) == null ? null : upgrades.getItem(41).getType()); + } + + @Test + void largeArmyPresentationKeepsQueueAndNavigationSlotsAvailable() { + observer.getEspionage().resetReportsAndRolls(); + for (int i = 0; i < 35; i++) + target.getMilitary().getRegiments().add(fixture.regiment("extra" + i, false, 1, 0)); + Inventory military = menu(SFGUI.MILITARY_VIEW); + ReportedMenus.military(military, viewer, target, manager); + assertNotNull(military.getItem(38)); + assertEquals(Material.CLOCK, military.getItem(39).getType()); + assertEquals(Material.MINECART, military.getItem(49).getType()); + assertEquals(Material.BARRIER, military.getItem(53).getType()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void governmentAndPolicyViewsRespectMissingAndPresentSnapshots(boolean known) { + if (known) { + estimate("Administrative power", 12, 15); + estimate("Legitimacy", 70, 75); + estimate("Stability", 80, 85); + estimate("Council size", 3, 4); + report.details.put("law:government", List.of("Other policy")); + } else observer.getEspionage().resetReportsAndRolls(); + Inventory government = menu(SFGUI.GOVERNMENT_VIEW); + ReportedMenus.government(government, viewer, target, manager); + assertTrue(text(government.getItem(10)).contains("ForeignLeader")); + assertTrue(text(government.getItem(13)).contains("Council members: Unknown")); + assertTrue(text(government.getItem(11)).contains(known ? "80" : "Unknown")); + Inventory laws = menu(SFGUI.LAW_VIEW); + ReportedMenus.laws(laws, viewer, target, manager); + assertTrue(text(laws.getItem(10)).contains(known ? "Other policy" : "Unknown")); + click(laws, 10); + Inventory selection = opened(); + assertEquals(SFGUI.LAW_SELECT, ((SFInventoryHolder) selection.getHolder()).getType()); + assertTrue(text(selection.getItem(0)).contains(known ? "No" : "Unknown")); + assertTrue(text(selection.getItem(1)).contains(known ? "Yes" : "Unknown")); + } + + @Test + void installationDetailsStayInTheReportAndOmitCoordinatesAndVehicles() { + report.details.put( + "installations", + List.of( + "station\nTRAIN_STATION\nRecorded Station", "fort\nFORT\nRecorded Fort", "malformed")); + report.details.put("construction", List.of("Recorded Project")); + estimate("Installation:station:Level", 2, 3); + Inventory list = menu(SFGUI.INSTALLATIONS_VIEW); + ReportedMenus.installations(list, viewer, target, manager); + assertEquals(Material.MINECART, list.getItem(12).getType()); + assertTrue(text(list.getItem(13)).contains("Recorded Fort")); + assertTrue(text(list.getItem(39)).contains("Recorded Project")); + click(list, 12); + verify(manager).installationDetailView(viewer, target, "station"); + click(list, 13); + verify(manager).installationDetailView(viewer, target, "fort"); + Inventory details = menu(SFGUI.INSTALLATION_DETAIL_VIEW); + ReportedMenus.installationDetail(details, viewer, target, "station", manager); + assertTrue(text(details.getItem(49)).contains("Recorded Station")); + assertTrue(text(details.getItem(49)).contains("Coordinates: Unknown")); + ReportedMenus.installationDetail(details, viewer, target, "fort", manager); + assertEquals(Material.GREEN_CONCRETE, details.getItem(49).getType()); + ReportedMenus.installationDetail(details, viewer, target, "missing", manager); + assertTrue(text(details.getItem(49)).contains("Installation Details")); + observer.getEspionage().resetReportsAndRolls(); + ReportedMenus.installations(list, viewer, target, manager); + assertEquals(Material.GRAY_CONCRETE, list.getItem(12).getType()); + assertTrue(text(list.getItem(39)).contains("Construction")); + ReportedMenus.installationDetail(details, viewer, target, "station", manager); + assertTrue(text(details.getItem(49)).contains("Level: Unknown")); + } + + @Test + void malformedReportedInstallationBeforeAValidOneDoesNotShiftItsClickTarget() { + report.details.put( + "installations", List.of("malformed", "station\nTRAIN_STATION\nRecorded Station")); + Inventory list = menu(SFGUI.INSTALLATIONS_VIEW); + ReportedMenus.installations(list, viewer, target, manager); + assertTrue(text(list.getItem(12)).contains("Recorded Station")); + click(list, 12); + verify(manager).installationDetailView(viewer, target, "station"); + } + + @Test + void installationConstructionCardDoesNotNavigateToAnUndisplayedInstallation() { + List<String> entries = new ArrayList<>(); + for (int index = 0; index < 40; index++) entries.add("fort" + index + "\nFORT\nFort " + index); + report.details.put("installations", entries); + report.details.put("construction", List.of("Current project")); + Inventory menu = menu(SFGUI.INSTALLATIONS_VIEW); + ReportedMenus.installations(menu, viewer, target, manager); + assertEquals(Material.YELLOW_CONCRETE, menu.getItem(39).getType()); + click(menu, 39); + verify(manager, never()).installationDetailView(any(), any(), anyString()); + } + + @Test + void taxReportsOpenReadOnlySpecificRatesAndKeepTheTreasuryUnchanged() { + for (TaxTarget target : TaxTarget.values()) estimate("Tax:" + target.name(), 15, 20); + Inventory taxes = menu(SFGUI.TAX_VIEW); + ReportedMenus.taxes(taxes, viewer, target, manager); + List<TaxTarget> types = + Arrays.stream(TaxTarget.values()).filter(target.getTaxHandler()::canCollectTax).toList(); + double balance = target.getBank().getWealth(); + for (int i = 0; i < types.size(); i++) { + assertTrue(text(taxes.getItem(i)).contains("Tax rate:")); + click(taxes, i); + if (types.get(i).name().endsWith("_ID")) { + Inventory specific = opened(); + assertEquals(SFGUI.TAX_VIEW_SPECIFIC, ((SFInventoryHolder) specific.getHolder()).getType()); + assertTrue(((SFInventoryHolder) specific.getHolder()).isReported()); + assertTrue(text(specific.getItem(0)).contains("Unknown")); + } + } + assertEquals(balance, target.getBank().getWealth()); + } + + @ParameterizedTest + @ValueSource(ints = {13, 24, 33}) + void governmentNavigationOpensMaskedCouncilProposalsAndMovements(int slot) { + Inventory menu = menu(SFGUI.GOVERNMENT_VIEW); + ReportedMenus.government(menu, viewer, target, manager); + click(menu, slot); + Inventory next = opened(); + SFGUI expected = + slot == 13 ? SFGUI.COUNCIL_VIEW : slot == 24 ? SFGUI.PROPOSALS : SFGUI.MOVEMENT_LIST; + assertEquals(expected, ((SFInventoryHolder) next.getHolder()).getType()); + assertTrue(((SFInventoryHolder) next.getHolder()).isReported()); + assertTrue(text(next.getItem(slot == 24 ? 13 : 10)).contains("Unknown")); + } + + @ParameterizedTest + @ValueSource(ints = {2, 4, 11, 13, 15, 22}) + void guildLoanAndCompanyNavigationNeverExposesLivePrivateRecords(int slot) { + boolean loans = slot == 2 || slot == 4; + Inventory menu = + menu(loans ? SFGUI.LOAN_MAIN_VIEW : SFGUI.COMPANY_VIEW, guild.getId(), loans ? 9 : 27); + if (loans) ReportedMenus.loans(menu, manager); + else ReportedMenus.company(menu, manager); + click(menu, slot); + Inventory next = opened(); + assertTrue(((SFInventoryHolder) next.getHolder()).isReported()); + assertTrue(text(next.getItem(loans || slot == 22 ? 0 : 9)).contains("Unknown")); + assertEquals(Material.BARRIER, next.getItem(53).getType()); + } + + @Test + void unknownStaleEmptyAndBackClicksLeaveTheCurrentMenuInPlace() { + Inventory menu = menu(SFGUI.STABILITY_VIEW); + menu.setItem(3, new ItemStack(Material.PAPER)); + click(menu, 3); + assertSame(menu, opened()); + click(menu, 2); + assertSame(menu, opened()); + menu.setItem(3, new ItemStack(Material.BARRIER)); + click(menu, 3); + assertSame(menu, opened()); + Inventory missing = menu(SFGUI.LAW_VIEW, "deleted", 54); + missing.setItem(10, new ItemStack(Material.PAPER)); + click(missing, 10); + assertSame(missing, opened()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/StaleGovernmentEconomyReviewRegressionTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/StaleGovernmentEconomyReviewRegressionTest.java new file mode 100644 index 00000000..230d0148 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/StaleGovernmentEconomyReviewRegressionTest.java @@ -0,0 +1,289 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.tfminecraft.simplefactions.enums.SFGUI; +import net.tfminecraft.simplefactions.enums.Stance; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.branch.Branch; +import net.tfminecraft.simplefactions.managers.RelationManager; +import net.tfminecraft.simplefactions.managers.RelocationPrompt; +import net.tfminecraft.simplefactions.managers.RequestManager; +import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.rest.RestServer; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.PersistenceFilesFixture; +import net.tfminecraft.simplefactions.utils.DisplayNameGate; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemCreator; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +@SuppressWarnings("deprecation") +class StaleGovernmentEconomyReviewRegressionTest { + private FactionDomainFixture domain; + private PersistenceFilesFixture files; + private MockedStatic<TLibs> itemProvider; + private MockedStatic<RestServer> rest; + private final Map<Map<Object, Object>, Map<Object, Object>> globals = new IdentityHashMap<>(); + private Faction home; + private Faction other; + private Guild guild; + private Player ruler; + private Player alice; + private GovernmentView government; + private GuildView guildView; + + @BeforeEach + void setUp() throws Exception { + files = new PersistenceFilesFixture(); + domain = new FactionDomainFixture(); + for (var entry : + List.of( + Map.entry(RelocationPrompt.class, "pending"), + Map.entry(DisplayNameGate.class, "pending"), + Map.entry(RequestManager.class, "requests"))) { + Field field = entry.getKey().getDeclaredField(entry.getValue()); + field.setAccessible(true); + @SuppressWarnings("unchecked") + Map<Object, Object> map = (Map<Object, Object>) field.get(null); + globals.put(map, new LinkedHashMap<>(map)); + map.clear(); + } + domain.provincesEnabled(true); + domain.lawGroup( + "government", + Map.of( + "effects.faction.rules", + List.of("CAN_HAVE_VASSALS true", "CAN_FAVOUR true", "CAN_REPRESS true"))); + var data = domain.data("home", "Ruler"); + data.provinces.addAll(List.of(1, 2)); + home = domain.saved(data); + other = domain.saved("other", "Bob"); + guild = domain.guild(home, "traders", "Alice"); + for (int id : List.of(1, 2)) domain.provinceData.put(id, new Province(id, "PLAINS", 50)); + guild.setCapital(1); + guild.getBank().setWealth(500.0); + home.getSettlementHandler().found("Original", 1, 0, 0); + home.getGovernment().setPower(50); + ruler = domain.player("Ruler"); + alice = domain.player("Alice"); + ItemAPI api = mock(ItemAPI.class); + ItemCreator items = mock(ItemCreator.class); + when(api.getCreator()).thenReturn(items); + when(items.getItemFromPath(anyString())).thenAnswer(call -> new ItemStack(Material.PAPER)); + when(items.getItemsAdderItem(anyString())).thenAnswer(call -> new ItemStack(Material.PAPER)); + itemProvider = mockStatic(TLibs.class); + itemProvider.when(TLibs::getItemAPI).thenReturn(api); + rest = mockStatic(RestServer.class); + rest.when(() -> RestServer.getProvince(alice)).thenReturn(2); + when(domain.map.getRelocationTarget(alice)).thenReturn(home); + when(domain.ui.scheduler.runTaskLater(any(), any(Runnable.class), anyLong())) + .thenReturn(mock(BukkitTask.class)); + government = new GovernmentView(domain.inventory); + guildView = new GuildView(domain.inventory); + guildView.setProvinceManager(domain.provinces); + domain.ui.tasks.clear(); + } + + @AfterEach + void tearDown() throws Exception { + try { + globals.forEach( + (map, original) -> { + map.clear(); + map.putAll(original); + }); + } finally { + try { + if (rest != null) rest.close(); + if (itemProvider != null) itemProvider.close(); + } finally { + try { + if (domain != null) domain.close(); + } finally { + if (files != null) files.close(); + } + } + } + } + + @Test + void removingABankWhileTheCityNamePromptIsOpenPreservesTheCapitalAndSettlements() { + var oldCity = home.getSettlementHandler().getByProvince(1); + assertTrue(RelocationPrompt.begin(alice, guild, home, 2, false, 100)); + Bank account = guild.getBank(); + guild.setBank(null); + var answer = new AsyncPlayerChatEvent(true, alice, "Destination", Set.of()); + new RelocationPrompt().onPlayerChat(answer); + assertTrue(answer.isCancelled()); + assertAll( + () -> assertDoesNotThrow(domain.ui::runTasks), + () -> assertEquals(1, guild.getCapital()), + () -> assertSame(oldCity, home.getSettlementHandler().getByProvince(1)), + () -> assertNull(home.getSettlementHandler().getByProvince(2)), + () -> assertEquals(500.0, account.getWealth())); + } + + @Test + void relocationFromAnOpenGuildMenuRejectsAMissingBank() { + Inventory menu = guildMenu(); + menu.setItem(34, guildView.creator.createRelocateItem(alice, home, guild)); + Bank account = guild.getBank(); + guild.setBank(null); + assertAll( + () -> assertDoesNotThrow(() -> guildView.click(domain.ui.click(alice, 34), menu, alice)), + () -> assertEquals(1, guild.getCapital()), + () -> assertNull(home.getSettlementHandler().getByProvince(2)), + () -> assertEquals(500.0, account.getWealth()), + () -> assertFalse(RequestManager.hasRequest(ruler))); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void branchActionsFromAnOpenMenuRejectAMissingBankBeforeChangingTheLevel(boolean upgrade) { + Branch branch = guild.getBranch("commerce"); + assertNotNull(branch); + branch.levelUp(); + Inventory menu = guildMenu(); + int slot = upgrade ? 20 : 38; + menu.setItem( + slot, + upgrade + ? guildView.creator.createBranchUpgradeItem(alice, guild, branch) + : guildView.creator.createBranchDowngradeItem(alice, guild, branch)); + Bank account = guild.getBank(); + guild.setBank(null); + assertAll( + () -> assertDoesNotThrow(() -> guildView.click(domain.ui.click(alice, slot), menu, alice)), + () -> + assertEquals( + 1, + branch.getLevel(), + "An absent account must not lose an already-paid branch level"), + () -> assertEquals(500.0, account.getWealth())); + } + + private Inventory guildMenu() { + Inventory menu = + Bukkit.createInventory( + new SFInventoryHolder(guild.getId(), SFGUI.GUILD_VIEW), 54, "§7Guild View"); + alice.openInventory(menu); + return menu; + } + + @Test + void aStanceButtonCannotChangeAGuildThatHasRelocatedOutOfTheDisplayedFaction() { + guild.setStance(Stance.NEUTRAL); + government.governmentView(alice, home, null); + Inventory menu = alice.getOpenInventory().getTopInventory(); + assertNotNull(menu.getItem(28)); + guild.relocate(other, -1); + assertFalse(home.getGovernment().canAffectStability(guild)); + double power = home.getGovernment().getPower(); + assertAll( + () -> assertDoesNotThrow(() -> government.click(domain.ui.click(alice, 28), menu, alice)), + () -> assertEquals(Stance.NEUTRAL, guild.getStance(other)), + () -> assertSame(other, guild.getFaction()), + () -> assertEquals(power, home.getGovernment().getPower())); + } + + @Test + void membersSeeTheStanceWithoutBeingOfferedALeaderOnlyAction() { + guild.addMember("Charlie"); + Player member = domain.player("Charlie"); + guild.setStance(Stance.NEUTRAL); + assertFalse(guild.isLeader(member)); + government.governmentView(member, home, null); + Inventory menu = member.getOpenInventory().getTopInventory(); + ItemStack stance = menu.getItem(28); + assertNotNull(stance); + assertEquals(Material.YELLOW_CONCRETE, stance.getType()); + String description = String.join(" ", stance.getItemMeta().getLore()); + assertTrue(description.contains("Only the guild leader can change this stance.")); + assertFalse(description.contains("Click to change")); + government.click(domain.ui.click(member, 28), menu, member); + assertEquals(Stance.NEUTRAL, guild.getStance(home)); + } + + @Test + void theCurrentGuildLeaderCanStillUseTheDisplayedStanceButton() { + guild.setStance(Stance.NEUTRAL); + government.governmentView(alice, home, null); + Inventory menu = alice.getOpenInventory().getTopInventory(); + assertNotNull(menu.getItem(28)); + assertTrue(String.join(" ", menu.getItem(28).getItemMeta().getLore()).contains("Click to change")); + government.click(domain.ui.click(alice, 28), menu, alice); + assertEquals(Stance.SUPPORT, guild.getStance(home)); + } + + @ParameterizedTest + @CsvSource({"true,true", "true,false", "false,true", "false,false"}) + void favourAndRepressCannotApplyToAGuildOrVassalThatLeftTheDisplayedRealm( + boolean guildTarget, boolean favour) { + Faction subject = null; + Guild selected = guild; + if (!guildTarget) { + subject = domain.saved("subject", "VassalLeader"); + domain.subject(home, subject); + selected = subject.getOrCreateMainGuild(); + } + government.favourRepressSelectView(ruler, home, favour, guildTarget, null); + Inventory menu = ruler.getOpenInventory().getTopInventory(); + assertNotNull(menu.getItem(0)); + if (guildTarget) guild.relocate(other, -1); + else { + RelationManager.endVassalage(home, subject, false); + domain.subject(other, subject); + assertFalse(home.getSubjects().contains(subject)); + } + Guild changed = selected; + double power = home.getGovernment().getPower(); + assertAll( + () -> assertDoesNotThrow(() -> government.click(domain.ui.click(ruler, 0), menu, ruler)), + () -> assertFalse(changed.isFavoured()), + () -> assertFalse(changed.isRepressed()), + () -> assertEquals(power, home.getGovernment().getPower())); + } + + @ParameterizedTest + @CsvSource({"true,true", "true,false", "false,true", "false,false"}) + void currentGuildsAndDirectVassalsStillAcceptTheLeadersFavourAndRepressChoices( + boolean guildTarget, boolean favour) { + Guild selected = guild; + if (!guildTarget) { + Faction subject = domain.saved("subject", "VassalLeader"); + domain.subject(home, subject); + selected = subject.getOrCreateMainGuild(); + } + government.favourRepressSelectView(ruler, home, favour, guildTarget, null); + Inventory menu = ruler.getOpenInventory().getTopInventory(); + assertNotNull(menu.getItem(0)); + government.click(domain.ui.click(ruler, 0), menu, ruler); + assertEquals(favour, selected.isFavoured()); + assertEquals(!favour, selected.isRepressed()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleViewPageClickTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleViewPageClickTest.java index f4ef59da..2c6f31c6 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleViewPageClickTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/TierTitleViewPageClickTest.java @@ -30,7 +30,7 @@ import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.enums.SFGUI; -import net.tfminecraft.simplefactions.loaders.TitleLoader; +import net.tfminecraft.simplefactions.loaders.TierLoader; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.managers.InventoryManager; import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; @@ -72,7 +72,7 @@ void pageButton_redrawEmptiesClickedSlot_turnsPageWithoutReadingIt() { ItemStack firstTitle = itemWith(PersistentDataType.STRING, "county-a"); Inventory inventory = mock(Inventory.class); - when(inventory.getHolder()).thenReturn(new SFInventoryHolder("faction-1", SFGUI.TITLE_TYPE_VIEW)); + when(inventory.getHolder()).thenReturn(new SFInventoryHolder("faction-1", SFGUI.TITLE_TYPE_VIEW, 0, false, "county")); when(inventory.getContents()).thenReturn(new ItemStack[] { firstTitle }); InventoryView inventoryView = mock(InventoryView.class); @@ -82,9 +82,9 @@ void pageButton_redrawEmptiesClickedSlot_turnsPageWithoutReadingIt() { when(event.getCurrentItem()).thenReturn(pageItem, pageItem, null); try (MockedStatic<FactionManager> factions = mockStatic(FactionManager.class); - MockedStatic<TitleLoader> titles = mockStatic(TitleLoader.class)) { + MockedStatic<TierLoader> tiers = mockStatic(TierLoader.class)) { factions.when(() -> FactionManager.getByString("faction-1")).thenReturn(faction); - titles.when(() -> TitleLoader.getById("county-a")).thenReturn(title); + tiers.when(() -> TierLoader.getByString("county")).thenReturn(tier); assertDoesNotThrow(() -> view.click(event, inventory, player)); diff --git a/src/test/java/net/tfminecraft/simplefactions/map/MapBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/map/MapBoundaryCoverageTest.java new file mode 100644 index 00000000..73949b62 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/map/MapBoundaryCoverageTest.java @@ -0,0 +1,176 @@ +package net.tfminecraft.simplefactions.map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.GZIPOutputStream; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.managers.ProvinceManager; +import net.tfminecraft.simplefactions.map.fertility.FertilityProvinceResolver; +import net.tfminecraft.simplefactions.map.infra.TrackProvinceCache; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import org.bukkit.Location; +import org.bukkit.World; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class MapBoundaryCoverageTest { + @TempDir Path directory; + + @Test + void fertilityUsesTheConfiguredWorldInsteadOfMatchingCoordinatesInAnotherWorld() + throws Exception { + try (var ui = new GuiTestFixture()) { + String previous = Cache.worldName; + try { + Cache.worldName = "world"; + var grid = grid(2, 2, new int[] {7, 0, 0, 9}); + var provinces = new ProvinceManager(); + provinces.start(Map.of(7, new Province(7, "plains", 62), 9, new Province(9, "forest", 34))); + when(ui.plugin.getProvinceGrid()).thenReturn(grid); + when(ui.plugin.getProvinceManager()).thenReturn(provinces); + World other = mock(World.class); + when(other.getName()).thenReturn("other_world"); + assertEquals(62, FertilityProvinceResolver.fertilityAt(new Location(ui.world, 0, 64, 0))); + assertEquals(0, FertilityProvinceResolver.fertilityAt(new Location(other, 0, 64, 0))); + assertEquals( + 34, FertilityProvinceResolver.fertilityAt(new Location(ui.world, 1.9, 64, 1.1))); + } finally { + Cache.worldName = previous; + } + } + } + + @Test + void fertilityWithoutALocationWorldOrLivePluginReturnsNoBonus() { + String previous = Cache.worldName; + try (var ui = new GuiTestFixture()) { + Cache.worldName = "world"; + assertEquals(0, FertilityProvinceResolver.fertilityAt((Location) null)); + assertEquals(0, FertilityProvinceResolver.fertilityAt(new Location(null, 0, 64, 0))); + SimpleFactions.plugin = null; + assertEquals(0, FertilityProvinceResolver.fertilityAt(new Location(ui.world, 0, 64, 0))); + } finally { + Cache.worldName = previous; + } + } + + @Test + void overflowingGridDimensionsAreRejectedBeforeAnUnusableGridIsPublished() throws Exception { + Path file = + payload( + ByteBuffer.allocate(8) + .order(ByteOrder.LITTLE_ENDIAN) + .putInt(65536) + .putInt(65536) + .array()); + assertThrows(IOException.class, () -> ProvinceGrid.load(file.toFile())); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1}) + void nonpositiveGridDimensionsAreRejected(int dimension) throws Exception { + Path file = + payload( + ByteBuffer.allocate(8) + .order(ByteOrder.LITTLE_ENDIAN) + .putInt(dimension) + .putInt(2) + .array()); + assertTrue( + assertThrows(IOException.class, () -> ProvinceGrid.load(file.toFile())) + .getMessage() + .contains("dimensions")); + } + + @Test + void missingTruncatedAndInconsistentGridsCannotBecomeLiveLookupData() throws Exception { + assertThrows(IOException.class, () -> ProvinceGrid.load(null)); + assertThrows( + IOException.class, () -> ProvinceGrid.load(directory.resolve("missing.gz").toFile())); + Path tooShort = payload(new byte[] {1, 2, 3}); + assertTrue( + assertThrows(IOException.class, () -> ProvinceGrid.load(tooShort.toFile())) + .getMessage() + .contains("header")); + Path missingCell = + payload( + ByteBuffer.allocate(10) + .order(ByteOrder.LITTLE_ENDIAN) + .putInt(2) + .putInt(1) + .putShort((short) 7) + .array()); + assertTrue( + assertThrows(IOException.class, () -> ProvinceGrid.load(missingCell.toFile())) + .getMessage() + .contains("body length")); + } + + @Test + void loadedGridPreservesUnsignedProvinceIdsAndRowMajorBoundaries() throws Exception { + ProvinceGrid grid = grid(2, 2, new int[] {7, 65535, 9, 42}); + assertEquals(2, grid.getWidth()); + assertEquals(2, grid.getHeight()); + assertEquals(7, grid.getAt(0, 0)); + assertEquals(65535, grid.getAt(1, 0)); + assertEquals(9, grid.getAt(0, 1)); + assertEquals(42, grid.getAt(1, 1)); + assertEquals(0, grid.getAt(-1, 0)); + assertEquals(0, grid.getAt(0, -1)); + assertEquals(0, grid.getAt(2, 1)); + assertEquals(0, grid.getAt(1, 2)); + } + + @Test + void liveTrackSnapshotIsSharedImmutableAndRecalculatesOnlyOnChange() { + TrackProvinceCache live = TrackProvinceCache.live(); + Set<Integer> previous = live.provinces(); + Set<Integer> replacement = + previous.equals(Set.of(101, 102)) ? Set.of(103, 104) : Set.of(101, 102); + AtomicInteger recalculations = new AtomicInteger(); + try { + assertTrue( + live.refresh( + () -> replacement, recalculations::incrementAndGet, message -> fail(message))); + assertSame(live, TrackProvinceCache.live()); + assertEquals(replacement, TrackProvinceCache.live().provinces()); + assertThrows(UnsupportedOperationException.class, () -> live.provinces().add(999)); + assertFalse( + live.refresh( + () -> Set.copyOf(replacement), + recalculations::incrementAndGet, + message -> fail(message))); + assertEquals(1, recalculations.get()); + } finally { + live.refresh(() -> previous, () -> {}, message -> fail(message)); + } + } + + private ProvinceGrid grid(int width, int height, int[] ids) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate(8 + ids.length * 2).order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(width).putInt(height); + for (int id : ids) buffer.putShort((short) id); + return ProvinceGrid.load(payload(buffer.array()).toFile()); + } + + private Path payload(byte[] bytes) throws IOException { + Path file = Files.createTempFile(directory, "grid-", ".bin.gz"); + try (var compressed = new GZIPOutputStream(Files.newOutputStream(file))) { + compressed.write(bytes); + } + return file; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/map/MapClaimsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/map/MapClaimsCoverageTest.java new file mode 100644 index 00000000..ff38813d --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/map/MapClaimsCoverageTest.java @@ -0,0 +1,239 @@ +package net.tfminecraft.simplefactions.map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.installation.*; +import net.tfminecraft.simplefactions.managers.*; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.rest.RestServer; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.war.civilwar.CivilWarSnapshot; +import net.tfminecraft.simplefactions.war.core.War; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +class MapClaimsCoverageTest { + private FactionDomainFixture fixture; + private MapSystem map; + private Faction home; + private Player leader; + private int cost; + private MockedStatic<RestServer> rest; + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + cost = Cache.provinceCost; + Cache.provinceCost = 0; + for (int id = 1; id <= 8; id++) fixture.provinceData.put(id, new Province(id, "PLAINS", 50)); + link(1, 2); + link(2, 3); + link(3, 4); + home = realm("home", "Leader", 1, 1); + leader = fixture.player("Leader"); + map = new MapSystem(); + rest = mockStatic(RestServer.class); + } + + @AfterEach + void close() { + try { + rest.close(); + Cache.provinceCost = cost; + } finally { + fixture.close(); + } + } + + private void link(int a, int b) { + fixture.provinceData.get(a).addNeighbour(b); + fixture.provinceData.get(b).addNeighbour(a); + } + + private Faction realm(String id, String name, int capital, int... owned) { + var data = fixture.data(id, name); + data.capital = capital; + for (int p : owned) data.provinces.add(p); + Faction f = fixture.saved(data); + f.getOrCreateMainGuild(); + return f; + } + + @SuppressWarnings("unchecked") + private void civilWar(Faction host) throws Exception { + Faction rebels = fixture.saved("rebels", "Rebel"); + War war = new War(71, rebels, host); + CivilWarSnapshot snapshot = new CivilWarSnapshot(); + snapshot.setHostFactionId(host.getId()); + snapshot.setTempRebelFactionId(rebels.getId()); + war.setCivilWarSnapshot(snapshot); + var field = WarManager.class.getDeclaredField("wars"); + field.setAccessible(true); + ((List<War>) field.get(null)).add(war); + } + + @Test + void unknownProvinceCannotBecomeALandlessFactionsFirstClaim() { + Faction landless = fixture.saved("landless", "Nomad"); + map.claim(fixture.player("Nomad"), landless, 999, false); + assertTrue(landless.getProvinces().isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = {"WATER", "SEA"}) + void waterTilesAreRejectedBeforeOwnershipChanges(String terrain) { + fixture.provinceData.put(2, new Province(2, terrain, 0)); + map.claim(leader, home, 2, false); + assertEquals(List.of(1), home.getProvinces()); + verify(leader).sendMessage(contains("no province")); + } + + @Test + void invalidOwnedAndDistantClaimsReturnSpecificDenials() { + map.claim(leader, home, 0, false); + verify(leader).sendMessage(contains("no province")); + map.claim(leader, home, 1, false); + verify(leader).sendMessage(contains("already owns")); + map.claim(leader, home, 8, false); + verify(leader).sendMessage(contains("does not border")); + assertEquals(List.of(1), home.getProvinces()); + } + + @Test + void validNeighborClaimChangesOwnershipExactlyOnce() { + map.claim(leader, home, 2, false); + map.claim(leader, home, 2, false); + assertEquals(List.of(1, 2), home.getProvinces()); + assertSame(home, FactionManager.getByProvince(2)); + verify(leader).sendMessage(contains("Successfully")); + } + + @Test + void insufficientPrestigeDoesNotClaimOrRemoveAnOwnersProvince() { + Cache.provinceCost = 1_000_000; + assertTrue(home.getPrestige() < TitleManager.getClaimCost(home)); + map.claim(leader, home, 2, false); + assertEquals(List.of(1), home.getProvinces()); + verify(leader).sendMessage(contains("prestige to claim")); + } + + @Test + void firstCapitalClaimMayPassTheUntitledLimitButOrdinaryClaimCannot() { + Cache.maxUntitledProvinces = 0; + map.claim(leader, home, 2, false); + assertFalse(home.hasProvince(2)); + verify(leader).sendMessage(contains("too many untitled")); + map.claimForCapital(leader, home, 2, false); + assertTrue(home.hasProvince(2)); + } + + @Test + void anotherRealmBelowItsCapKeepsItsProvince() { + Faction owner = realm("other", "Other", 2, 2); + map.claim(leader, home, 2, false); + assertSame(owner, FactionManager.getByProvince(2)); + assertFalse(home.hasProvince(2)); + verify(leader).sendMessage(contains("already claimed")); + } + + @Test + void civilWarLocksClaimAndUnclaimForItsParticipants() throws Exception { + home.addProvince(2); + civilWar(home); + map.claim(leader, home, 3, false); + map.unclaim(leader, home, 2); + assertEquals(List.of(1, 2), home.getProvinces()); + verify(leader) + .sendMessage(net.tfminecraft.simplefactions.war.civilwar.CivilWarCopy.CANNOT_CLAIM); + verify(leader) + .sendMessage(net.tfminecraft.simplefactions.war.civilwar.CivilWarCopy.CANNOT_UNCLAIM); + } + + @Test + void lockedOverCapOwnerCannotLoseProvinceToAnotherClaim() throws Exception { + Faction owner = realm("other", "Other", 3, 2, 3); + civilWar(owner); + Cache.provinceCost = 1000; + home.getBank().deposit(1_000_000_000.0); + home.updatePrestige(); + map.claim(leader, home, 2, false); + assertSame(owner, FactionManager.getByProvince(2)); + verify(leader) + .sendMessage(net.tfminecraft.simplefactions.war.civilwar.CivilWarCopy.CANNOT_STEAL); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void stealingAnOverCapHoldingTransfersItsInstallationWithTheLand(boolean online) { + Faction owner = realm("other", "Other", 3, 2, 3); + Cache.provinceCost = 1000; + home.getBank().deposit(1_000_000_000.0); + home.updatePrestige(); + assertTrue(TitleManager.overProvinceCap(owner)); + assertTrue(home.getPrestige() >= TitleManager.getClaimCost(home)); + Player other = fixture.player("Other"); + if (!online) fixture.online.remove("Other"); + Installation fort = new Installation("fort", "Riverside", InstallationKind.FORT, 2, 0, 0, 0); + owner.getInstallationHandler().acceptTransferred(fort); + map.claim(leader, home, 2, false); + assertTrue(home.hasProvince(2)); + assertFalse(owner.hasProvince(2)); + assertTrue(owner.hasProvince(3)); + assertSame(fort, home.getInstallationHandler().getById("fort")); + assertNull(owner.getInstallationHandler().getById("fort")); + if (online) verify(other).sendMessage(contains("claimed one of your provinces")); + else verify(other, never()).sendMessage(anyString()); + verify(fixture.map, atLeastOnce()).enqueue("nation", home.getRGB()); + } + + @Test + void unclaimRejectsMissingAndCapitalButAllowsOrdinaryHoldingAndInternalCleanup() { + map.unclaim(leader, home, 0); + map.unclaim(leader, home, 7); + map.unclaim(leader, home, 1); + assertEquals(List.of(1), home.getProvinces()); + home.addProvince(2); + map.unclaim(leader, home, 2); + assertEquals(List.of(1), home.getProvinces()); + home.addProvince(2); + map.unclaim(null, home, 2); + map.unclaim(null, home, 0); + map.unclaim(null, home, 8); + map.unclaim(null, home, 1); + assertEquals(List.of(1), home.getProvinces()); + verify(leader).sendMessage(contains("Cannot unclaim the capital")); + } + + @Test + void relocationRequiresANonBaseGuildAndADifferentValidLandCapital() { + Player outsider = fixture.player("Outsider"); + assertNull(map.getRelocationTarget(outsider)); + assertNull(map.getRelocationTarget(leader)); + Guild guild = fixture.guild(home, "merchant", "Merchant"); + Player merchant = fixture.player("Merchant"); + assertNull(map.getRelocationTarget(merchant)); + guild.setCapital(2, false); + rest.when(() -> RestServer.getProvince(merchant)).thenReturn(2); + assertNull(map.getRelocationTarget(merchant)); + rest.when(() -> RestServer.getProvince(merchant)).thenReturn(999); + assertNull(map.getRelocationTarget(merchant)); + fixture.provinceData.put(6, new Province(6, "SEA", 0)); + rest.when(() -> RestServer.getProvince(merchant)).thenReturn(6); + assertNull(map.getRelocationTarget(merchant)); + rest.when(() -> RestServer.getProvince(merchant)).thenReturn(1); + assertSame(home, map.getRelocationTarget(merchant)); + rest.when(() -> RestServer.getProvince(merchant)).thenReturn(2); + guild.setCapital(3, false); + assertSame(home, map.getRelocationTarget(merchant)); + rest.when(() -> RestServer.getProvince(merchant)).thenReturn(8); + assertNull(map.getRelocationTarget(merchant)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/map/MapPublicationCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/map/MapPublicationCoverageTest.java new file mode 100644 index 00000000..db94170a --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/map/MapPublicationCoverageTest.java @@ -0,0 +1,296 @@ +package net.tfminecraft.simplefactions.map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.google.gson.*; +import java.io.File; +import java.nio.file.*; +import java.util.*; +import java.util.logging.Logger; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.managers.*; +import net.tfminecraft.simplefactions.map.provinces.*; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.rest.RestServer; +import net.tfminecraft.simplefactions.testsupport.*; +import org.bukkit.Bukkit; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +/** Exercises actual disk payloads with the HTTP boundary and Bukkit task queues isolated. */ +class MapPublicationCoverageTest { + private FactionDomainFixture fixture; + private PersistenceFilesFixture files; + private MockedStatic<RestServer> rest; + private MapSystem map; + private Faction faction; + private final List<String> uploads = new ArrayList<>(); + private final List<String> regenerations = new ArrayList<>(); + private final List<JsonObject> sentQueues = new ArrayList<>(); + private boolean oldChronicle; + + @BeforeEach + void setup() throws Exception { + files = new PersistenceFilesFixture(); + fixture = new FactionDomainFixture(); + faction = fixture.saved("home", "Leader"); + faction.getOrCreateMainGuild(); + fixture.guild(faction, "merchants", "Trader"); + fixture.provinceData.put(7, new Province(7, "PLAINS", 50)); + files.write("Input/county.json", "{}"); + files.write("Input/duchy.json", "{}"); + files.write("Input/kingdom.json", "{}"); + files.write("Input/empire.json", "{}"); + Files.createDirectories(files.root.resolve("MapAPI")); + Files.createDirectories(files.root.resolve("Data")); + oldChronicle = Cache.chronicleEnabled; + Cache.chronicleEnabled = true; + rest = mockStatic(RestServer.class); + rest.when(() -> RestServer.upload(anyString(), any(File.class))) + .thenAnswer( + call -> { + String kind = call.getArgument(0); + File file = call.getArgument(1); + assertTrue(file.isFile(), kind + " payload must exist before upload"); + uploads.add(kind); + if (kind.equals("queue")) + sentQueues.add( + JsonParser.parseString(Files.readString(file.toPath())).getAsJsonObject()); + return null; + }); + rest.when(() -> RestServer.commenceRegen(anyString())) + .thenAnswer( + call -> { + regenerations.add(call.getArgument(0)); + return null; + }); + map = new MapSystem(); + } + + @AfterEach + void close() throws Exception { + try { + rest.close(); + Cache.chronicleEnabled = oldChronicle; + fixture.close(); + } finally { + files.close(); + } + } + + private JsonElement read(String name) throws Exception { + return JsonParser.parseString(Files.readString(files.root.resolve("MapAPI/" + name + ".json"))); + } + + private void completeUpload() { + List<Runnable> pending = new ArrayList<>(fixture.ui.asyncTasks); + fixture.ui.asyncTasks.clear(); + pending.forEach(Runnable::run); + fixture.ui.runTasks(); + } + + @Test + void mutationsDuringAnUploadRemainQueuedForTheNextPublication() throws Exception { + map.enqueue("nation", "first"); + map.enqueue("nation", "first"); + map.updateMap(); + assertEquals( + List.of("first"), + read("queue").getAsJsonObject().getAsJsonArray("nation").asList().stream() + .map(JsonElement::getAsString) + .toList()); + map.enqueue("nation", "next"); + map.updateMap(); + assertEquals( + 1, + fixture.ui.asyncTasks.size(), + "Do not overwrite the queue file while it is being uploaded"); + completeUpload(); + map.updateMap(); + completeUpload(); + assertEquals(2, sentQueues.size()); + assertTrue(sentQueues.get(1).has("nation")); + assertEquals("next", sentQueues.get(1).getAsJsonArray("nation").get(0).getAsString()); + assertEquals(List.of("queued", "queued"), regenerations); + } + + @Test + void repeatedMutationOfTheSameNationDuringUploadIsNotDropped() { + map.enqueue("nation", "same"); + map.updateMap(); + map.enqueue("nation", "same"); + completeUpload(); + map.updateMap(); + completeUpload(); + assertTrue( + sentQueues.get(1).has("nation"), + "A second change of the same nation still needs publication"); + } + + @Test + void quietCyclesPublishLiveDataAndOnlyShipNationWhenLeaderNamesChange() throws Exception { + fixture.provincesEnabled(true); + map.tick(); + assertEquals(1, fixture.ui.asyncTasks.size()); + completeUpload(); + assertEquals(List.of("province_data", "guilds", "chronicle"), uploads); + assertEquals(List.of("trade"), regenerations); + assertEquals( + 7, read("province_data").getAsJsonArray().get(0).getAsJsonObject().get("id").getAsInt()); + assertEquals(2, read("guilds").getAsJsonArray().size()); + uploads.clear(); + map.markLeaderNamesChanged(); + map.updateLiveData(); + completeUpload(); + assertTrue(uploads.contains("nation")); + assertTrue(read("nation").getAsJsonObject().has("home")); + uploads.clear(); + map.updateLiveData(); + completeUpload(); + assertFalse(uploads.contains("nation")); + for (int i = 0; i < 300; i++) map.tick(); + assertTrue(fixture.ui.asyncTasks.isEmpty()); + map.tick(); + assertEquals(1, fixture.ui.asyncTasks.size()); + completeUpload(); + } + + @Test + void disabledProvinceTicksDoNothingAndHourlyRefreshCannotBeStarvedByQueuedChanges() { + map.tick(); + assertTrue(fixture.ui.asyncTasks.isEmpty()); + fixture.provincesEnabled(true); + for (int i = 0; i < 3601; i++) { + map.enqueue("province", Integer.toString(i)); + map.tick(); + completeUpload(); + } + assertTrue( + sentQueues.getLast().getAsJsonArray("nation").asList().stream() + .anyMatch(e -> e.getAsString().equals(faction.getRGB()))); + assertEquals("queued", regenerations.getLast()); + } + + @Test + void fullRegenerationPersistsNationsAndUploadsOptionalRegionsOnlyWhenPresent() throws Exception { + map.fullRegen(); + assertTrue(uploads.isEmpty()); + completeUpload(); + assertEquals(List.of("fullregen"), regenerations); + assertFalse(uploads.contains("regions")); + assertEquals( + "home", read("nation").getAsJsonObject().getAsJsonObject("home").get("id").getAsString()); + files.write("Input/regions.json", "{}"); + uploads.clear(); + map.uploadAll(); + assertTrue( + uploads.containsAll( + List.of( + "province_data", + "guilds", + "chronicle", + "nation", + "map_markers", + "county", + "duchy", + "kingdom", + "empire", + "regions"))); + map.enqueue("nation", "old"); + map.clear(); + map.updateMap(); + completeUpload(); + assertEquals(0, sentQueues.getLast().size()); + } + + @ParameterizedTest + @ValueSource(strings = {"province_data", "guilds", "map_markers", "chronicle"}) + void failedPayloadExportLogsItsNameAndDoesNotThrow(String payload) throws Exception { + Logger logger = mock(Logger.class); + when(Bukkit.getLogger()).thenReturn(logger); + Files.createDirectory(files.root.resolve("MapAPI/" + payload + ".json")); + switch (payload) { + case "province_data" -> map.exportProvinces(); + case "guilds" -> map.exportGuilds(); + case "map_markers" -> map.exportMarkers(); + case "chronicle" -> map.exportChronicle(); + } + verify(logger).severe("[SimpleFactions] Failed to export " + payload + ".json"); + } + + @Test + void disabledChronicleDoesNotReplaceAnExistingExport() throws Exception { + files.write("MapAPI/chronicle.json", "sentinel"); + Cache.chronicleEnabled = false; + map.exportChronicle(); + assertEquals("sentinel", Files.readString(files.root.resolve("MapAPI/chronicle.json"))); + } + + @Test + void nationCompilerSkipsMalformedFilesWithoutLosingGoodUtf8Names() throws Exception { + files.write("Data/good.json", "{\"id\":\"realm\",\"name\":\"雪山\"}"); + files.write("Data/bad.json", "[bad"); + files.write("Data/ignored.txt", "not json"); + Compiler compiler = new Compiler(); + compiler.exportAllFactionsToNationJson(); + assertEquals(1, read("nation").getAsJsonObject().size()); + assertEquals( + "雪山", read("nation").getAsJsonObject().getAsJsonObject("realm").get("name").getAsString()); + files.remove("Data"); + compiler.exportAllFactionsToNationJson(); + assertEquals(0, read("nation").getAsJsonObject().size()); + Files.createDirectory(files.root.resolve("Data")); + compiler.exportAllFactionsToNationJson(); + assertEquals(0, read("nation").getAsJsonObject().size()); + files.remove("MapAPI/nation.json"); + Files.createDirectory(files.root.resolve("MapAPI/nation.json")); + assertDoesNotThrow(compiler::exportAllFactionsToNationJson); + Files.createDirectory(files.root.resolve("MapAPI/queue.json")); + assertDoesNotThrow(() -> compiler.exportQueue(new HashMap<>())); + } + + @Test + void occupationChangesQueueBothRealmsAndDeduplicateNationColors() throws Exception { + var data = fixture.data("other", "Other"); + data.rgb = "8,9,10"; + Faction other = fixture.saved(data); + other.getOrCreateMainGuild(); + var war = new net.tfminecraft.simplefactions.war.core.War(17, faction, other); + map.enqueueOccupationFromWar(null); + map.enqueueOccupationFromWar(war); + map.enqueueOccupationFromWar(war); + map.updateMap(); + completeUpload(); + assertEquals( + Set.of(faction.getRGB(), other.getRGB()), + new HashSet<>( + sentQueues.getFirst().getAsJsonArray("nation").asList().stream() + .map(JsonElement::getAsString) + .toList())); + assertEquals(2, sentQueues.getFirst().getAsJsonArray("nation").size()); + } + + @Test + void provincePayloadOmitsNegligibleTradeAndRetainsOccupationAndRoundedProduction() { + Province province = fixture.provinceData.get(7); + ProvinceDataEntry value = new ProvinceDataEntry(faction.getOrCreateMainGuild()); + value.setTrade(1.239); + value.setProduction(2.345); + ProvinceDataEntry tiny = new ProvinceDataEntry(faction.getOrCreateMainGuild()); + tiny.setTrade(.09); + province.getAllData().put("merchants", value); + province.getAllData().put("tiny", tiny); + JsonObject row = Compiler.provinceToJson(province, "occupier"); + assertEquals("occupier", row.get("occupied_by").getAsString()); + assertFalse(row.getAsJsonObject("trade").has("tiny")); + assertEquals( + 1.24, row.getAsJsonObject("trade").getAsJsonObject("merchants").get("trade").getAsDouble()); + assertEquals( + 2.35, + row.getAsJsonObject("trade").getAsJsonObject("merchants").get("production").getAsDouble()); + assertFalse(Compiler.provinceToJson(province, null).has("occupied_by")); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/map/export/MapExportLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/map/export/MapExportLifecycleCoverageTest.java new file mode 100644 index 00000000..f63d6eea --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/map/export/MapExportLifecycleCoverageTest.java @@ -0,0 +1,348 @@ +package net.tfminecraft.simplefactions.map.export; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.tfminecraft.denareconomy.DenarEconomy; +import net.tfminecraft.denareconomy.enums.Accounts; +import net.tfminecraft.denareconomy.managers.MoneyManager; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.installation.Installation; +import net.tfminecraft.simplefactions.installation.InstallationKind; +import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader; +import net.tfminecraft.simplefactions.loaders.RankLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.ProvinceManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.Modifier; +import net.tfminecraft.simplefactions.settlement.Settlement; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.war.campaign.progression.CampaignCoalitionService.CampaignCoalition; +import net.tfminecraft.simplefactions.war.campaign.schedule.ScheduledCampaignBattle; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.enums.CampaignBattleKind; +import net.tfminecraft.simplefactions.war.enums.WarGoalType; +import net.tfminecraft.simplefactions.war.enums.WarType; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +class MapExportLifecycleCoverageTest { + @TempDir Path temporary; + private FactionDomainFixture fixture; + private MockedStatic<DenarEconomy> economy; + private MoneyManager money; + private ProvinceManager provinces; + private Map<Object, Object> installationKinds; + private final Map<Field, Object> globals = new LinkedHashMap<>(); + + @BeforeEach + void setup() throws Exception { + fixture = new FactionDomainFixture(); + economy = mockStatic(DenarEconomy.class); + money = mock(MoneyManager.class); + economy.when(DenarEconomy::getMoneyManager).thenReturn(money); + when(money.getServerBal(Accounts.POUCH)).thenReturn(25.5); + when(money.getServerBal(Accounts.BANK)).thenReturn(35.75); + installationKinds = new LinkedHashMap<>(kindMap()); + remember(InstallationConfigLoader.class, "consentProximityBlocks"); + remember(InstallationConfigLoader.class, "transferRequestTimeoutSeconds"); + YamlConfiguration config = new YamlConfiguration(); + config.set("consent-proximity-blocks", 20); + config.set("transfer-request-timeout-seconds", 60); + for (InstallationKind kind : InstallationKind.values()) { + String key = kind.getCommandName(); + config.set(key + ".daily-upkeep", 1); + config.set(key + ".construction-time", 1); + config.set(key + ".radius", 2); + config.createSection(key + ".slots"); + } + Path configFile = temporary.resolve("installations.yml"); + config.save(configFile.toFile()); + InstallationConfigLoader.load(configFile.toFile()); + for (String name : + List.of("mapRef", "chapterId", "chapterName", "settlementLargePopulationThreshold")) + remember(Cache.class, name); + Cache.mapRef = "test-map"; + Cache.chapterId = "riverlands"; + Cache.chapterName = "Riverlands"; + Cache.settlementLargePopulationThreshold = 1; + provinces = new ProvinceManager(); + Map<Integer, Province> data = new LinkedHashMap<>(); + for (int id = 1; id <= 8; id++) data.put(id, new Province(id, "PLAINS", 50, id * 10, id * 20)); + data.put(9, new Province(9, "SEA", 0)); + provinces.start(data); + when(fixture.ui.plugin.getProvinceManager()).thenReturn(provinces); + } + + @AfterEach + void close() throws Exception { + kindMap().clear(); + kindMap().putAll(installationKinds); + for (var entry : globals.entrySet()) entry.getKey().set(null, entry.getValue()); + economy.close(); + fixture.close(); + } + + @Test + void publishedMarkersContainSettlementsInstallationsFortControlAndActiveWar() throws Exception { + WorldState state = worldState(); + Path output = temporary.resolve("nested/markers.json"); + Markers.export(output.toFile()); + JsonObject root = JsonParser.parseString(Files.readString(output)).getAsJsonObject(); + assertEquals("test-map", root.get("map_id").getAsString()); + assertEquals("riverlands", root.get("chapter_id").getAsString()); + assertEquals("Riverlands", root.get("chapter_name").getAsString()); + assertDoesNotThrow(() -> Instant.parse(root.get("exported_at").getAsString())); + assertEquals(1, root.get("settlement_large_population_threshold").getAsInt()); + JsonObject capital = find(root.getAsJsonArray("settlements"), "capital"); + assertEquals("faction_capital", capital.get("kind").getAsString()); + assertEquals("home", capital.get("faction_id").getAsString()); + assertEquals(1, capital.get("population").getAsInt()); + assertEquals("small", capital.get("marker_size").getAsString()); + assertEquals(10, capital.get("center_x").getAsInt()); + assertEquals(20, capital.get("center_z").getAsInt()); + assertEquals(List.of(1), ints(capital.getAsJsonArray("provinces"))); + JsonObject town = find(root.getAsJsonArray("settlements"), "town"); + assertEquals("settlement", town.get("kind").getAsString()); + assertEquals("large", town.get("marker_size").getAsString()); + assertEquals(2, town.get("population").getAsInt()); + assertEquals(4, root.getAsJsonArray("installations").size()); + assertEquals( + "fort", find(root.getAsJsonArray("installations"), "fort").get("kind").getAsString()); + assertEquals(1, find(root.getAsJsonArray("installations"), "station").get("level").getAsInt()); + JsonObject fort = find(root.getAsJsonArray("forts"), "fort"); + assertEquals(List.of(2, 4), ints(fort.getAsJsonArray("zoc_provinces"))); + JsonObject war = root.getAsJsonArray("wars").get(0).getAsJsonObject(); + assertEquals("7", war.get("id").getAsString()); + assertEquals(List.of(1, 2, 4), ints(war.getAsJsonArray("campaign_provinces"))); + assertEquals(10, war.getAsJsonObject("attacker_capital").get("center_x").getAsInt()); + assertEquals(4, war.getAsJsonObject("defender_capital").get("province_id").getAsInt()); + assertFalse(war.getAsJsonObject("defender_capital").has("center_x")); + assertSame(state.home, state.traders.getFaction()); + assertEquals(150, state.home.getWealth(), 1e-9); + } + + @Test + void chronicleReflectsRealSubjectsGuildWealthPopulationAndWarMembership() throws Exception { + WorldState state = worldState(); + state.home.setRank(RankLoader.getByLevel(2)); + state.home.setPrestigeModifiers( + new ArrayList<>( + Arrays.asList( + null, new Modifier(null, 2.0, false), new Modifier("Historic Award", 12.0, true)))); + Instant captured = Instant.parse("2026-10-07T00:00:00Z"); + JsonObject root = ChronicleSnapshot.build(FactionManager.factions, 8, 900, captured); + assertEquals(captured.toString(), root.get("captured_at").getAsString()); + assertTrue(root.get("complete").getAsBoolean()); + assertEquals(8, root.get("server_day").getAsInt()); + assertEquals(900, root.get("day_progress_seconds").getAsInt()); + JsonObject global = root.getAsJsonObject("global"); + assertEquals(3, global.get("faction_count").getAsInt()); + assertEquals(4, global.get("guild_count").getAsInt()); + assertEquals(25.5, global.get("pouch_wealth").getAsDouble()); + assertEquals(35.75, global.get("player_bank_wealth").getAsDouble()); + assertEquals(1, global.get("active_wars").getAsInt()); + JsonObject home = find(root.getAsJsonArray("factions"), "home"); + assertEquals(List.of("subject"), strings(home.getAsJsonArray("subjects"))); + assertEquals(List.of("7"), strings(home.getAsJsonArray("wars"))); + assertEquals(2, home.get("settlements").getAsInt()); + assertEquals(3, home.get("population").getAsInt()); + assertEquals(4, home.get("installations").getAsInt()); + assertEquals(1, home.get("forts").getAsInt()); + assertEquals(50, home.getAsJsonObject("wealth_breakdown").get("traders").getAsDouble()); + assertEquals( + 12, home.getAsJsonObject("prestige_breakdown").get("Historic Award").getAsDouble()); + assertEquals(9500, home.get("rank_down_at").getAsDouble()); + JsonObject subject = find(root.getAsJsonArray("factions"), "subject"); + assertEquals("home", subject.get("overlord").getAsString()); + assertEquals(List.of("7"), strings(subject.getAsJsonArray("wars"))); + assertEquals(50, find(root.getAsJsonArray("guilds"), "traders").get("bank").getAsDouble()); + assertEquals(0, root.getAsJsonArray("events").size()); + assertDoesNotThrow(() -> new Gson().toJson(root)); + state.home.setPrestigeModifiers(new ArrayList<>()); + Path output = temporary.resolve("live/chronicle.json"); + ChronicleExport.export(output.toFile()); + JsonObject written = JsonParser.parseString(Files.readString(output)).getAsJsonObject(); + assertEquals(3, written.getAsJsonArray("factions").size()); + assertEquals(4, written.getAsJsonArray("guilds").size()); + assertEquals(150, state.home.getWealth(), 1e-9); + } + + @Test + void absentOrFailingEconomyDegradesOnlyItsAggregateFigures() { + Faction home = fixture.saved("home", "Alice"); + assertNull(WarMapExporter.exportCapitalCoords(home)); + home.getBank().deposit(25.0); + economy.when(DenarEconomy::getMoneyManager).thenReturn(null); + JsonObject root = ChronicleSnapshot.build(FactionManager.factions, 1, 0, Instant.EPOCH); + assertEquals(0, root.getAsJsonObject("global").get("pouch_wealth").getAsDouble()); + assertEquals(0, root.getAsJsonObject("global").get("player_bank_wealth").getAsDouble()); + assertEquals(25, find(root.getAsJsonArray("factions"), "home").get("bank").getAsDouble()); + economy + .when(DenarEconomy::getMoneyManager) + .thenThrow(new LinkageError("economy plugin unavailable")); + JsonObject failed = ChronicleSnapshot.build(FactionManager.factions, 1, 0, Instant.EPOCH); + assertEquals(0, failed.getAsJsonObject("global").get("pouch_wealth").getAsDouble()); + assertEquals(25, find(failed.getAsJsonArray("factions"), "home").get("wealth").getAsDouble()); + } + + @Test + void fortExportHandlesMissingLocationsUnownedNeighborsAndAmbiguousWars() { + WorldState state = worldState(); + assertEquals(List.of(), ZocRealm.computeZocProvinces(state.home, 999)); + assertEquals(List.of(), ZocRealm.computeZocProvincesForExport(null, state.home, List.of())); + assertSame(state.home, ZocRealm.resolveExportControllerFaction(null, state.home, List.of())); + assertNull(ZocRealm.resolveExportControllerFaction(state.fort, null, List.of())); + assertNull(ZocRealm.selectPrimaryWarForFort(state.fort, null)); + War unrelated = new War(8, state.home, state.enemy); + unrelated.setCampaignProvinces(List.of(6, 7)); + unrelated.setCampaignBattleSchedule( + List.of(new ScheduledCampaignBattle(6, CampaignBattleKind.SIEGE, true, "other-fort"))); + assertNull(ZocRealm.selectPrimaryWarForFort(state.fort, Arrays.asList(null, unrelated))); + assertEquals( + List.of(2), + ZocRealm.computeZocProvincesForExport(state.fort, state.home, List.of(unrelated))); + War scheduled = new War(9, state.home, state.enemy); + scheduled.setCampaignProvinces(List.of(6, 7)); + scheduled.setCampaignBattleSchedule( + List.of(new ScheduledCampaignBattle(2, CampaignBattleKind.SIEGE, false, "fort"))); + assertSame( + scheduled, ZocRealm.selectPrimaryWarForFort(state.fort, List.of(unrelated, scheduled))); + assertSame( + state.home, + ZocRealm.resolveExportControllerFaction(state.fort, state.home, List.of(scheduled))); + Installation unnamed = + new Installation("", "Unnamed import", InstallationKind.FORT, 2, 20, 40, 0); + assertSame( + state.home, + ZocRealm.resolveExportControllerFaction(unnamed, state.home, List.of(state.war))); + assertNull(ZocRealm.selectPrimaryWarForFort(unnamed, List.of(unrelated))); + assertTrue(OccupationMapExport.occupiedProvinceIds(null).isEmpty()); + assertTrue(OccupationMapExport.occupierByProvince(null).isEmpty()); + assertTrue(OccupationMapExport.nationRgbsToEnqueue(null, null).isEmpty()); + assertTrue(WarMapExporter.exportWars(null).isEmpty()); + assertNull(WarMapExporter.exportCapitalCoords(null)); + state.war.setOccupiedByAttacker(Arrays.asList(null, 2, 4)); + assertEquals( + Map.of(2, "home", 4, "home"), OccupationMapExport.occupierByProvince(List.of(state.war))); + } + + @Test + void legacyWarWithoutPushTargetStillExportsTheInvasionDirection() { + WorldState state = worldState(); + state.war.setPushTarget(null); + JsonObject row = WarMapExporter.exportWars(List.of(state.war)).get(0).getAsJsonObject(); + assertEquals("toward_objective", row.get("push_target").getAsString()); + assertEquals("7", row.get("id").getAsString()); + assertEquals(List.of("home", "subject", "enemy"), strings(row.getAsJsonArray("belligerents"))); + } + + @Test + void occupationRefreshSkipsMissingProvinceIdsWhileRetainingValidOwners() { + WorldState state = worldState(); + state.enemy.setRGB("4,5,6"); + state.war.setOccupiedByAttacker(Arrays.asList(null, 2, 4, 5)); + assertEquals(Set.of(2, 4, 5), OccupationMapExport.occupiedProvinceIds(state.war)); + assertEquals( + List.of("1,2,3", "4,5,6"), + OccupationMapExport.nationRgbsToEnqueue(state.war, id -> provinces.get(id).getOwner())); + assertEquals( + Map.of(2, "home", 4, "home", 5, "home"), + OccupationMapExport.occupierByProvince(List.of(state.war))); + } + + private WorldState worldState() { + Faction home = fixture.saved("home", "Alice"); + Faction enemy = fixture.saved("enemy", "Eve"); + Faction subject = fixture.saved("subject", "Sam"); + home.addProvince(1); + home.addProvince(2); + enemy.addProvince(4); + subject.addProvince(3); + home.setCapital(1, true, false); + enemy.setCapital(4, true, false); + fixture.subject(home, subject); + Guild traders = fixture.guild(home, "traders", "Bob"); + traders.setCapital(2); + traders.addMember("Charlie"); + home.getBank().deposit(100.0); + traders.getBank().deposit(50.0); + home.getSettlementHandler().acceptTransferred(new Settlement("capital", "Capital", 1, 10, 20)); + home.getSettlementHandler().acceptTransferred(new Settlement("town", "Town", 2, 20, 40)); + Installation fort = new Installation("fort", "Fort", InstallationKind.FORT, 2, 20, 40, 1); + home.getInstallationHandler().acceptTransferred(fort); + home.getInstallationHandler() + .acceptTransferred(new Installation("port", "Port", InstallationKind.PORT, 1, 10, 20, 2)); + home.getInstallationHandler() + .acceptTransferred( + new Installation("air", "Airfield", InstallationKind.AIRPORT, 1, 10, 20, 3)); + home.getInstallationHandler() + .acceptTransferred( + new Installation("station", "Station", InstallationKind.TRAIN_STATION, 1, 10, 20, 4)); + for (int id : List.of(4, 5, 9, 999)) provinces.get(2).addNeighbour(id); + War war = new War(7, home, enemy); + war.setWarType(WarType.SUBJUGATE); + war.setGoal(WarGoalType.SUBJUGATE); + war.setCampaignProvinces(List.of(1, 2, 4)); + war.setObjectiveProvinceId(4); + war.putFortController("fort", CampaignCoalition.DEFENDER); + WarManager.get().add(war); + return new WorldState(home, enemy, subject, traders, fort, war); + } + + private static JsonObject find(JsonArray rows, String id) { + for (var row : rows) + if (row.getAsJsonObject().get("id").getAsString().equals(id)) return row.getAsJsonObject(); + fail("Missing exported row " + id); + return null; + } + + private static List<Integer> ints(JsonArray values) { + List<Integer> out = new ArrayList<>(); + values.forEach(value -> out.add(value.getAsInt())); + return out; + } + + private static List<String> strings(JsonArray values) { + List<String> out = new ArrayList<>(); + values.forEach(value -> out.add(value.getAsString())); + return out; + } + + private void remember(Class<?> owner, String name) throws Exception { + Field field = owner.getDeclaredField(name); + field.setAccessible(true); + globals.put(field, field.get(null)); + } + + @SuppressWarnings("unchecked") + private Map<Object, Object> kindMap() throws Exception { + Field field = InstallationConfigLoader.class.getDeclaredField("byKind"); + field.setAccessible(true); + return (Map<Object, Object>) field.get(null); + } + + private record WorldState( + Faction home, Faction enemy, Faction subject, Guild traders, Installation fort, War war) {} +} diff --git a/src/test/java/net/tfminecraft/simplefactions/map/presence/PresenceLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/map/presence/PresenceLifecycleCoverageTest.java new file mode 100644 index 00000000..5d44ce0f --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/map/presence/PresenceLifecycleCoverageTest.java @@ -0,0 +1,435 @@ +package net.tfminecraft.simplefactions.map.presence; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.lang.reflect.Field; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.GZIPOutputStream; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.events.*; +import net.tfminecraft.simplefactions.loaders.RegionLoader; +import net.tfminecraft.simplefactions.loaders.TierLoader; +import net.tfminecraft.simplefactions.loaders.TitleLoader; +import net.tfminecraft.simplefactions.managers.ProvinceManager; +import net.tfminecraft.simplefactions.map.ProvinceGrid; +import net.tfminecraft.simplefactions.map.ProvinceSpatial; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.tiers.Title; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.player.PlayerQuitEvent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PresenceLifecycleCoverageTest { + @TempDir Path temporary; + private FactionDomainFixture fixture; + private final Map<Field, Object> globals = new LinkedHashMap<>(); + private final List<Event> events = new ArrayList<>(); + private ProvincePresenceService previousProvinces; + private TitlePresenceService previousTitles; + private RegionPresenceService previousRegions; + + @BeforeEach + void setup() throws Exception { + fixture = new FactionDomainFixture(); + previousProvinces = ProvincePresenceService.getInstance(); + previousTitles = TitlePresenceService.getInstance(); + previousRegions = RegionPresenceService.getInstance(); + for (String name : List.of("regions", "byId", "byProvince")) remember(RegionLoader.class, name); + for (String name : + List.of("mapEnabled", "portSeaProximityBlocks", "battleProvincePollIntervalTicks")) + remember(Cache.class, name); + Cache.mapEnabled = true; + fixture.provincesEnabled(true); + ProvincePresenceService.resetForTests(); + TitlePresenceService.resetForTests(); + RegionPresenceService.resetForTests(); + var plugins = Bukkit.getPluginManager(); + doAnswer( + call -> { + events.add(call.getArgument(0)); + return null; + }) + .when(plugins) + .callEvent(any(Event.class)); + when(Bukkit.getOnlinePlayers()).thenAnswer(call -> fixture.online.values()); + ProvinceManager manager = new ProvinceManager(); + manager.start( + Map.of( + 1, + new Province(1, "PLAINS", 50), + 2, + new Province(2, "SEA", 0), + 3, + new Province(3, "PLAINS", 50))); + when(fixture.ui.plugin.getProvinceManager()).thenReturn(manager); + grid(new int[][] {{1, 2, 0}, {3, 1, 9}}); + } + + @AfterEach + void close() throws Exception { + ProvincePresenceService.setInstance(previousProvinces); + TitlePresenceService.setInstance(previousTitles); + RegionPresenceService.setInstance(previousRegions); + for (var entry : globals.entrySet()) entry.getKey().set(null, entry.getValue()); + fixture.close(); + } + + @Test + void largeRadiusDoesNotWrapAndIncludeSeaOutsideTheCircle() throws Exception { + grid(new int[][] {{2}}); + assertFalse( + ProvinceSpatial.withinBlocksOfSea(65536, 65536, 65536), + "The only sea cell is sqrt(2) radii away; integer squares must not wrap to zero"); + assertTrue( + ProvinceSpatial.withinBlocksOfSea(65536, 0, 65536), + "A point exactly on the large circle remains included"); + assertFalse( + ProvinceSpatial.withinBlocksOfSea(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE)); + assertTrue(ProvinceSpatial.withinBlocksOfSea(0, 0, Integer.MAX_VALUE)); + } + + @Test + void crossingIntoAnotherWorldLeavesTheMappedProvinceAndItsTitles() throws Exception { + titlesAndRegions(); + Player player = player("Alice"); + AtomicReference<Location> location = + new AtomicReference<>(new Location(fixture.ui.world, 0, 64, 0)); + when(player.getLocation()).thenAnswer(call -> location.get()); + ProvincePresenceService service = ProvincePresenceService.getInstance(); + service.tick(); + events.clear(); + World other = mock(World.class); + when(other.getName()).thenReturn("world_nether"); + location.set(new Location(other, 0, 64, 0)); + service.tick(); + assertEquals(ProvincePresenceService.UNKNOWN_PROVINCE, service.getCurrentProvince(player)); + assertEquals(1, events.stream().filter(PlayerProvinceLeaveEvent.class::isInstance).count()); + assertEquals(2, events.stream().filter(PlayerTitleLeaveEvent.class::isInstance).count()); + assertEquals(1, events.stream().filter(PlayerLeaveRegionEvent.class::isInstance).count()); + } + + @Test + void defaultServicesPublishOrderedTransitionsAndQuitClearsEveryPresenceLayer() throws Exception { + titlesAndRegions(); + Player player = player("Alice"); + AtomicReference<Location> location = + new AtomicReference<>(new Location(fixture.ui.world, 0, 64, 0)); + when(player.getLocation()).thenAnswer(call -> location.get()); + ProvincePresenceService service = ProvincePresenceService.getInstance(); + service.tick(); + assertTrue(service.isInProvince(player, 1)); + assertEquals( + List.of( + PlayerProvinceEnterEvent.class, + PlayerTitleEnterEvent.class, + PlayerTitleEnterEvent.class, + PlayerEnterRegionEvent.class), + eventTypes()); + var enter = (PlayerProvinceEnterEvent) events.getFirst(); + assertSame(player, enter.getPlayer()); + assertEquals(1, enter.getProvinceId()); + assertNull(enter.getPreviousProvinceId()); + assertSame(PlayerProvinceEnterEvent.getHandlerList(), enter.getHandlers()); + var title = (PlayerTitleEnterEvent) events.get(1); + assertSame(player, title.getPlayer()); + assertEquals("county", title.getTierId()); + assertEquals("west", title.getTitleId()); + assertEquals("west", title.getTitleName()); + assertNull(title.getPreviousTitleId()); + assertSame(PlayerTitleEnterEvent.getHandlerList(), title.getHandlers()); + var region = (PlayerEnterRegionEvent) events.getLast(); + assertSame(player, region.getPlayer()); + assertEquals("west", region.getRegionId()); + assertEquals("Western March", region.getRegionName()); + assertNull(region.getPreviousRegionId()); + assertSame(PlayerEnterRegionEvent.getHandlerList(), region.getHandlers()); + events.clear(); + service.tick(); + assertTrue(events.isEmpty()); + location.set(new Location(fixture.ui.world, 0, 64, 1)); + service.tick(); + assertEquals( + List.of( + PlayerProvinceLeaveEvent.class, + PlayerProvinceEnterEvent.class, + PlayerTitleLeaveEvent.class, + PlayerTitleEnterEvent.class, + PlayerLeaveRegionEvent.class, + PlayerEnterRegionEvent.class), + eventTypes()); + assertEquals(3, service.getCurrentProvince(player.getUniqueId())); + var departure = (PlayerProvinceLeaveEvent) events.getFirst(); + assertSame(player, departure.getPlayer()); + assertEquals(1, departure.getProvinceId()); + assertEquals(3, departure.getNextProvinceId()); + assertSame(PlayerProvinceLeaveEvent.getHandlerList(), departure.getHandlers()); + assertNotSame(enter.getHandlers(), departure.getHandlers()); + var arrival = (PlayerProvinceEnterEvent) events.get(1); + assertEquals(1, arrival.getPreviousProvinceId()); + assertEquals(3, arrival.getProvinceId()); + var oldTitle = (PlayerTitleLeaveEvent) events.get(2); + assertSame(player, oldTitle.getPlayer()); + assertEquals("county", oldTitle.getTierId()); + assertEquals("west", oldTitle.getTitleId()); + assertEquals("east", oldTitle.getNextTitleId()); + assertSame(PlayerTitleLeaveEvent.getHandlerList(), oldTitle.getHandlers()); + assertNotSame(title.getHandlers(), oldTitle.getHandlers()); + var newTitle = (PlayerTitleEnterEvent) events.get(3); + assertEquals("east", newTitle.getTitleId()); + assertEquals("west", newTitle.getPreviousTitleId()); + var oldRegion = (PlayerLeaveRegionEvent) events.get(4); + assertSame(player, oldRegion.getPlayer()); + assertEquals("west", oldRegion.getRegionId()); + assertEquals("east", oldRegion.getNextRegionId()); + assertSame(PlayerLeaveRegionEvent.getHandlerList(), oldRegion.getHandlers()); + assertNotSame(region.getHandlers(), oldRegion.getHandlers()); + var newRegion = (PlayerEnterRegionEvent) events.get(5); + assertEquals("east", newRegion.getRegionId()); + assertEquals("west", newRegion.getPreviousRegionId()); + events.clear(); + new ProvincePresenceListener().onPlayerQuit(new PlayerQuitEvent(player, "left")); + assertEquals( + List.of( + PlayerProvinceLeaveEvent.class, + PlayerTitleLeaveEvent.class, + PlayerTitleLeaveEvent.class, + PlayerLeaveRegionEvent.class), + eventTypes()); + assertEquals(3, ((PlayerProvinceLeaveEvent) events.getFirst()).getProvinceId()); + assertNull(((PlayerProvinceLeaveEvent) events.getFirst()).getNextProvinceId()); + assertNull(((PlayerTitleLeaveEvent) events.get(1)).getNextTitleId()); + assertNull(((PlayerTitleLeaveEvent) events.get(2)).getNextTitleId()); + assertEquals("east", ((PlayerLeaveRegionEvent) events.getLast()).getRegionId()); + assertNull(((PlayerLeaveRegionEvent) events.getLast()).getNextRegionId()); + assertEquals(ProvincePresenceService.UNKNOWN_PROVINCE, service.getCurrentProvince(player)); + events.clear(); + new ProvincePresenceListener().onPlayerQuit(new PlayerQuitEvent(player, "left")); + assertTrue(events.isEmpty()); + } + + @Test + void spatialQueriesUseCircularDistanceMapBoundsAndKnownSeaProvinces() { + assertFalse(ProvinceSpatial.isSeaAt(0, 0)); + assertTrue(ProvinceSpatial.isSeaAt(1, 0)); + assertFalse(ProvinceSpatial.isSeaAt(2, 0)); + assertFalse(ProvinceSpatial.isSeaAt(2, 1)); + assertFalse(ProvinceSpatial.isSeaAt(-1, 0)); + assertTrue(ProvinceSpatial.withinBlocksOfSea(0, 0, 1)); + assertFalse(ProvinceSpatial.withinBlocksOfSea(0, 1, 1)); + assertFalse(ProvinceSpatial.withinBlocksOfSea(0, 0, 0)); + assertFalse(ProvinceSpatial.withinBlocksOfSea(2, 0, 0)); + assertFalse(ProvinceSpatial.withinBlocksOfSea(0, 0, -1)); + assertFalse(ProvinceSpatial.withinBlocksOfSea(-4, -4, 1)); + Cache.portSeaProximityBlocks = 1; + assertTrue(ProvinceSpatial.withinConfiguredPortSeaProximity(0, 0)); + when(fixture.ui.plugin.getProvinceGrid()).thenReturn(null); + assertFalse(ProvinceSpatial.isSeaAt(1, 0)); + assertFalse(ProvinceSpatial.withinBlocksOfSea(0, 0, 1)); + } + + @Test + void pollingStartsAtAValidIntervalAndDefersPresenceUntilItsScheduledCallback() { + Player player = player("Alice"); + when(player.getLocation()).thenReturn(new Location(fixture.ui.world, 0, 64, 0)); + Cache.battleProvincePollIntervalTicks = 0; + ProvincePresenceTickService.start(); + verify(fixture.ui.scheduler) + .runTaskTimer(eq(fixture.ui.plugin), any(Runnable.class), eq(1L), eq(1L)); + assertTrue(events.isEmpty()); + assertEquals(1, fixture.ui.repeatingTasks.size()); + fixture.ui.repeatingTasks.getFirst().run(); + assertTrue(ProvincePresenceService.getInstance().isInProvince(player, 1)); + assertEquals(1, events.stream().filter(PlayerProvinceEnterEvent.class::isInstance).count()); + fixture.ui.repeatingTasks.getFirst().run(); + assertEquals(1, events.size(), "Stationary players produce no repeated entry events"); + } + + @Test + void absentOfflineAndUnresolvedPlayersDoNotPublishSpuriousEvents() { + Player player = player("Alice"); + when(player.getLocation()).thenReturn(new Location(fixture.ui.world, 0, 64, 0)); + ProvincePresenceService service = ProvincePresenceService.getInstance(); + assertEquals( + ProvincePresenceService.UNKNOWN_PROVINCE, service.getCurrentProvince((Player) null)); + assertEquals(ProvincePresenceService.UNKNOWN_PROVINCE, service.getCurrentProvince((UUID) null)); + service.tick((List<Player>) null); + service.handleQuit((Player) null); + service.handleQuit((UUID) null); + TitlePresenceService.getInstance().applyProvince((Player) null, 1); + TitlePresenceService.getInstance().applyProvince((UUID) null, 1); + TitlePresenceService.getInstance().handleQuit((Player) null); + TitlePresenceService.getInstance().handleQuit((UUID) null); + TitlePresenceService.getInstance().handleQuit(UUID.randomUUID()); + RegionPresenceService.getInstance().applyProvince((Player) null, 1); + RegionPresenceService.getInstance().applyProvince((UUID) null, 1); + RegionPresenceService.getInstance().handleQuit((Player) null); + RegionPresenceService.getInstance().handleQuit((UUID) null); + when(player.isOnline()).thenReturn(false); + service.tick(Arrays.asList(null, player)); + assertTrue(events.isEmpty()); + when(player.isOnline()).thenReturn(true); + SimpleFactions.plugin = null; + service.tick(); + assertTrue(events.isEmpty()); + SimpleFactions.plugin = fixture.ui.plugin; + when(Bukkit.getServer()).thenReturn(null); + service.tick(); + assertTrue(events.isEmpty()); + when(Bukkit.getServer()).thenReturn(fixture.ui.server); + when(Bukkit.getPlayer(player.getUniqueId())).thenReturn(null); + service.tick(); + service.handleQuit(player); + assertEquals(ProvincePresenceService.UNKNOWN_PROVINCE, service.getCurrentProvince(player)); + assertTrue(events.isEmpty()); + } + + @Test + void injectableEntryOnlyCallbacksCanIgnoreDepartureAndResolveMissingTitles() { + Player player = player("Alice"); + List<String> entries = new ArrayList<>(); + ProvincePresenceService provinces = + new ProvincePresenceService( + p -> 1, (id, province, previous) -> entries.add("province:" + province)); + ProvincePresenceService.setInstance(provinces); + assertSame(provinces, ProvincePresenceService.getInstance()); + provinces.tick(List.of(player)); + provinces.handleQuit(player); + assertEquals(ProvincePresenceService.UNKNOWN_PROVINCE, provinces.getCurrentProvince(player)); + TitlePresenceService titles = + new TitlePresenceService( + province -> + province == 1 + ? Map.of("county", new TitlePresenceResolver.ResolvedTitle("west", "West")) + : null, + (id, tier, title, name, previous) -> entries.add("title:" + title)); + TitlePresenceService.setInstance(titles); + assertSame(titles, TitlePresenceService.getInstance()); + titles.applyProvince(player, 1); + titles.applyProvince(player, 2); + titles.handleQuit(player); + RegionPresenceService regions = + new RegionPresenceService( + province -> + province == 1 + ? new net.tfminecraft.simplefactions.map.MapRegion( + "west", "West", java.util.Set.of(1)) + : null, + (id, region, name, previous) -> entries.add("region:" + region)); + RegionPresenceService.setInstance(regions); + assertSame(regions, RegionPresenceService.getInstance()); + regions.applyProvince(player, 1); + regions.applyProvince(player, 1); + regions.applyProvince(player, 2); + regions.handleQuit(player); + assertEquals(List.of("province:1", "title:west", "region:west"), entries); + ProvincePresenceService.setInstance(null); + TitlePresenceService.setInstance(null); + RegionPresenceService.setInstance(null); + assertNotSame(provinces, ProvincePresenceService.getInstance()); + assertNotSame(titles, TitlePresenceService.getInstance()); + assertNotSame(regions, RegionPresenceService.getInstance()); + assertEquals( + ProvincePresenceService.UNKNOWN_PROVINCE, + ProvincePresenceService.getInstance().getCurrentProvince(player)); + } + + @Test + void titleResolutionKeepsTheFirstTierUsesIdFallbackAndReturnsAnImmutableHierarchy() { + JsonObject unnamed = new JsonObject(); + JsonArray provinceIds = new JsonArray(); + provinceIds.add(1); + unnamed.add("provinces", provinceIds); + Title county = new Title(TierLoader.getByString("county"), "unnamed", unnamed); + TitleLoader.getTitles().add(county); + JsonObject parent = new JsonObject(); + parent.addProperty("name", "Higher County"); + JsonArray children = new JsonArray(); + children.add("unnamed"); + parent.add("titles", children); + TitleLoader.getTitles().add(new Title(TierLoader.getByString("county"), "parent", parent)); + assertEquals( + Map.of("county", new TitlePresenceResolver.ResolvedTitle("unnamed", "unnamed")), + TitlePresenceResolver.resolve(1)); + assertThrows( + UnsupportedOperationException.class, () -> TitlePresenceResolver.resolve(1).clear()); + assertTrue(TitlePresenceResolver.resolve(ProvincePresenceService.UNKNOWN_PROVINCE).isEmpty()); + assertTrue(TitlePresenceResolver.resolve(999).isEmpty()); + JsonObject legacy = new JsonObject(); + JsonArray unknown = new JsonArray(); + unknown.add(2); + legacy.add("provinces", unknown); + TitleLoader.getTitles().add(new Title(null, "legacy", legacy)); + assertTrue( + TitlePresenceResolver.resolve(2).isEmpty(), + "A title with an unavailable tier does not invent a tier event"); + } + + private Player player(String name) { + Player player = fixture.player(name); + when(Bukkit.getPlayer(player.getUniqueId())).thenReturn(player); + return player; + } + + private void titlesAndRegions() throws Exception { + fixture.title("west", "county", 1); + fixture.title("east", "county", 3); + JsonObject duchy = new JsonObject(); + duchy.addProperty("name", "Both Counties"); + JsonArray children = new JsonArray(); + children.add("west"); + children.add("east"); + duchy.add("titles", children); + TitleLoader.getTitles().add(new Title(TierLoader.getByString("duchy"), "both", duchy)); + Path path = temporary.resolve("regions.json"); + Files.writeString( + path, + "{\"west\":{\"name\":\"Western March\",\"provinces\":[1]},\"east\":{\"name\":\"Eastern" + + " March\",\"provinces\":[3]}}"); + RegionLoader.loadAll(path.toFile()); + } + + private List<Class<?>> eventTypes() { + return events.stream().<Class<?>>map(Event::getClass).toList(); + } + + private void grid(int[][] ids) throws Exception { + int height = ids.length, width = ids[0].length; + ByteBuffer buffer = ByteBuffer.allocate(8 + width * height * 2).order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(width).putInt(height); + for (int[] row : ids) for (int id : row) buffer.putShort((short) id); + Path path = temporary.resolve("grid.bin.gz"); + try (GZIPOutputStream stream = new GZIPOutputStream(Files.newOutputStream(path))) { + stream.write(buffer.array()); + } + ProvinceGrid grid = ProvinceGrid.load(path.toFile()); + when(fixture.ui.plugin.getProvinceGrid()).thenReturn(grid); + } + + private void remember(Class<?> owner, String name) throws Exception { + Field field = owner.getDeclaredField(name); + field.setAccessible(true); + globals.put(field, field.get(null)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryReputationTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryReputationTest.java index bebadc6b..58ce5479 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryReputationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryReputationTest.java @@ -183,4 +183,11 @@ private MercenaryContract contract(int slots, int days, boolean clean, int daysS when(contract.hasCleanAttendance()).thenReturn(clean); return contract; } + + @Test + void anUnrecoverableBreachContractUsesTheMaximumPenalty() { + assertEquals(-40, MercenaryReputationCalculator.calculateBreachPenalty(null)); + assertEquals(-40, MercenaryReputationCalculator.calculateBankruptcyPenalty(null)); + } + } diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractBoundaryCoverageTest.java new file mode 100644 index 00000000..c4ed380a --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryContractBoundaryCoverageTest.java @@ -0,0 +1,411 @@ +package net.tfminecraft.simplefactions.mercenary.contract; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.gson.Gson; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.database.MercenaryContractData; +import net.tfminecraft.simplefactions.database.WarData; +import net.tfminecraft.simplefactions.diplomacy.Relation; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.loaders.RelationLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.mercenary.company.WageSettings; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.utils.PostSettlementPayouts.PlayerUuidLookup; +import net.tfminecraft.simplefactions.war.battle.engine.core.Battle; +import net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager; +import net.tfminecraft.simplefactions.war.battle.enums.BattleType; +import net.tfminecraft.simplefactions.war.battle.events.BattleEndedEvent; +import net.tfminecraft.simplefactions.war.battle.events.BattleStartedEvent; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.core.WarMapper; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +class MercenaryContractBoundaryCoverageTest { + @Test + void wageOverridesFollowThePlayerAcrossNameCasingAndLocale() { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + WageSettings wages = new WageSettings(); + wages.setActivePercent(20); + wages.setPeacetimePerDay(2); + wages.setActiveOverride("IRIS", 55.0); + wages.setPeacetimeOverride("IRIS", 7.0); + + assertEquals(55.0, wages.activeShareOf(100, "iris")); + assertEquals(7.0, wages.peacetimeFor("iris")); + assertEquals(7.0, wages.getPeacetimeOverride("iris")); + wages.clearActiveOverride("iris"); + wages.clearPeacetimeOverride("iris"); + assertNull(wages.getPeacetimeOverride("IRIS")); + assertEquals(20.0, wages.activeShareOf(100, "IRIS")); + assertEquals(2.0, wages.peacetimeFor("IRIS")); + } finally { + Locale.setDefault(previous); + } + } + + @Test + void aCompanyCannotFightItsHostsGrandchildRealm() { + try (FactionDomainFixture domain = new FactionDomainFixture()) { + Faction host = domain.saved("host", "HostLeader"); + Faction middle = domain.saved("middle", "MiddleLeader"); + Faction enemy = domain.saved("grandchild", "GrandchildLeader"); + Faction hirer = domain.saved("hirer", "HirerLeader"); + domain.subject(host, middle); + domain.subject(middle, enemy); + Guild guild = domain.guild(host, "blades", "Captain"); + MercenaryCompany company = company(guild); + War war = new War(551092, hirer, enemy); + + var result = MercenaryLoyalty.canServe(company, hirer, List.of(war)); + + assertFalse(result.ok(), "The company must not fight a nested subject of its host realm"); + assertTrue(company.getContractHandler().getActive().isEmpty()); + assertEquals(List.of("Captain"), company.getEnlisted()); + assertSame(host, company.getGuild().getFaction()); + } + } + + @Test + void deepAncestorLoyaltyStillAllowsACompanyToServeAgainstAnUnrelatedPeer() { + try (FactionDomainFixture domain = new FactionDomainFixture()) { + Faction root = domain.saved("root", "RootLeader"); + Faction middle = domain.saved("middle", "MiddleLeader"); + Faction host = domain.saved("host", "HostLeader"); + Faction peer = domain.saved("peer", "PeerLeader"); + Faction hirer = domain.saved("hirer", "HirerLeader"); + domain.subject(root, middle); + domain.subject(middle, host); + domain.subject(root, peer); + Faction ally = domain.saved("neutral_ally", "AllyLeader"); + var alliance = domain.relationType("ally", Map.of("name", "Ally")); + host.setRelation(ally, new Relation(alliance, RelationLoader.getDefaultAttitude())); + MercenaryCompany company = company(domain.guild(host, "blades", "Captain")); + assertFalse( + MercenaryLoyalty.canServe(company, hirer, List.of(new War(551092, hirer, root))).ok()); + assertTrue( + MercenaryLoyalty.canServe(company, hirer, List.of(new War(551093, hirer, peer))).ok(), + "A neutral peer is not an ancestor, descendant, or ally of the company host"); + } + } + + @Test + void loyaltyQueriesHandleUnavailableCompaniesHirersAndWarSnapshots() { + try (FactionDomainFixture domain = new FactionDomainFixture()) { + Faction host = domain.saved("host", "HostLeader"); + Faction hirer = domain.saved("hirer", "HirerLeader"); + MercenaryCompany company = company(domain.guild(host, "blades", "Captain")); + assertFalse(MercenaryLoyalty.canServe(null, hirer, List.of()).ok()); + assertFalse(MercenaryLoyalty.canServe(company, null, List.of()).ok()); + assertTrue(MercenaryLoyalty.canServe(company, hirer, null).ok()); + MercenaryCompany unhosted = company(null); + assertTrue(MercenaryLoyalty.canServe(unhosted, hirer, List.of()).ok()); + assertFalse(MercenaryLoyalty.canServeAlongside(null, hirer, List.of()).ok()); + assertFalse(MercenaryLoyalty.canServeAlongside(company, null, List.of()).ok()); + assertTrue(MercenaryLoyalty.canServeAlongside(company, hirer, null).ok()); + assertFalse(SlotReservations.canPromise(null, 1, 0, 1).ok()); + assertTrue(company.getContractHandler().getAll().isEmpty()); + } + } + + @Test + void contractsWithRemovedOrMatchingHirersDoNotBlockAnUnrelatedLoyalOffer() { + try (FactionDomainFixture domain = new FactionDomainFixture()) { + Faction host = domain.saved("host", "HostLeader"); + Faction hirer = domain.saved("hirer", "HirerLeader"); + Faction enemy = domain.saved("enemy", "EnemyLeader"); + Faction neutral = domain.saved("neutral", "NeutralLeader"); + Faction removed = domain.saved("removed", "RemovedLeader"); + MercenaryCompany company = company(domain.guild(host, "blades", "Captain")); + contract(company, hirer, 1, System.currentTimeMillis()); + contract(company, neutral, 1, System.currentTimeMillis()); + contract(company, removed, 1, System.currentTimeMillis()); + FactionManager.factions.remove(removed); + War war = new War(551092, hirer, enemy); + assertTrue(MercenaryLoyalty.canServeAlongside(company, hirer, Arrays.asList(null, war)).ok()); + assertNull( + MercenaryEngagements.forPlayer( + new War(551095, enemy, domain.saved("another_enemy", "OtherEnemyLeader")), + "Captain")); + assertEquals( + 3, + company.getContractHandler().getActive().size(), + "Loyalty queries do not mutate active contracts"); + } + } + + @Test + void aSavedWarWithoutItsHirersMainRowCannotAuthorizeMercenaryEnrollment() throws Exception { + try (ContractRig rig = new ContractRig()) { + MercenaryContract contract = contract(rig.company, rig.hirer, 1, System.currentTimeMillis()); + assertSame(contract, MercenaryEngagements.forPlayer(rig.war, "Captain").contract()); + assertSame(rig.war.getAttackers(), MercenaryEngagements.sideFor(rig.war, "Captain")); + WarData saved = WarMapper.toData(rig.war); + saved.attackers.participants.removeIf(row -> row.leader.equals(rig.hirer.getId())); + Gson gson = new Gson(); + War restored = WarMapper.fromData(gson.fromJson(gson.toJson(saved), WarData.class)); + assertNotNull(restored); + assertTrue(restored.isParticipating(rig.hirer), "The saved coalition still names its leader"); + assertNull(restored.getSide(rig.hirer), "The missing main row cannot resolve enrollment"); + + assertNull(MercenaryEngagements.forPlayer(restored, "Captain")); + assertNull(MercenaryEngagements.sideFor(restored, "Captain")); + assertTrue(MercenaryEngagements.on(restored, restored.getAttackers()).isEmpty()); + assertTrue(MercenaryEngagements.on(restored, restored.getDefenders()).isEmpty()); + assertEquals(List.of(contract), rig.company.getContractHandler().getActive()); + assertEquals(List.of("Captain"), rig.company.getEnlisted()); + assertEquals(0.0, contract.getAccruedToCompany()); + assertEquals(0.0, contract.getAccruedToHirer()); + } + } + + @Test + void liveCampaignBattleFreezesAnAmendmentUntilTheFightEnds() throws Exception { + try (ContractRig rig = new ContractRig()) { + long now = System.currentTimeMillis(); + rig.company.getRegiment().setCurrentSlots(3); + MercenaryContract contract = contract(rig.company, rig.hirer, 1, now); + contract.setDaysServed(3); + assertEquals(30.0, contract.getServedDaysOwed()); + assertTrue( + rig.company.getContractHandler().proposeSlots(contract.getId(), "Captain", 2, now).ok()); + assertEquals(1, SlotReservations.remaining(rig.company, now, contract.getDueDate())); + assertFalse(SlotReservations.canPromise(rig.company, 2, now, contract.getDueDate()).ok()); + Battle manual = new Battle("manual"); + manual.setStarted(true); + Battle missingWar = new Battle("orphan"); + missingWar.setStarted(true); + missingWar.setWarId(999_999); + Battle campaign = new Battle("campaign"); + campaign.setWarId(rig.war.getId()); + BattleManager.get().addAll(List.of(manual, missingWar, campaign)); + assertFalse(ContractBattleGate.hirerIsFighting(rig.hirer)); + campaign.setStarted(true); + + var refused = + rig.company + .getContractHandler() + .acceptSlots(contract.getId(), rig.hirer, "HirerLeader", now); + + assertFalse(refused.ok()); + assertTrue(refused.message().contains("battle")); + assertEquals(1, contract.getSlots()); + assertEquals(2, contract.getPendingSlots(now)); + assertFalse(ContractBattleGate.hirerIsFighting(rig.host)); + campaign.setStarted(false); + assertTrue( + rig.company + .getContractHandler() + .acceptSlots(contract.getId(), rig.hirer, "HirerLeader", now) + .ok()); + assertEquals(2, contract.getSlots()); + assertFalse(contract.hasPendingSlots(now)); + contract.addDayServed(); + assertEquals( + 50.0, + contract.getServedDaysOwed(), + "A size change keeps prior service at its original rate"); + } + } + + @Test + void battleEventsChargeOnceRefundAbsenceAndPayOnlyAttendingSoldiers() throws Exception { + try (ContractRig rig = new ContractRig()) { + rig.company.getRegiment().setCurrentSlots(2); + assertTrue(rig.company.enlist("Recruit")); + UUID captain = UUID.fromString("00000000-0000-0000-0000-000000000001"); + UUID recruit = UUID.fromString("00000000-0000-0000-0000-000000000002"); + Map<String, UUID> ids = Map.of("Captain", captain, "Recruit", recruit); + MercenaryEngagements.setUuidLookup(ids::get); + rig.company.getWageSettings().setActivePercent(20); + MercenaryContract contract = contract(rig.company, rig.hirer, 2, System.currentTimeMillis()); + AttendanceService.Hook listener = new AttendanceService.Hook(); + BattleStartedEvent started = + new BattleStartedEvent( + "contract-battle", BattleType.FIELD, rig.war.getId(), Set.of(captain, recruit)); + BattleEndedEvent ended = + new BattleEndedEvent( + "contract-battle", + BattleType.FIELD, + rig.war.getId(), + "attacker", + Map.of(), + Set.of(captain)); + + listener.onBattleStarted(started); + listener.onBattleStarted(started); + assertEquals(100.0, contract.getAccruedToCompany()); + listener.onBattleEnded(ended); + + AttendanceService.Result result = AttendanceService.result(contract, "contract-battle"); + assertEquals(1, result.attended()); + assertEquals(1, result.absent()); + assertEquals(Set.of(captain), result.attendedIds()); + assertFalse(result.snapshotMissing()); + assertEquals(50.0, contract.getAccruedToHirer()); + assertEquals(Map.of("Captain", 10.0), rig.company.getPendingWages()); + assertFalse(contract.hasCleanAttendance()); + int reputation = rig.company.getReputation(); + assertTrue(reputation < 50); + listener.onBattleEnded(ended); + assertEquals(50.0, contract.getAccruedToHirer()); + assertEquals(Map.of("Captain", 10.0), rig.company.getPendingWages()); + assertEquals(reputation, rig.company.getReputation()); + } + } + + @Test + void equalDateDoubleHireUsesStableSavedIdentityAndKeepsLoyaltyTerminationNeutral() + throws Exception { + try (ContractRig rig = new ContractRig()) { + long now = System.currentTimeMillis(); + MercenaryContract current = restoredContract(rig.company, rig.hirer, "z-contract", now); + MercenaryContract olderById = restoredContract(rig.company, rig.enemy, "a-contract", now); + List<String> reputationEvents = new ArrayList<>(); + ContractReputationSeam recorder = + (contract, reason) -> reputationEvents.add(contract.getId() + ":" + reason); + ContractTerminationService.setReputationSeam(recorder); + assertSame(recorder, ContractTerminationService.getReputationSeam()); + + assertEquals( + List.of(current), + ContractTerminationService.resolveDoubleHire(rig.company, List.of(rig.war))); + + assertTrue(olderById.isActive()); + assertFalse(current.isActive()); + assertTrue(reputationEvents.isEmpty()); + assertEquals(0.0, current.getAccruedToHirer()); + assertTrue( + ContractTerminationService.resolveDoubleHire(rig.company, List.of(rig.war)).isEmpty()); + assertEquals( + 0.0, ContractTerminationService.terminate(current, TerminationReason.LOYALTY_CONFLICT)); + } + } + + @Test + void correctingServedDaysRepricesUnamendedHistoryAndSurvivesPersistence() { + try (FactionDomainFixture domain = new FactionDomainFixture()) { + Faction host = domain.saved("host", "HostLeader"); + Faction hirer = domain.saved("hirer", "HirerLeader"); + MercenaryCompany company = company(domain.guild(host, "blades", "Captain")); + MercenaryContract contract = contract(company, hirer, 2, System.currentTimeMillis()); + contract.setDaysServed(3); + assertEquals(60.0, contract.getServedDaysOwed()); + MercenaryContract restored = new MercenaryContract(company, contract.serialize()); + assertEquals(60.0, restored.getServedDaysOwed()); + restored.setDaysServed(-1); + assertEquals(0.0, restored.getServedDaysOwed()); + restored.addDayServed(); + assertEquals(20.0, restored.getServedDaysOwed()); + } + } + + private MercenaryContract contract(MercenaryCompany company, Faction hirer, int slots, long now) { + MercenaryContract contract = + new MercenaryContract( + company, + hirer, + ContractKind.MERCENARY, + new ContractTerms(slots, 50, 10, 7, 50, 500), + now); + company.getContractHandler().add(contract); + assertTrue(contract.activate()); + return contract; + } + + private MercenaryContract restoredContract( + MercenaryCompany company, Faction hirer, String id, long now) { + MercenaryContract original = + new MercenaryContract( + company, hirer, ContractKind.MERCENARY, new ContractTerms(1, 50, 10, 7, 50, 500), now); + MercenaryContractData data = original.serialize(); + data.id = id; + data.status = ContractStatus.ACTIVE.name(); + MercenaryContract restored = new MercenaryContract(company, data); + company.getContractHandler().add(restored); + return restored; + } + + private final class ContractRig implements AutoCloseable { + final FactionDomainFixture domain = new FactionDomainFixture(); + final Faction host = domain.saved("host", "HostLeader"); + final Faction hirer = domain.saved("hirer", "HirerLeader"); + final Faction enemy = domain.saved("enemy", "EnemyLeader"); + final MercenaryCompany company = company(domain.guild(host, "blades", "Captain")); + final War war = new War(551094, hirer, enemy); + final List<Battle> previousBattles = new ArrayList<>(BattleManager.get()); + final PlayerUuidLookup previousUuids = MercenaryEngagements.uuidLookup(); + final ContractReputationSeam previousReputation = + ContractTerminationService.getReputationSeam(); + final ContractBattleGate.Fighting previousFighting; + final Map<Object, Object> snapshots; + final Map<Object, Object> savedSnapshots; + final Map<Object, Object> results; + final Map<Object, Object> savedResults; + + ContractRig() throws Exception { + Field fighting = ContractBattleGate.class.getDeclaredField("fighting"); + fighting.setAccessible(true); + previousFighting = (ContractBattleGate.Fighting) fighting.get(null); + snapshots = map("startSnapshots"); + results = map("results"); + savedSnapshots = new HashMap<>(snapshots); + savedResults = new HashMap<>(results); + AttendanceService.reset(); + BattleManager.get().clear(); + ContractBattleGate.setFighting(null); + WarManager.get().add(war); + } + + @SuppressWarnings("unchecked") + private Map<Object, Object> map(String name) throws Exception { + Field field = AttendanceService.class.getDeclaredField(name); + field.setAccessible(true); + return (Map<Object, Object>) field.get(null); + } + + @Override + public void close() { + try { + BattleManager.get().clear(); + BattleManager.get().addAll(previousBattles); + ContractBattleGate.setFighting(previousFighting); + MercenaryEngagements.setUuidLookup(previousUuids); + ContractTerminationService.setReputationSeam(previousReputation); + snapshots.clear(); + snapshots.putAll(savedSnapshots); + results.clear(); + results.putAll(savedResults); + } finally { + domain.close(); + } + } + } + + private MercenaryCompany company(Guild guild) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("item.material", "IRON_SWORD"); + yaml.set("mercenary", true); + MercenaryCompany company = + new MercenaryCompany(guild, "Blades", new Regiment("mercenary", yaml), 0); + if (guild != null) guild.setCompany(company); + company.enlistLeader(); + return company; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/stat/ExternalIntegrationsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/stat/ExternalIntegrationsCoverageTest.java new file mode 100644 index 00000000..f15c5111 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/stat/ExternalIntegrationsCoverageTest.java @@ -0,0 +1,564 @@ +package net.tfminecraft.simplefactions.mercenary.stat; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.api.player.MMOPlayerData; +import io.lumine.mythic.lib.api.stat.SharedStat; +import io.lumine.mythic.lib.api.stat.modifier.StatModifier; +import io.lumine.mythic.lib.player.modifier.ModifierType; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.logging.Logger; +import net.bytebuddy.ByteBuddy; +import net.bytebuddy.implementation.MethodDelegation; +import net.tfminecraft.rpcharacters.managers.PlayerManager; +import net.tfminecraft.rpcharacters.objects.PlayerData; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.objects.trait.Trait; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.api.GatewayClient; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.upgrade.Upgrade; +import net.tfminecraft.simplefactions.loaders.CompanyUpgradeLoader; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryEligibility; +import net.tfminecraft.simplefactions.mercenary.company.RpCharactersMercenaryTraitProbe; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.war.battle.enums.BattleType; +import net.tfminecraft.simplefactions.war.battle.events.BattleEndedEvent; +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeInstance; +import org.bukkit.attribute.AttributeModifier; +import org.bukkit.damage.DamageSource; +import org.bukkit.entity.Player; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.EquipmentSlotGroup; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +public class ExternalIntegrationsCoverageTest { + private static final NamespacedKey HEALTH_KEY = + new NamespacedKey("simplefactions", "mercenary_max_health"); + private FactionDomainFixture fixture; + private MercenaryCompany company; + private Player player; + private PluginManager plugins; + private Locale previousLocale; + private final Map<Field, Object> globals = new LinkedHashMap<>(); + private Map<UUID, MercenaryStatPlan> previousApplied; + private Map<String, Upgrade> previousUpgrades; + + @BeforeEach + void setup() throws Exception { + fixture = new FactionDomainFixture(); + previousLocale = Locale.getDefault(); + Locale.setDefault(Locale.ROOT); + for (String name : List.of("gate", "applier", "missingLogged")) { + Field field = MercenaryStatService.class.getDeclaredField(name); + field.setAccessible(true); + globals.put(field, field.get(null)); + } + previousApplied = new HashMap<>(applied()); + MercenaryStatService.reset(); + previousUpgrades = new LinkedHashMap<>(CompanyUpgradeLoader.get()); + new CompanyUpgradeLoader() + .load(Path.of("src/main/resources/Guilds/company-upgrades.yml").toFile()); + Faction faction = fixture.saved("home", "Ivar"); + Guild guild = faction.getOrCreateMainGuild(); + Regiment regiment = fixture.regiment("mercenary", false, 2, 1); + company = new MercenaryCompany(guild, "Hired Blades", regiment, 0); + guild.setCompany(company); + regiment.setCurrentSlots(2); + assertTrue(company.enlist("Sigrun")); + company.getUpgrade("company_health").setLevel(4); + company.getUpgrade("company_mana").setLevel(3); + company.getUpgrade("company_mana_regen").setLevel(2); + player = fixture.player("Sigrun"); + when(Bukkit.getPlayer(any(UUID.class))) + .thenAnswer( + call -> + fixture.online.values().stream() + .filter(online -> online.getUniqueId().equals(call.getArgument(0))) + .findFirst() + .orElse(null)); + when(Bukkit.getOnlinePlayers()).thenAnswer(call -> fixture.online.values()); + plugins = Bukkit.getPluginManager(); + when(plugins.isPluginEnabled(anyString())).thenReturn(true); + MercenaryStatService.setGate(name -> name.equalsIgnoreCase("Sigrun")); + } + + @AfterEach + void close() throws Exception { + applied().clear(); + applied().putAll(previousApplied); + for (var entry : globals.entrySet()) entry.getKey().set(null, entry.getValue()); + CompanyUpgradeLoader.get().clear(); + CompanyUpgradeLoader.get().putAll(previousUpgrades); + Locale.setDefault(previousLocale); + GatewayProvider.response = null; + fixture.close(); + } + + @Test + void mythicAndHealthModifiersCarryThePlanAndStripOnlyTheirOwnContributions() { + Health health = health(player); + AttributeModifier foreign = modifier("another_plugin", "healthy", 8); + AttributeModifier stale = modifier("simplefactions", "mercenary_max_health", 99); + health.modifiers.addAll(List.of(foreign, stale)); + MMOPlayerData data = mock(MMOPlayerData.class); + List<List<?>> arguments = new ArrayList<>(); + try (MockedStatic<MMOPlayerData> players = mockStatic(MMOPlayerData.class); + MockedConstruction<StatModifier> modifiers = + mockConstruction( + StatModifier.class, + (modifier, context) -> arguments.add(new ArrayList<>(context.arguments())))) { + players.when(() -> MMOPlayerData.getOrNull(player)).thenReturn(data); + MythicLibStatApplier applier = new MythicLibStatApplier(); + assertTrue(applier.isAvailable()); + + applier.apply(player, new MercenaryStatPlan(2, 3, 0.2)); + + assertEquals( + List.of("simplefactions_mercenary_max_mana", SharedStat.MAX_MANA, 3.0, ModifierType.FLAT), + arguments.get(0)); + assertEquals( + List.of( + "simplefactions_mercenary_mana_regeneration", + SharedStat.MANA_REGENERATION, + 0.2, + ModifierType.FLAT), + arguments.get(1)); + for (StatModifier modifier : modifiers.constructed()) verify(modifier).register(data); + assertEquals(2, health.modifiers.size()); + assertTrue(health.modifiers.contains(foreign)); + assertFalse(health.modifiers.contains(stale)); + AttributeModifier bonus = + health.modifiers.stream() + .filter(m -> HEALTH_KEY.equals(m.getKey())) + .findFirst() + .orElseThrow(); + assertEquals(2, bonus.getAmount()); + assertEquals(AttributeModifier.Operation.ADD_NUMBER, bonus.getOperation()); + assertEquals(EquipmentSlotGroup.ANY, bonus.getSlotGroup()); + + applier.strip(player); + applier.strip(player); + + assertEquals(List.of(foreign), health.modifiers); + for (StatModifier modifier : modifiers.constructed()) verify(modifier).unregister(data); + } + } + + @Test + void statModifierIdentifiersAreStableUnderTurkishLocale() { + health(player); + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + MMOPlayerData data = mock(MMOPlayerData.class); + List<List<?>> arguments = new ArrayList<>(); + try (MockedStatic<MMOPlayerData> players = mockStatic(MMOPlayerData.class); + MockedConstruction<StatModifier> modifiers = + mockConstruction( + StatModifier.class, + (modifier, context) -> arguments.add(new ArrayList<>(context.arguments())))) { + players.when(() -> MMOPlayerData.getOrNull(player)).thenReturn(data); + + new MythicLibStatApplier().apply(player, new MercenaryStatPlan(0, 0, 1)); + + assertEquals(1, modifiers.constructed().size()); + assertEquals("simplefactions_mercenary_mana_regeneration", arguments.getFirst().getFirst()); + } + } + + @Test + void joiningAfterACompanionPluginWasDisabledStillRemovesPersistedHealth() { + Health health = health(player); + AttributeModifier foreign = modifier("another_plugin", "healthy", 8); + health.modifiers.addAll( + List.of(foreign, modifier("simplefactions", "mercenary_max_health", 2))); + when(plugins.isPluginEnabled("MMOCore")).thenReturn(false); + MercenaryStatService.setApplier(new MythicLibStatApplier()); + + new MercenaryStatService.Listener().onJoin(new PlayerJoinEvent(player, "joined")); + + assertEquals(List.of(foreign), health.modifiers); + assertFalse(MercenaryStatService.isApplied(player)); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void clearingAfterCompanionShutdownUsesOnlyTheStillAvailableProvider(boolean mythicLibRemains) { + Health health = health(player); + AttributeModifier foreign = modifier("another_plugin", "healthy", 8); + health.modifiers.add(foreign); + MMOPlayerData data = mock(MMOPlayerData.class); + try (MockedStatic<MMOPlayerData> players = mockStatic(MMOPlayerData.class); + MockedConstruction<StatModifier> modifiers = mockConstruction(StatModifier.class)) { + players.when(() -> MMOPlayerData.getOrNull(player)).thenReturn(data); + MercenaryStatService.setApplier(new MythicLibStatApplier()); + assertTrue(MercenaryStatService.apply(player)); + assertEquals(2, modifiers.constructed().size()); + assertEquals(2, health.modifiers.size()); + players.clearInvocations(); + when(plugins.isPluginEnabled("MMOCore")).thenReturn(false); + when(plugins.isPluginEnabled("MythicLib")).thenReturn(mythicLibRemains); + + new MercenaryStatService.Listener().onQuit(new PlayerQuitEvent(player, "left")); + + assertEquals(List.of(foreign), health.modifiers); + assertFalse(MercenaryStatService.isApplied(player)); + players.verify(() -> MMOPlayerData.getOrNull(player), times(mythicLibRemains ? 1 : 0)); + for (StatModifier modifier : modifiers.constructed()) { + verify(modifier, times(mythicLibRemains ? 1 : 0)).unregister(data); + } + } + } + + @ParameterizedTest + @ValueSource(strings = {"join", "quit", "death", "battle"}) + void lifecycleEventsStripAppliedCompanyBonuses(String event) { + RecordingStatApplier applier = new RecordingStatApplier(); + MercenaryStatService.setApplier(applier); + assertTrue(MercenaryStatService.apply(player)); + assertEquals(new MercenaryStatPlan(2, 3, 0.2), MercenaryStatService.appliedTo(player)); + MercenaryStatService.Listener listener = new MercenaryStatService.Listener(); + + switch (event) { + case "join" -> listener.onJoin(new PlayerJoinEvent(player, "joined")); + case "quit" -> listener.onQuit(new PlayerQuitEvent(player, "left")); + case "death" -> + listener.onDeath( + new PlayerDeathEvent(player, mock(DamageSource.class), new ArrayList<>(), 0, "died")); + case "battle" -> + listener.onBattleEnded( + new BattleEndedEvent( + "battle", + BattleType.FIELD, + 7, + "attackers", + Map.of(), + Set.of(player.getUniqueId()))); + default -> throw new AssertionError(event); + } + + assertFalse(MercenaryStatService.isApplied(player)); + assertEquals(MercenaryStatPlan.EMPTY, MercenaryStatService.appliedTo(player)); + assertEquals(1, applier.strips); + } + + @Test + void clearingAllDropsOnlineAndOfflineRecordsAndSupportsShutdownWithoutAServer() { + RecordingStatApplier applier = new RecordingStatApplier(); + MercenaryStatService.setApplier(applier); + MercenaryStatService.setGate(name -> true); + Player other = fixture.player("Edda"); + assertTrue(company.enlist("Edda")); + assertTrue(MercenaryStatService.apply(player)); + assertTrue(MercenaryStatService.apply(other)); + fixture.online.remove("Edda"); + + MercenaryStatService.clearAll(); + + assertFalse(MercenaryStatService.isApplied(player)); + assertFalse(MercenaryStatService.isApplied(other)); + assertEquals(1, applier.strips, "Only the online player has live attributes to strip"); + assertTrue(MercenaryStatService.apply(player)); + when(Bukkit.getServer()).thenReturn(null); + + MercenaryStatService.clearAll(); + + assertFalse(MercenaryStatService.isApplied(player)); + assertEquals(1, applier.strips); + } + + @Test + void missingCompanionDependenciesLogOnceAndNullParticipantsRemainHarmless() { + RecordingStatApplier applier = new RecordingStatApplier(); + applier.available = false; + MercenaryStatService.setApplier(applier); + Logger logger = mock(Logger.class); + when(fixture.ui.plugin.getLogger()).thenReturn(logger); + SimpleFactions.plugin = null; + assertFalse(MercenaryStatService.apply(player)); + verifyNoInteractions(logger); + SimpleFactions.plugin = fixture.ui.plugin; + + assertFalse(MercenaryStatService.apply(player)); + assertFalse(MercenaryStatService.apply(player)); + + verify(logger) + .info( + "[SimpleFactions] MythicLib or MMOCore missing; mercenary company buffs not applied."); + assertTrue(applier.applications.isEmpty()); + assertEquals(0, applier.strips); + MercenaryStatService.clear(null); + MercenaryStatService.clearParticipants(null); + assertFalse(MercenaryStatService.apply(null)); + assertFalse(MercenaryStatService.isApplied(null)); + assertEquals(MercenaryStatPlan.EMPTY, MercenaryStatService.appliedTo(null)); + assertEquals(MercenaryStatPlan.EMPTY, MercenaryStatService.planFor(null)); + MercenaryStatService.setGate(null); + assertEquals(MercenaryStatPlan.EMPTY, MercenaryStatService.planFor("Sigrun")); + MercenaryStatService.setApplier(null); + assertFalse(MercenaryStatService.apply(player)); + } + + @Test + void offlineParticipantsLoseBookkeepingWithoutTryingToStripPlayerAttributes() { + RecordingStatApplier applier = new RecordingStatApplier(); + MercenaryStatService.setApplier(applier); + assertTrue(MercenaryStatService.apply(player)); + fixture.online.clear(); + + MercenaryStatService.clearParticipants(Set.of(player.getUniqueId())); + + assertFalse(MercenaryStatService.isApplied(player)); + assertEquals(0, applier.strips); + assertTrue(MercenaryStatService.apply(player)); + when(Bukkit.getServer()).thenReturn(null); + MercenaryStatService.clearParticipants(Set.of(player.getUniqueId())); + assertFalse(MercenaryStatService.isApplied(player)); + assertEquals(0, applier.strips); + } + + @Test + void healthCleanupSurvivesAnUnloadedMmoProfileAndAnAbsentAttribute() { + Health health = health(player); + MMOPlayerData data = mock(MMOPlayerData.class); + try (MockedStatic<MMOPlayerData> players = mockStatic(MMOPlayerData.class); + MockedConstruction<StatModifier> modifiers = mockConstruction(StatModifier.class)) { + MythicLibStatApplier applier = new MythicLibStatApplier(); + applier.apply(player, new MercenaryStatPlan(2, 3, 1)); + assertEquals(1, health.modifiers.size()); + assertTrue(modifiers.constructed().isEmpty(), "No MMO profile means no mana registration"); + applier.apply(player, MercenaryStatPlan.EMPTY); + assertTrue(health.modifiers.isEmpty()); + players.when(() -> MMOPlayerData.getOrNull(player)).thenReturn(data); + applier.apply(player, new MercenaryStatPlan(2, 3, 0)); + assertEquals(1, modifiers.constructed().size()); + verify(modifiers.constructed().getFirst()).register(data); + players.when(() -> MMOPlayerData.getOrNull(player)).thenReturn(null); + + applier.strip(player); + + assertTrue(health.modifiers.isEmpty()); + verify(modifiers.constructed().getFirst(), never()).unregister(any()); + when(player.getAttribute(Attribute.MAX_HEALTH)).thenReturn(null); + players.when(() -> MMOPlayerData.getOrNull(player)).thenReturn(data); + applier.apply(player, MercenaryStatPlan.EMPTY); + applier.strip(player); + assertEquals(1, modifiers.constructed().size()); + } + } + + @Test + void activeCharacterTraitsControlEligibilityWhileUnavailablePlayersRemainUnknown() { + RpCharactersMercenaryTraitProbe probe = new RpCharactersMercenaryTraitProbe(); + assertEquals(MercenaryEligibility.Status.UNKNOWN, probe.check("Offline")); + when(plugins.isPluginEnabled("RPCharacters")).thenReturn(false); + assertEquals(MercenaryEligibility.Status.UNKNOWN, probe.check("Sigrun")); + when(plugins.isPluginEnabled("RPCharacters")).thenReturn(true); + try (MockedStatic<PlayerManager> manager = mockStatic(PlayerManager.class)) { + manager.when(() -> PlayerManager.get(player)).thenReturn(null); + assertEquals(MercenaryEligibility.Status.INELIGIBLE, probe.check("Sigrun")); + PlayerData data = mock(PlayerData.class); + manager.when(() -> PlayerManager.get(player)).thenReturn(data); + assertEquals(MercenaryEligibility.Status.INELIGIBLE, probe.check("Sigrun")); + RPCharacter character = mock(RPCharacter.class); + when(data.hasActiveCharacter()).thenReturn(true); + when(data.getActiveCharacter()).thenReturn(character); + Trait merchant = mock(Trait.class); + when(merchant.getId()).thenReturn("merchant"); + when(character.getTraits()).thenReturn(Arrays.asList(null, merchant)); + assertEquals(MercenaryEligibility.Status.INELIGIBLE, probe.check("Sigrun")); + Trait mercenary = mock(Trait.class); + when(mercenary.getId()).thenReturn("MERCENARY"); + when(character.getTraits()).thenReturn(Arrays.asList(null, merchant, mercenary)); + assertEquals(MercenaryEligibility.Status.ELIGIBLE, probe.check("Sigrun")); + } + when(Bukkit.getPluginManager()).thenReturn(null); + assertEquals(MercenaryEligibility.Status.UNKNOWN, probe.check("Sigrun")); + when(Bukkit.getServer()).thenReturn(null); + assertEquals(MercenaryEligibility.Status.UNKNOWN, probe.check("Sigrun")); + } + + @Test + void gatewayBridgePreservesRequestAndProviderSuccessOrFailure() throws Exception { + Class<?> bridge = gatewayBridge(); + List<List<String>> requests = new ArrayList<>(); + GatewayProvider.response = + request -> { + requests.add(request); + return new GatewayReply(true, "{\"saved\":true}", null); + }; + GatewayView saved = request(bridge, "POST", "/plugin/state", "{\"id\":7}"); + assertTrue(saved.ok); + assertEquals("{\"saved\":true}", saved.body); + assertNull(saved.error); + assertEquals(List.of(List.of("POST", "/plugin/state", "{\"id\":7}")), requests); + GatewayProvider.response = request -> new GatewayReply(true, null, null); + assertEquals("", request(bridge, "GET", "/empty", null).body); + GatewayProvider.response = request -> new GatewayReply(false, null, "request denied"); + GatewayView denied = request(bridge, "DELETE", "/protected", null); + assertFalse(denied.ok); + assertNull(denied.body); + assertEquals("request denied", denied.error); + GatewayProvider.response = request -> new GatewayReply(false, null, null); + assertEquals("request failed", request(bridge, "GET", "/failed", null).error); + GatewayProvider.response = + request -> { + throw new IllegalStateException("provider offline"); + }; + assertEquals( + "TFMCWeb gateway unavailable: provider offline", + request(bridge, "GET", "/status", null).error); + GatewayProvider.response = + request -> { + throw new IllegalStateException(" "); + }; + assertEquals( + "TFMCWeb gateway unavailable: IllegalStateException", + request(bridge, "GET", "/status", null).error); + GatewayClient.Result absent = GatewayClient.request("GET", "/status", null); + assertFalse(absent.ok, "The isolated provider must not leak into the application classloader"); + assertTrue(absent.error.contains("ProvinceSystemGateway")); + } + + private Health health(Player target) { + AttributeInstance attribute = mock(AttributeInstance.class); + List<AttributeModifier> modifiers = new ArrayList<>(); + when(target.getAttribute(Attribute.MAX_HEALTH)).thenReturn(attribute); + when(attribute.getModifiers()).thenAnswer(call -> List.copyOf(modifiers)); + doAnswer( + call -> { + modifiers.add(call.getArgument(0)); + return null; + }) + .when(attribute) + .addModifier(any(AttributeModifier.class)); + doAnswer( + call -> { + modifiers.remove(call.getArgument(0)); + return null; + }) + .when(attribute) + .removeModifier(any(AttributeModifier.class)); + return new Health(modifiers); + } + + private static AttributeModifier modifier(String namespace, String id, double amount) { + return new AttributeModifier( + new NamespacedKey(namespace, id), + amount, + AttributeModifier.Operation.ADD_NUMBER, + EquipmentSlotGroup.ANY); + } + + @SuppressWarnings("unchecked") + private static Map<UUID, MercenaryStatPlan> applied() throws Exception { + Field field = MercenaryStatService.class.getDeclaredField("applied"); + field.setAccessible(true); + return (Map<UUID, MercenaryStatPlan>) field.get(null); + } + + private record Health(List<AttributeModifier> modifiers) {} + + /** An external provider API with the same reflective field contract as TFMCWeb. */ + public static final class GatewayReply { + public final boolean ok; + public final String body; + public final String error; + + public GatewayReply(boolean ok, String body, String error) { + this.ok = ok; + this.body = body; + this.error = error; + } + } + + public static final class GatewayProvider { + static Function<List<String>, Object> response; + + public static Object request(String method, String path, String body) { + return response.apply(Arrays.asList(method, path, body)); + } + } + + private record GatewayView(boolean ok, String body, String error) {} + + private static GatewayView request(Class<?> bridge, String method, String path, String body) + throws Exception { + Object reply = + bridge + .getMethod("request", String.class, String.class, String.class) + .invoke(null, method, path, body); + return new GatewayView( + (Boolean) reply.getClass().getField("ok").get(reply), + (String) reply.getClass().getField("body").get(reply), + (String) reply.getClass().getField("error").get(reply)); + } + + private static Class<?> gatewayBridge() throws Exception { + String gateway = "net.tfminecraft.tfmcweb.api.ProvinceSystemGateway"; + Map<String, byte[]> definitions = new HashMap<>(); + definitions.put( + gateway, + new ByteBuddy() + .subclass(Object.class) + .name(gateway) + .defineMethod("request", Object.class, Modifier.PUBLIC | Modifier.STATIC) + .withParameters(String.class, String.class, String.class) + .intercept(MethodDelegation.to(GatewayProvider.class)) + .make() + .getBytes()); + for (Class<?> type : List.of(GatewayClient.class, GatewayClient.Result.class)) { + try (var bytes = + type.getResourceAsStream("/" + type.getName().replace('.', '/') + ".class")) { + assertNotNull(bytes); + definitions.put(type.getName(), bytes.readAllBytes()); + } + } + ClassLoader isolated = + new ClassLoader(GatewayClient.class.getClassLoader()) { + @Override + protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { + synchronized (getClassLoadingLock(name)) { + if (!definitions.containsKey(name)) return super.loadClass(name, resolve); + Class<?> type = findLoadedClass(name); + if (type == null) { + byte[] bytes = definitions.get(name); + type = + defineClass( + name, bytes, 0, bytes.length, GatewayClient.class.getProtectionDomain()); + } + if (resolve) resolveClass(type); + return type; + } + } + }; + return isolated.loadClass(GatewayClient.class.getName()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/objects/DomainConfigurationBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/objects/DomainConfigurationBoundaryCoverageTest.java new file mode 100644 index 00000000..1d779a5f --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/objects/DomainConfigurationBoundaryCoverageTest.java @@ -0,0 +1,517 @@ +package net.tfminecraft.simplefactions.objects; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.google.gson.JsonParser; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.enums.FactionModifiers; +import net.tfminecraft.simplefactions.enums.GuildModifier; +import net.tfminecraft.simplefactions.enums.Region; +import net.tfminecraft.simplefactions.enums.Scope; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.IconFormat; +import net.tfminecraft.simplefactions.guild.branch.Branch; +import net.tfminecraft.simplefactions.guild.upgrade.Upgrade; +import net.tfminecraft.simplefactions.loaders.BranchLoader; +import net.tfminecraft.simplefactions.loaders.GuildLoader; +import net.tfminecraft.simplefactions.loaders.ProvinceLoader; +import net.tfminecraft.simplefactions.loaders.RankLoader; +import net.tfminecraft.simplefactions.loaders.TierLoader; +import net.tfminecraft.simplefactions.loaders.TitleLoader; +import net.tfminecraft.simplefactions.loaders.UpgradeLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.handler.LawHandler; +import net.tfminecraft.simplefactions.prestige.TradePrestige; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.tiers.Tier; +import net.tfminecraft.simplefactions.tiers.Title; +import net.tfminecraft.simplefactions.tiers.admin.TitleAdminService; +import net.tfminecraft.simplefactions.util.LegacyModelData; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemCreator; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class DomainConfigurationBoundaryCoverageTest { + @TempDir Path directory; + private FactionDomainFixture fixture; + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + } + + @AfterEach + void close() { + fixture.close(); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void guildModifierIdentifiersAreIndependentOfServerLocale(boolean upgrade) { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + var config = guildConfig("paper.1", List.of("prestige 3 2")); + double amount = + upgrade + ? new Upgrade(new Upgrade("prestige", config), 2).getAmount(GuildModifier.PRESTIGE) + : new Branch(new Branch("prestige", config), 2).getAmount(GuildModifier.PRESTIGE); + assertEquals( + 7.0, + amount, + "The same guild configuration must grant the same prestige on every server locale"); + } finally { + Locale.setDefault(previous); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void malformedGuildModifiersAreSkippedWithoutLosingValidSiblings(boolean upgrade) { + var config = + guildConfig( + "paper.1", + List.of( + "TRADE_POWER 3 2", + "PRESTIGE NaN", + "ADMIN_POWER 1 Infinity", + "MANA_REGEN 1 2 3", + "MAX_MANA nope", + "UNKNOWN 2", + "incomplete")); + var modifiers = + upgrade + ? new Upgrade("workshop", config).getModifiers() + : new Branch("workshop", config).getModifiers(); + assertEquals(List.of(GuildModifier.TRADE_POWER), new ArrayList<>(modifiers.keySet())); + assertEquals(7.0, modifiers.get(GuildModifier.TRADE_POWER).getCurrent(2)); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void configuredMaterialIconsDoNotDependOnServerLocale(boolean upgrade) { + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + var config = guildConfig("iron_ingot.7", List.of()); + ItemStack item = + upgrade + ? new Upgrade("smith", config).getIconItem() + : new Branch("smith", config).getIconItem(); + assertEquals(Material.IRON_INGOT, item.getType()); + assertEquals(7, LegacyModelData.get(item.getItemMeta())); + } finally { + Locale.setDefault(previous); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void itemPathIconsAreIndependentCopiesOfTheExternalTemplate(boolean upgrade) { + ItemStack template = new ItemStack(Material.EMERALD, 4); + var meta = template.getItemMeta(); + meta.setDisplayName("Original template"); + template.setItemMeta(meta); + try (var items = mockStatic(TLibs.class)) { + ItemAPI api = mock(ItemAPI.class); + ItemCreator creator = mock(ItemCreator.class); + items.when(TLibs::getItemAPI).thenReturn(api); + when(api.getCreator()).thenReturn(creator); + when(creator.getItemFromPath("ia.guild.badge")).thenReturn(template); + var config = guildConfig("ia.guild.badge", List.of()); + ItemStack icon = + upgrade + ? new Upgrade("badge", config).getIconItem() + : new Branch("badge", config).getIconItem(); + assertNotSame(template, icon); + assertEquals(4, icon.getAmount()); + assertEquals("Original template", icon.getItemMeta().getDisplayName()); + icon.setAmount(1); + var changed = icon.getItemMeta(); + changed.setDisplayName("Menu copy"); + icon.setItemMeta(changed); + assertEquals(4, template.getAmount()); + assertEquals("Original template", template.getItemMeta().getDisplayName()); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void missingOrBrokenExternalIconUsesFreshBlackDye(boolean throwsError) { + try (var items = mockStatic(TLibs.class)) { + ItemAPI api = mock(ItemAPI.class); + ItemCreator creator = mock(ItemCreator.class); + items.when(TLibs::getItemAPI).thenReturn(api); + when(api.getCreator()).thenReturn(creator); + if (throwsError) + when(creator.getItemFromPath("m.unavailable")) + .thenThrow(new IllegalArgumentException("missing model")); + ItemStack first = IconFormat.itemFromPath("m.unavailable"); + ItemStack second = IconFormat.itemFromPath("m.unavailable"); + assertEquals(Material.BLACK_DYE, first.getType()); + assertEquals(1, first.getAmount()); + first.setAmount(9); + assertEquals(1, second.getAmount()); + assertNotSame(first, second); + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void invalidLegacyMaterialDoesNotPreventGuildMenuRendering(boolean upgrade) { + var config = guildConfig("not_a_material.2", List.of()); + ItemStack icon = + upgrade + ? new Upgrade("invalid", config).getIconItem() + : new Branch("invalid", config).getIconItem(); + assertEquals(Material.DIRT, icon.getType()); + assertEquals(1, icon.getAmount()); + assertFalse(LegacyModelData.has(icon.getItemMeta())); + } + + @Test + void rankEditsChangeLookupAndRequiredPrestigeTogether() { + YamlConfiguration config = new YamlConfiguration(); + config.set("name", "Eminent"); + config.set("level", 3); + config.set("minimum-prestige", 300.0); + config.set("an", true); + PrestigeRank rank = new PrestigeRank("eminent", config); + RankLoader.getRanks().add(rank); + assertTrue(rank.getAn()); + assertEquals(300.0, FactionManager.getRankUpAmount(rank)); + rank.setId("illustrious"); + rank.setName("Illustrious"); + rank.setMin(450.0); + assertNull(RankLoader.getByString("eminent")); + assertSame(rank, RankLoader.getByString("ILLUSTRIOUS")); + assertEquals("Illustrious", RankLoader.getByLevel(3).getName()); + rank.setLevel(4); + assertNull(RankLoader.getByLevel(3)); + assertSame(rank, RankLoader.getByLevel(4)); + assertEquals(450.0, FactionManager.getRankUpAmount(rank)); + } + + @Test + void configuredLargeRankLevelsUseNumericIdentity() { + PrestigeRank rank = fixture.rank("legendary", 200, 500.0); + assertSame(rank, RankLoader.getByLevel(Integer.valueOf("200"))); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void failedRankReloadPreservesTheCurrentDefinitions(boolean malformed) throws Exception { + var previous = new ArrayList<>(RankLoader.getRanks()); + Path file = directory.resolve("ranks.yml"); + if (malformed) Files.writeString(file, "rank: [unterminated\n"); + assertThrows(IllegalStateException.class, () -> new RankLoader().loadRanks(file.toFile())); + assertEquals(previous, RankLoader.getRanks()); + assertSame(previous.getFirst(), RankLoader.getLowest()); + } + + @Test + void configuredTierAliasesChangeDisplayWithoutChangingItsIdentityOrCost() { + YamlConfiguration config = new YamlConfiguration(); + config.set("name", "County"); + config.set("tier", 2); + config.set("form-cost", 40); + config.set("aliases", List.of("Earldom", "Shire")); + Tier template = new Tier("county_alias", config); + Tier selected = new Tier(template, 1); + assertEquals("Shire §7(County§7)", selected.getFormattedName()); + assertEquals(1, selected.getIndex()); + selected.setIndex(0); + assertEquals("Earldom", selected.getCurrentAlias()); + assertEquals("County", template.getFormattedName()); + assertEquals("county_alias", selected.getId()); + assertEquals(40, selected.getFormCost()); + assertTrue(selected.canForm()); + } + + @Test + void completeCompositeTitleNeedsEveryChildAndEnoughOfTheirLand() { + Faction faction = fixture.saved("realm", "Leader"); + Title first = fixture.title("west", "county", 1, 2); + Title second = fixture.title("east", "county", 3, 4); + Title duchy = + new Title( + TierLoader.getByString("duchy"), + "duchy_complete", + JsonParser.parseString( + "{\"name\":\"Complete" + + " Duchy\",\"titles\":[\"west\",\"east\"],\"title-complete\":true}") + .getAsJsonObject()); + TitleLoader.getTitles().add(duchy); + assertFalse(duchy.canBeCreatedBy(faction, List.of(1, 2, 3, 4), List.of(first), 50)); + assertFalse(duchy.canBeCreatedBy(faction, List.of(1), List.of(first, second), 50)); + assertTrue(duchy.canBeCreatedBy(faction, List.of(1, 3), List.of(first, second), 50)); + var owned = new ArrayList<>(List.of(first, second)); + duchy.destroy(faction, List.of(), owned); + assertEquals( + List.of(first, second), owned, "Destroying an unowned parent cannot revoke its children"); + } + + @ParameterizedTest + @ValueSource(strings = {"1,blue,3", "1,2147483648,3"}) + void invalidTitleColourCannotChangeItsMapIdentity(String invalid) { + Title title = fixture.title("west", "county", 1, 2); + var result = TitleAdminService.setColour("west", invalid); + assertFalse(result.ok()); + assertEquals("4,5,6", title.getRgb()); + assertTrue(result.changed().isEmpty()); + assertTrue(result.regenerate().isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = {"3,nope", "3,999", " , "}) + void invalidProvinceBatchReportsTheReasonAndLeavesTitlesUnchanged(String invalid) { + Title title = fixture.title("west", "county", 1, 2); + var result = TitleAdminService.addProvinces("west", List.of(invalid), id -> id < 100); + String reason = + invalid.contains("nope") + ? "Province ids must be numbers: nope" + : invalid.contains("999") + ? "No province with the id 999" + : "Give at least one province id"; + assertFalse(result.ok()); + assertEquals(List.of(TitleAdminService.PREFIX + reason), result.lines()); + assertEquals(List.of(1, 2), title.getProvinces()); + assertTrue(result.changed().isEmpty()); + } + + @Test + void releasableGuildListReflectsLiveCapitalConflictsAndVassalLaw() { + fixture.lawGroup("vassals", Map.of("effects.faction.rules", List.of("CAN_HAVE_VASSALS true"))); + Faction faction = fixture.saved("realm", "Leader"); + Guild first = fixture.guild(faction, "west", "West"); + Guild second = fixture.guild(faction, "east", "East"); + first.setCapital(10); + second.setCapital(20); + assertEquals(2, faction.getGuildHandler().getReleasableGuilds().size()); + assertTrue(faction.getGuildHandler().getReleasableGuilds().containsAll(List.of(first, second))); + second.setCapital(10); + assertTrue(faction.getGuildHandler().getReleasableGuilds().isEmpty()); + second.setCapital(20); + var denied = + fixture.law( + "vassals", + "denied", + Map.of("effects.faction.rules", List.of("CAN_HAVE_VASSALS false"))); + faction.getLawHandler().getGroup("vassals").setCurrent(denied); + assertTrue(faction.getGuildHandler().getReleasableGuilds().isEmpty()); + assertSame(first, faction.getGuildHandler().getGuild("west")); + assertSame(second, faction.getGuildHandler().getGuild("east")); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void missingGuildDoesNotHideUnrelatedConfiguredLawModifiers(boolean attached) { + fixture.lawGroup( + "economy", Map.of("effects.domestic_guilds.modifiers", List.of("PRODUCTION(7)"))); + Faction faction = fixture.saved("realm", "Leader"); + LawHandler handler = attached ? faction.getLawHandler() : new LawHandler(null); + SimpleFactions previous = SimpleFactions.plugin; + try { + if (!attached) SimpleFactions.plugin = null; + var modifiers = handler.getLawModifiers("removed_guild", Scope.DOMESTIC_GUILDS, null); + assertEquals(1, modifiers.size()); + assertEquals(FactionModifiers.PRODUCTION, modifiers.getFirst().getType()); + assertEquals(7.0, modifiers.getFirst().getAmount()); + } finally { + SimpleFactions.plugin = previous; + } + } + + @Test + void exposedDefinitionRegistriesDriveExistingLookupAndLegacyBranchAlias() { + var config = guildConfig("paper.1", List.of()); + config.set("group", 6); + Branch branch = new Branch("counting_houses", config); + BranchLoader.get().put(branch.getId(), branch); + Upgrade upgrade = new Upgrade("guild_hall", config); + UpgradeLoader.get().put(upgrade.getId(), upgrade); + Faction faction = fixture.saved("realm", "Leader"); + Guild guild = fixture.guild(faction, "trade", "Trader"); + assertSame(branch, BranchLoader.getByString("freight_yards")); + assertSame(branch, BranchLoader.getByGroup(guild, 6)); + assertSame(upgrade, UpgradeLoader.getByString("GUILD_HALL")); + assertSame(guild.getType(), GuildLoader.get().get("guild")); + } + + @Test + void tinyTradeConversionUnderflowProducesZeroPrestige() { + double previous = Cache.prestigePerTradePower; + try { + Cache.prestigePerTradePower = 0.5; + assertEquals(0.0, TradePrestige.fromTradePower(Double.MIN_VALUE)); + assertEquals(0.5, TradePrestige.fromTradePower(1.0)); + } finally { + Cache.prestigePerTradePower = previous; + } + } + + @Test + void rankReloadCommitsAllDefinitionsOnlyAfterTheEntireFileIsValid() throws Exception { + var previous = new ArrayList<>(RankLoader.getRanks()); + Path file = directory.resolve("rank-reload.yml"); + Files.writeString( + file, "novice:\n name: Novice\n level: 1\n minimum-prestige: 0\nbroken: scalar\n"); + RankLoader loader = new RankLoader(); + assertThrows(IllegalStateException.class, () -> loader.loadRanks(file.toFile())); + assertEquals(previous, RankLoader.getRanks()); + assertNull( + RankLoader.getByString("novice"), "An earlier valid row must not leak from a failed load"); + Files.writeString( + file, + "novice:\n" + + " name: Novice\n" + + " level: 1\n" + + " minimum-prestige: 0\n" + + "master:\n" + + " name: Master\n" + + " level: 2\n" + + " minimum-prestige: 200\n"); + loader.loadRanks(file.toFile()); + assertEquals(2, RankLoader.getRanks().size()); + assertNull(RankLoader.getByString("common")); + assertEquals("novice", RankLoader.getLowest().getId()); + assertEquals(200.0, RankLoader.getByLevel(2).getMin()); + assertSame(RankLoader.getLowest(), fixture.saved("new_realm", "NewLeader").getRank()); + } + + @Test + void emptyRankReloadCannotPreventTheNextFactionFromLoading() throws Exception { + var previous = new ArrayList<>(RankLoader.getRanks()); + Path file = directory.resolve("empty-ranks.yml"); + Files.writeString(file, "# accidental empty replacement\n"); + assertThrows(IllegalStateException.class, () -> new RankLoader().loadRanks(file.toFile())); + assertAll( + () -> assertEquals(previous, RankLoader.getRanks()), + () -> assertSame(previous.getFirst(), fixture.saved("after_reload", "Leader").getRank())); + } + + @Test + void lossOfTheLastTitleNotifiesTheCurrentOnlineLeaderOnce() { + var leader = fixture.player("Leader"); + Faction faction = fixture.saved("realm", "Leader"); + Title west = fixture.title("west", "county", 1, 2); + faction.addTitle(west); + var owned = new ArrayList<>(faction.getTitles()); + west.destroy(faction, List.of(), owned); + assertFalse(faction.hasTitle(west)); + assertTrue(owned.isEmpty()); + verify(leader) + .sendMessage("§cYou lost the title §ewest §cdue to a lack of provinces or required titles"); + west.destroy(faction, List.of(), owned); + verify(leader, times(1)) + .sendMessage("§cYou lost the title §ewest §cdue to a lack of provinces or required titles"); + } + + @Test + void renamingAPersistentModifierPreservesItsIdentityWhenLaterAmountsMerge() { + Faction faction = fixture.saved("realm", "Leader"); + Modifier grant = new Modifier("legacy charter", 5.0, true); + faction.addPersistentPrestigeModifier(grant); + grant.setType("Royal charter"); + faction.addPersistentPrestigeModifier(new Modifier("ROYAL CHARTER", 3.0, true)); + var persistent = + faction.getPrestigeModifiers().stream().filter(Modifier::isPersistent).toList(); + assertEquals(1, persistent.size()); + assertEquals("ROYAL CHARTER", persistent.getFirst().getType()); + assertEquals(8.0, persistent.getFirst().getAmount()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void provinceConfigurationFailuresAreReportedBeforeAnyPartialMapEscapes(boolean neighboursFail) + throws Exception { + Path provinces = directory.resolve("provinces.txt"); + Path neighbours = directory.resolve("neighbours.json"); + Files.writeString( + provinces, neighboursFail ? "1 = 10,64,20;plains;30\n" : "one = 10,64,20;plains;30\n"); + Files.writeString(neighbours, neighboursFail ? "{" : "{}"); + RuntimeException failure = + assertThrows( + RuntimeException.class, + () -> new ProvinceLoader().loadProvinces(provinces.toFile(), neighbours.toFile())); + assertEquals( + neighboursFail ? "Failed to load province neighbours" : "Failed to load provinces file", + failure.getMessage()); + assertNotNull(failure.getCause()); + Files.writeString(provinces, "1 = 10,64,20;plains;30\n"); + Files.writeString(neighbours, "{\"1\":[]}"); + var recovered = new ProvinceLoader().loadProvinces(provinces.toFile(), neighbours.toFile()); + assertEquals(java.util.Set.of(1), recovered.keySet()); + assertEquals(1, recovered.get(1).getId()); + } + + @Test + void guildMembershipQueriesAndForcedRemovalAgreeWithoutChangingLeaders() { + Faction faction = fixture.saved("realm", "Alice"); + faction.addMember("Citizen"); + Guild traders = fixture.guild(faction, "traders", "Bob"); + traders.addMember("Traveler"); + var guilds = faction.getGuildHandler(); + assertSame(traders, guilds.getGuildByMember("traveler")); + assertNull(guilds.getGuildByMember("missing")); + assertTrue(guilds.isGuildLeader("BOB")); + assertFalse(guilds.isGuildLeader("Traveler")); + guilds.forceKick("traveler"); + assertNull(guilds.getGuildByMember("Traveler")); + assertFalse(faction.isMemberIgnoreCase("Traveler")); + assertEquals(List.of("Bob"), traders.getMembers()); + assertEquals("Bob", traders.getLeader()); + assertTrue(faction.isMemberIgnoreCase("Citizen")); + guilds.forceKick("already gone"); + assertTrue(guilds.isGuildLeader("Alice")); + assertTrue(guilds.isGuildLeader("Bob")); + } + + @Test + void currentLawLookupAndRegionalModifiersUseTheSameConfiguredPolicy() { + fixture.lawGroup( + "production", + Map.of( + "effects.domestic_guilds.modifiers", List.of("PRODUCTION(5)"), + "effects.domestic_guilds.foreign_territory", List.of("PRODUCTION(7)"))); + var faction = fixture.saved("realm", "Alice"); + var laws = faction.getLawHandler(); + assertSame(laws.getGroup("production").getCurrent(), laws.getLaw("production", "current")); + assertNull(laws.getLaw("removed_group", "current")); + assertNull(laws.getLaw("production", "removed_law")); + var domestic = laws.getLawModifiers(null, Scope.DOMESTIC_GUILDS, Region.OUR_TERRITORY); + var foreign = laws.getLawModifiers(null, Scope.DOMESTIC_GUILDS, Region.FOREIGN_TERRITORY); + assertEquals(1, domestic.size()); + assertEquals(1, foreign.size()); + assertEquals(FactionModifiers.PRODUCTION, foreign.getFirst().getType()); + assertEquals(5.0, domestic.getFirst().getAmount()); + assertEquals(12.0, foreign.getFirst().getAmount()); + assertTrue( + laws.getLawModifiers(null, Scope.FOREIGN_GUILDS, Region.FOREIGN_TERRITORY).isEmpty()); + } + + private YamlConfiguration guildConfig(String icon, List<String> modifiers) { + var config = new YamlConfiguration(); + config.set("icon", icon); + config.set("allowed-types", List.of("realm", "guild")); + config.set("modifiers", modifiers); + return config; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/objects/FactionCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/objects/FactionCoverageTest.java new file mode 100644 index 00000000..08539682 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/objects/FactionCoverageTest.java @@ -0,0 +1,1133 @@ +package net.tfminecraft.simplefactions.objects; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.database.FactionData; +import net.tfminecraft.simplefactions.database.GovernmentData; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.database.MovementData; +import net.tfminecraft.simplefactions.diplomacy.Relation; +import net.tfminecraft.simplefactions.enums.Brackets; +import net.tfminecraft.simplefactions.enums.FactionModifiers; +import net.tfminecraft.simplefactions.enums.GuildModifier; +import net.tfminecraft.simplefactions.enums.Member; +import net.tfminecraft.simplefactions.enums.Region; +import net.tfminecraft.simplefactions.enums.Rules; +import net.tfminecraft.simplefactions.enums.Scope; +import net.tfminecraft.simplefactions.espionage.EspionageState; +import net.tfminecraft.simplefactions.government.movement.Action; +import net.tfminecraft.simplefactions.government.movement.Movement; +import net.tfminecraft.simplefactions.government.movement.PoliticalAction; +import net.tfminecraft.simplefactions.government.movement.cause.Cause; +import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.government.proposal.Proposal; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.branch.BranchModifier; +import net.tfminecraft.simplefactions.loaders.RankLoader; +import net.tfminecraft.simplefactions.loaders.RelationLoader; +import net.tfminecraft.simplefactions.loaders.TierLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.testsupport.TestRegistryAccess; +import net.tfminecraft.simplefactions.tiers.Title; +import net.tfminecraft.simplefactions.utils.Formatter; +import net.tfminecraft.simplefactions.utils.RandomRGB; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.resolution.WarReparationsObligation; +import org.bukkit.DyeColor; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.block.banner.Pattern; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BannerMeta; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class FactionCoverageTest { + private FactionDomainFixture fixture; + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + } + + @AfterEach + void cleanup() { + if (fixture != null) fixture.close(); + } + + @Test + void savedFactionRetainsIdentityTaxRatesAndRealDomainHandlersAfterJsonRoundTrip() { + FactionData data = fixture.data("home", "Alice"); + Faction faction = + fixture.saved(JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(data), FactionData.class)); + assertEquals("home", faction.getId()); + assertEquals("home", faction.getName()); + assertEquals("Alice", faction.getLeader()); + assertEquals("Governor", faction.getRulerTitle()); + assertEquals("Community", faction.getGovernmentString()); + assertEquals("Riverfolk", faction.getCulture()); + assertEquals("Old Faith", faction.getReligion()); + assertEquals("1,2,3", faction.getRGB()); + assertEquals(List.of("Alice"), faction.getMembers()); + assertTrue(faction.getOrCreateMainGuild().isBase()); + assertSame(faction, faction.getOrCreateMainGuild().getFaction()); + assertEquals(Material.WHITE_BANNER, faction.getBanner().getType()); + assertEquals(List.of("white"), faction.getBannerPatterns()); + assertEquals(5, faction.getTaxRate()); + assertEquals(20, faction.getVassalTaxRate()); + assertEquals(10, faction.getTaxRate(TaxTarget.GUILDS, null, false)); + assertNotNull(faction.getGovernment()); + assertNotNull(faction.getMilitary()); + assertNotNull(faction.getInstallationHandler()); + assertNotNull(faction.getSettlementHandler()); + assertEquals("landless", faction.getTier().getId()); + assertEquals("common", faction.getRank().getId()); + assertTrue(faction.getPrestige() > 0); + assertEquals(0, faction.getWealth()); + } + + @Test + void factionInvitationsAreCaseInsensitiveAndCanBeConsumedExactlyOnce() { + Faction faction = fixture.saved("home", "Alice"); + faction.invite(null); + faction.invite("Bob"); + faction.invite("BOB"); + assertEquals(List.of("Bob"), faction.getInvited()); + assertTrue(faction.isInvited("bob")); + assertFalse(faction.consumeInvite("Cara")); + assertTrue(faction.consumeInvite("bOB")); + assertFalse(faction.consumeInvite("Bob")); + assertTrue(faction.getInvited().isEmpty()); + faction.setInvited(new ArrayList<>(List.of("Cara"))); + assertTrue(faction.isInvited("cara")); + faction.addMember("Bob"); + assertTrue(faction.isMemberIgnoreCase("bob")); + assertFalse(faction.isMemberIgnoreCase("missing")); + } + + @Test + void newFactionSelectsAnUnusedColorAndOnlyAcceptsTheBannerWhileItsPlaceholderIsCurrent() { + try (MockedStatic<RandomRGB> rgb = mockStatic(RandomRGB.class)) { + rgb.when(RandomRGB::random).thenReturn("1,1,1", "2,3,4"); + rgb.when(() -> RandomRGB.isFree("2,3,4")).thenReturn(true); + rgb.when(() -> RandomRGB.similarButDistinct(anyString())).thenReturn("5,6,7"); + rgb.when(() -> RandomRGB.isFree("5,6,7")).thenReturn(true); + Faction faction = new Faction("River_Town", "Alice"); + assertEquals("River_Town", faction.getId()); + assertTrue(faction.getName().endsWith("River Town")); + assertEquals("2,3,4", faction.getRGB()); + assertEquals("Leader", faction.getRulerTitle()); + assertEquals("Multicultural", faction.getCulture()); + assertEquals("Religious Diversity", faction.getReligion()); + assertTrue(faction.getFoundedAt() <= System.currentTimeMillis() / 1000); + assertTrue(faction.getFoundedAt() > System.currentTimeMillis() / 1000 - 10); + assertEquals(1, fixture.bannerCallbacks.size()); + fixture.bannerCallbacks.getFirst().accept(null); + assertEquals(Material.WHITE_BANNER, faction.getBanner().getType()); + fixture.bannerCallbacks.getFirst().accept(new ArrayList<>(List.of("blue"))); + assertEquals(Material.BLUE_BANNER, faction.getBanner().getType()); + faction.setBannerPatterns(new ArrayList<>(List.of("red"))); + fixture.bannerCallbacks.getFirst().accept(List.of("green")); + assertEquals(Material.RED_BANNER, faction.getBanner().getType()); + assertEquals(List.of("Alice"), faction.getMembers()); + } + } + + @Test + void promotingAGuildToAFactionAppliesItsInitialTaxLawLikeEveryOtherFactionConstructor() { + fixture.lawGroup("finance", Map.of("effects.faction.brackets.CITIZEN_TAX", "10-20")); + Faction host = fixture.saved("host", "Alice"); + assertEquals(10, host.getTaxRate()); + Guild guild = fixture.guild(host, "merchants", "Bob"); + guild.rememberLeaderCharacter("Borin", "Bob"); + host.getGuildHandler().removeGuild("merchants", false, false); + try (MockedStatic<RandomRGB> rgb = mockStatic(RandomRGB.class)) { + rgb.when(() -> RandomRGB.isFree(anyString())).thenReturn(true); + Faction promoted = new Faction(guild); + assertEquals(10, promoted.getTaxRate()); + assertEquals("merchants", promoted.getId()); + assertEquals("merchants", promoted.getName()); + assertEquals("Bob", promoted.getLeader()); + assertEquals("Borin", promoted.getLeaderCharacter()); + assertEquals("Bob", promoted.getLeaderCharacterOf()); + assertEquals(host.getCulture(), promoted.getCulture()); + assertEquals(host.getReligion(), promoted.getReligion()); + assertSame(promoted, guild.getFaction()); + assertTrue(guild.isBase()); + assertSame(guild, promoted.getOrCreateMainGuild()); + } + } + + @Test + void persistedSpecificTaxesAndGovernmentPowerSurviveInvalidSiblingTaxCategories() { + FactionData data = fixture.data("home", "Alice"); + data.specificTaxes.put("GUILDS", new HashMap<>(Map.of("merchants", 22.5))); + data.specificTaxes.put("obsolete-category", new HashMap<>(Map.of("ignored", 99.0))); + data.specificTaxes.put("TARIFFS", null); + data.governmentData = new GovernmentData(); + data.governmentData.power = 42.0; + data.prestigeModifiers.add("Foundation(10.0)"); + Faction faction = fixture.saved(data); + assertEquals(22.5, faction.getTaxRate(TaxTarget.GUILD_ID, "merchants", false)); + assertEquals(10, faction.getTaxRate(TaxTarget.GUILD_ID, "other", false)); + assertEquals(30, faction.getTaxRate(TaxTarget.TARIFF_ID, "other", false)); + assertEquals(42, faction.getGovernment().getPower()); + assertTrue( + faction.getPrestigeModifiers().stream() + .anyMatch(m -> m.getType().equals("Foundation") && m.getAmount() == 10)); + } + + @Test + void baseGuildAndOrdinaryGuildMembershipsDetermineLeadershipVotingAndRealmRoles() { + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Cara"); + Guild artisans = fixture.guild(faction, "artisans", "Bob"); + artisans.addMember("Dana"); + assertEquals(List.of("Alice", "Bob", "Cara", "Dana"), faction.getMembers()); + assertEquals(Member.LEADER, faction.getRelationToFaction("Alice")); + assertEquals(Member.MEMBER, faction.getRelationToFaction("Cara")); + assertEquals(Member.GUILD_LEADER, faction.getRelationToFaction("Bob")); + assertEquals(Member.GUILD_MEMBER, faction.getRelationToFaction("Dana")); + assertEquals(Member.FOREIGNER, faction.getRelationToFaction("Visitor")); + assertFalse(faction.isInGuild("Visitor")); + assertFalse(faction.isInGuild("Cara")); + assertTrue(faction.isInGuild("Dana")); + assertFalse(faction.canBecomeLeader("Alice")); + assertFalse(faction.canBecomeLeader("Visitor")); + assertFalse(faction.canBecomeLeader("Bob")); + assertTrue(faction.canBecomeLeader("Dana")); + assertFalse(faction.canBeCleanKicked("Alice")); + assertFalse(faction.canBeCleanKicked("Bob")); + assertTrue(faction.canBeCleanKicked("Dana")); + assertTrue(faction.canVote("Cara")); + assertFalse(faction.canVote("Visitor")); + assertTrue(faction.canRemainLeader("Cara")); + assertFalse(faction.canRemainLeader("Visitor")); + assertSame(artisans, faction.getGuild("Dana")); + faction.promoteToLeader("Visitor"); + assertEquals("Alice", faction.getLeader()); + faction.promoteToLeader("Dana"); + assertEquals("Dana", faction.getLeader()); + assertTrue(faction.getOrCreateMainGuild().isMember("Dana")); + assertFalse(artisans.isMember("Dana")); + assertEquals(4, faction.getMembers().size()); + faction.forceRemoveMember("Cara"); + assertFalse(faction.isMember("Cara")); + } + + @Test + void realmMemberQueriesAndVassalVotingUseTheLiveRelationGraph() { + fixture.lawGroup( + "franchise", Map.of("effects.faction.rules", List.of("VASSAL_VOTING_RIGHTS true"))); + Faction root = fixture.saved("root", "Alice"); + Faction child = fixture.saved("child", "Bob"); + child.addMember("Cara"); + Faction grandchild = fixture.saved("grandchild", "Dana"); + fixture.subject(root, child); + fixture.subject(child, grandchild); + assertSame(root, child.getOverlord()); + assertNull(root.getOverlord()); + assertEquals(List.of(child), root.getSubjects()); + assertEquals(List.of(child), root.getVassals()); + assertTrue(root.hasVassals()); + assertEquals(List.of("Bob", "Cara"), root.getVassalMembers()); + assertEquals(Set.of("Alice", "Bob", "Cara", "Dana"), Set.copyOf(root.getCompleteMemberList())); + assertEquals(Member.VASSAL_LEADER, root.getRelationToFaction("Bob")); + assertEquals(Member.VASSAL_MEMBER, root.getRelationToFaction("Cara")); + assertTrue(root.canVote("Cara")); + assertFalse(root.canVote("Dana")); + assertTrue(child.canDissolve()); + assertTrue(root.canDissolve()); + assertFalse(grandchild.hasVassals()); + child.setWealth(125.25); + assertEquals(125.25, root.getVassalWealth()); + assertEquals(child.getGuildHandler().getTotalTradePower(), root.getVassalTradePower()); + assertEquals( + root.getGuildHandler().getTotalTradePower() + root.getVassalTradePower(), + root.getTotalTradePower()); + root.refreshLawSlots(); + assertSame(root.getRelation("child"), root.getRelations().get("child")); + root.updateRelations(); + } + + @Test + void applyingLocalRulesOverridesInheritedVassalRulesAndExplicitFalseWins() { + fixture.lawGroup( + "constitution", + Map.of( + "effects.vassals.rules", + List.of("CAN_HAVE_VASSALS false"), + "effects.faction.rules", + List.of("CAN_FAVOUR true"))); + Faction root = fixture.saved("root", "Alice"); + Faction child = fixture.saved("child", "Bob"); + fixture.subject(root, child); + assertFalse(child.canHaveVassals()); + assertTrue(child.hasFactionRule(Rules.CAN_FAVOUR)); + assertNull(child.getExplicitRule(Scope.DOMESTIC_GUILDS, Rules.CAN_FAVOUR)); + assertEquals(Boolean.FALSE, root.getExplicitRule(Scope.VASSALS, Rules.CAN_HAVE_VASSALS)); + var group = child.getLawHandler().getGroup("constitution"); + var effect = group.getCurrent().getScopedEffects().get(Scope.FACTION); + effect.getRules().put(Rules.CAN_HAVE_VASSALS, true); + assertTrue(child.canHaveVassals()); + fixture.lawGroup("restriction", Map.of("effects.faction.rules", List.of("CAN_FAVOUR false"))); + Faction restricted = fixture.saved("restricted", "Cara"); + assertFalse(restricted.hasFactionRule(Rules.CAN_FAVOUR)); + assertEquals(Boolean.FALSE, restricted.getExplicitRule(Scope.FACTION, Rules.CAN_FAVOUR)); + } + + @Test + void bannerPatternsRoundTripAcrossVanillaAndCustomRegistriesAndSkipInvalidDecorations() { + var bootstrapPattern = org.bukkit.block.banner.PatternType.STRIPE_TOP; + assertNotNull(bootstrapPattern); + var vanilla = TestRegistryAccess.registerPattern(NamespacedKey.minecraft("stripe_top")); + var custom = TestRegistryAccess.registerPattern(new NamespacedKey("tfmc", "test_pattern")); + Faction faction = fixture.saved("home", "Alice"); + faction.setBannerPatterns( + new ArrayList<>( + List.of( + "blue", + "badformat", + "notacolor.stripe_top", + "red.nonexistent", + "white.stripe_top", + "black.test_pattern"))); + BannerMeta meta = (BannerMeta) faction.getBanner().getItemMeta(); + assertEquals(Material.BLUE_BANNER, faction.getBanner().getType()); + assertEquals( + List.of(new Pattern(DyeColor.WHITE, vanilla), new Pattern(DyeColor.BLACK, custom)), + meta.getPatterns()); + ItemStack replacement = new ItemStack(Material.RED_BANNER); + BannerMeta next = (BannerMeta) replacement.getItemMeta(); + next.addPattern(new Pattern(DyeColor.BLUE, vanilla)); + replacement.setItemMeta(next); + faction.setBanner(replacement); + assertEquals(List.of("RED.BASE", "BLUE.STRIPE_TOP"), faction.getBannerPatterns()); + assertEquals(Material.RED_BANNER, faction.getBanner().getType()); + } + + @Test + void provincesCapitalAndTitlesDriveTheFactionTierWithoutDuplicatingTitles() { + Faction faction = fixture.saved("home", "Alice"); + assertFalse(faction.hasCapital()); + assertEquals(-1, faction.getCapital()); + faction.addProvince(7); + faction.addProvince(7); + faction.addProvince(8); + assertEquals(List.of(7, 8), faction.getProvinces()); + assertTrue(faction.hasProvince(7)); + assertTrue(faction.ownsProvince(8)); + assertEquals("province", faction.getTier().getId()); + faction.setCapital(99); + assertFalse(faction.hasCapital()); + faction.setCapital(7); + assertEquals(7, faction.getCapital()); + faction.setCapital(8, true, false); + assertEquals(8, faction.getCapital()); + Title county = fixture.title("county-a", "county", 7, 8); + Title duchy = fixture.title("duchy-a", "duchy", 7, 8); + faction.addTitle(county); + faction.addTitle(county); + faction.addTitle(duchy); + assertEquals(List.of(county, duchy), faction.getTitles()); + assertEquals(List.of(duchy, county), faction.getRankedTitles()); + assertSame(duchy, faction.getHighestTitle()); + assertEquals("duchy", faction.getTier().getId()); + assertEquals(List.of(county), faction.getTitles(TierLoader.getByString("county"))); + faction.resetTitles(java.util.Arrays.asList(county, county, null, duchy)); + assertEquals(2, faction.getTitles().size()); + faction.stripTitle(duchy); + faction.stripTitle(duchy); + assertEquals("county", faction.getTier().getId()); + faction.removeTitle(county); + faction.removeTitle(county); + assertNull(faction.getHighestTitle()); + assertEquals("province", faction.getTier().getId()); + faction.removeProvince(8, false); + faction.removeProvince(7, false); + assertEquals("landless", faction.getTier().getId()); + verify(fixture.map, atLeastOnce()).enqueue("nation", "1,2,3"); + } + + @Test + void persistentPrestigeModifiersAccumulateReplaceAndRemoveAsSourcesChange() { + Faction faction = fixture.saved("home", "Alice"); + faction.setPrestigeModifiers(new ArrayList<>()); + faction.addPersistentPrestigeModifier(new Modifier("Gift", 3.0, true)); + faction.addPersistentPrestigeModifier(new Modifier("GIFT", 4.0, true)); + assertEquals(7, faction.getPrestigeModifiers().getFirst().getAmount()); + faction.addPersistentPrestigeModifier(new Modifier("gift", -7.0, true)); + faction.addPersistentPrestigeModifier(new Modifier("zero", 0.0, true)); + assertTrue(faction.getPrestigeModifiers().isEmpty()); + faction.setPersistentPrestigeModifier("Foundation", 10); + faction.setPersistentPrestigeModifier("FOUNDATION", 20); + assertEquals(1, faction.getPrestigeModifiers().size()); + assertEquals(20, faction.getPrestigeModifiers().getFirst().getAmount()); + faction.setPersistentPrestigeModifier("foundation", 0); + faction.setPersistentPrestigeModifier("absent", 0); + assertTrue(faction.getPrestigeModifiers().isEmpty()); + faction.updatePrestige(); + assertTrue(faction.getPrestige() > 0); + } + + @Test + void guildBankMutationsRecalculateFactionWealthAndReportItsActualSources() { + Faction faction = fixture.saved("home", "Alice"); + Guild guild = fixture.guild(faction, "artisans", "Bob"); + assertFalse( + faction.getWealthModifiers().stream().anyMatch(m -> m.getType().contains("artisans"))); + guild.getBank().deposit(30.5); + faction.getBank().deposit(20.0); + assertEquals(50.5, faction.getWealth()); + assertTrue( + faction.getWealthModifiers().stream() + .anyMatch(m -> m.getType().contains("artisans") && m.getAmount() == 30.5)); + faction.giveTax("Cara", 4.25); + assertEquals( + Map.of("Cara", 4.25), faction.getOrCreateMainGuild().getLedger().getCitizenTaxesCopy()); + Bank replacement = + new Bank(faction.getOrCreateMainGuild(), 5.0, fixture.ui.world.getChunkAt(0, 0)); + faction.setBank(replacement); + assertSame(replacement, faction.getBank()); + faction.getOrCreateMainGuild().updateWealth(); + assertEquals(35.5, faction.getWealth()); + } + + @Test + void editedFactionIdentityAndGovernmentAreWrittenAsASingleCompletePersistenceSnapshot() + throws Exception { + Faction faction = fixture.saved("home", "Alice"); + faction.setName("River Union"); + faction.setRGB("11,22,33"); + faction.setRulerTitle("Chancellor"); + faction.setGovernment("Republic"); + faction.setCulture("Riverfolk and Coastfolk"); + faction.setReligion("Pluralist"); + faction.setFoundedAt(123456789L); + faction.setCapitalMoves(3); + faction.setExtraNodeCapacity(4); + faction.rememberLeaderCharacter("Alys Rivers", "Alice"); + faction.getGovernment().setPower(12.5); + faction.setPersistentPrestigeModifier("Charter", 17); + faction.addWarReparationsObligation(null); + faction.addWarReparationsObligation(new WarReparationsObligation("neighbor", 12.5, 3)); + List<FactionData> saved = new ArrayList<>(); + try (MockedStatic<JsonUtil> json = mockStatic(JsonUtil.class)) { + json.when(() -> JsonUtil.writeJsonAtomic(any(java.io.File.class), any())) + .thenAnswer( + call -> { + saved.add(call.getArgument(1)); + return null; + }); + assertTrue(new Database().saveFactionChecked(faction)); + } + assertEquals(1, saved.size()); + FactionData persisted = + JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(saved.getFirst()), FactionData.class); + assertEquals("home", persisted.id); + assertEquals("River Union", persisted.name); + assertEquals("11,22,33", persisted.rgb); + assertEquals("Chancellor", persisted.rulerTitle); + assertEquals("Republic", persisted.government); + assertEquals("Riverfolk and Coastfolk", persisted.culture); + assertEquals("Pluralist", persisted.religion); + assertEquals(123456789L, persisted.foundedAt); + assertEquals(3, persisted.capitalMoves); + assertEquals(4, persisted.extraNodeCapacity); + assertEquals("Alys Rivers", persisted.leaderCharacter); + assertEquals("Alice", persisted.leaderCharacterOf); + assertEquals(12.5, persisted.governmentData.power); + assertTrue(persisted.prestigeModifiers.contains("Charter(17.0)")); + assertEquals(1, persisted.warReparationsObligations.size()); + assertEquals("neighbor", persisted.warReparationsObligations.getFirst().payeeFactionId); + assertSame(faction.getEspionage(), saved.getFirst().espionage); + assertTrue(faction.canPurchaseCapacity()); + faction.setExtraNodeCapacity(10); + assertFalse(faction.canPurchaseCapacity()); + } + + @Test + void changingARegisteredFactionIdUpdatesItsIdentityWithoutReplacingItsDomainHandlers() { + Faction faction = fixture.saved("home", "Alice"); + var laws = faction.getLawHandler(); + faction.setId("river_union"); + assertEquals("river_union", faction.getId()); + assertSame(faction, FactionManager.getByString("river_union")); + assertNull(FactionManager.getByString("home")); + assertSame(laws, faction.getLawHandler()); + } + + @Test + void lawChangesClampEveryTaxAndVehicleFeeThenDisabledChargesBecomeZero() { + Map<String, Object> options = new HashMap<>(); + options.put( + "effects.faction.rules", + List.of( + "CITIZEN_TAX true", + "GUILD_TAX true", + "VASSAL_TAX true", + "DIVIDEND_TAX true", + "TARIFFS true", + "VEHICLE_TAX true", + "REGISTRATION_FEE true", + "TRANSFER_FEE true")); + for (var bracket : Brackets.values()) { + options.put("effects.faction.brackets." + bracket.name(), "10-40"); + } + fixture.lawGroup("finance", options); + Faction faction = fixture.saved("home", "Alice"); + assertEquals(10, faction.getTaxRate()); + assertEquals(15, faction.getTaxRate(TaxTarget.DIVIDENDS, null, false)); + assertEquals(20, faction.getVassalTaxRate()); + assertEquals(30, faction.getTaxRate(TaxTarget.TARIFFS, null, false)); + for (var fee : FeeKind.values()) { + assertEquals(10, faction.getVehicleFeeHandler().getRate(fee)); + assertTrue(faction.getVehicleFeeHandler().canCharge(fee)); + faction.getVehicleFeeHandler().setRate(fee, "wagon", 35); + } + faction.getTaxHandler().setTaxRate(TaxTarget.GUILD_ID, "artisans", 30); + var group = faction.getLawHandler().getGroup("finance"); + var disabled = + fixture.law( + "finance", + "disabled", + Map.of( + "effects.faction.rules", + List.of( + "CITIZEN_TAX false", + "GUILD_TAX false", + "VASSAL_TAX false", + "DIVIDEND_TAX false", + "TARIFFS false", + "VEHICLE_TAX false", + "REGISTRATION_FEE false", + "TRANSFER_FEE false"))); + faction.applyLaw(disabled, group); + assertSame(disabled, group.getCurrent()); + for (var target : + List.of( + TaxTarget.CITIZENS, + TaxTarget.GUILDS, + TaxTarget.VASSALS, + TaxTarget.DIVIDENDS, + TaxTarget.TARIFFS)) { + assertEquals(0, faction.getTaxRate(target, null, false)); + } + assertEquals(0, faction.getTaxRate(TaxTarget.GUILDS, "artisans", false)); + for (var fee : FeeKind.values()) { + assertEquals(0, faction.getVehicleFeeHandler().getRate(fee, "wagon")); + assertFalse(faction.getVehicleFeeHandler().canCharge(fee)); + } + } + + @Test + void councilLawChoosesSizeTypeAndCancelsElectionsWhenRightsAreRemoved() { + fixture.lawGroup( + "government", + Map.of( + "effects.faction.rules", + List.of("HAS_COUNCIL true", "ELECTED_COUNCIL true", "LEADER_ELECTIONS true"), + "effects.faction.council-size", + 2)); + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + assertEquals(2, faction.getCouncilSize()); + assertEquals(Rules.ELECTED_COUNCIL, faction.getCouncilType()); + var group = faction.getLawHandler().getGroup("government"); + assertTrue(Faction.enablesElections(group.getCurrent().getScopedEffects().get(Scope.FACTION))); + assertFalse(Faction.enablesElections(null)); + faction.getGovernment().getElection().start(); + faction.ping(); + assertTrue(faction.getGovernment().getElection().isActive()); + var autocracy = + fixture.law( + "government", + "autocracy", + Map.of( + "effects.faction.rules", + List.of("HAS_COUNCIL false", "LEADER_ELECTIONS false", "ELECTED_COUNCIL false"))); + faction.applyLaw(autocracy, group); + assertEquals(0, faction.getCouncilSize()); + assertEquals(Rules.NO_COUNCIL, faction.getCouncilType()); + assertFalse(faction.getGovernment().getElection().isActive()); + assertFalse(Faction.enablesElections(autocracy.getScopedEffects().get(Scope.FACTION))); + } + + @Test + void councilWithoutConfiguredSizeUsesFourSeatsAndUnspecifiedTypeUsesNoCouncil() { + fixture.lawGroup("foreign", Map.of("effects.vassals.rules", List.of("CAN_HAVE_VASSALS true"))); + fixture.lawGroup("government", Map.of("effects.faction.rules", List.of("HAS_COUNCIL true"))); + Faction faction = fixture.saved("home", "Alice"); + assertEquals(4, faction.getCouncilSize()); + assertEquals(Rules.NO_COUNCIL, faction.getCouncilType()); + var noFactionEffect = + fixture.law( + "government", + "foreignonly", + Map.of("effects.vassals.rules", List.of("CAN_HAVE_VASSALS false"))); + faction.applyLaw(noFactionEffect, faction.getLawHandler().getGroup("government")); + assertSame(noFactionEffect, faction.getLawHandler().getGroup("government").getCurrent()); + assertFalse(faction.getGovernment().getElection().isActive()); + } + + @Test + void outlawingVassalageFreesEveryExistingSubject() { + fixture.lawGroup( + "authority", Map.of("effects.faction.rules", List.of("CAN_HAVE_VASSALS true"))); + Faction ruler = fixture.saved("ruler", "Alice"); + Faction child = fixture.saved("child", "Bob"); + fixture.subject(ruler, child); + assertSame(ruler, child.getOverlord()); + var banned = + fixture.law( + "authority", + "banned", + Map.of("effects.faction.rules", List.of("CAN_HAVE_VASSALS false"))); + ruler.applyLaw(banned, ruler.getLawHandler().getGroup("authority")); + assertNull(child.getOverlord()); + assertTrue(ruler.getVassals().isEmpty()); + assertFalse(ruler.canHaveVassals()); + } + + @Test + void dailyArmyUpkeepShrinksPaidSlotsOnlyUntilAffordableAndPreservesFreeSlots() { + fixture.regiment("reserve", false, 0, 0); + fixture.regiment("guard", false, 1, 10); + fixture.regiment("levy", true, 0, 0); + Faction faction = fixture.saved("home", "Alice"); + var guard = faction.getMilitary().getRegiment("guard"); + guard.setFreeSlots(1); + guard.setCurrentSlots(4); + faction.getBank().deposit(25.0); + assertEquals(30, faction.getMilitary().getTotalUpkeep()); + faction.newDay(); + assertEquals(3, guard.getCurrentSlots()); + assertEquals(0, faction.getMilitary().getRegiment("reserve").getCurrentSlots()); + assertEquals(1, guard.getFreeSlots()); + assertEquals(5, faction.getBank().getWealth()); + faction.newDay(); + assertEquals(1, guard.getCurrentSlots()); + assertEquals(5, faction.getBank().getWealth()); + faction.newDay(); + assertEquals(1, guard.getCurrentSlots()); + assertEquals(5, faction.getBank().getWealth()); + } + + @Test + void missingBankRemovesOnlyPaidRegimentSlotsAndStillAllowsDailyHousekeeping() { + fixture.regiment("guard", false, 0, 10); + fixture.regiment("levy", true, 0, 0); + Faction faction = fixture.saved("home", "Alice"); + var guard = faction.getMilitary().getRegiment("guard"); + var levy = faction.getMilitary().getRegiment("levy"); + guard.setFreeSlots(2); + guard.setCurrentSlots(4); + levy.setCurrentSlots(5); + faction.setBank(null); + faction.newDay(); + assertEquals(2, guard.getCurrentSlots()); + assertEquals(5, levy.getCurrentSlots()); + assertNull(faction.getBank()); + } + + @Test + void tickCompletesRealRegimentConstructionAndLeavesAnEmptyQueue() { + fixture.regiment("guard", false, 0, 10); + Faction faction = fixture.saved("home", "Alice"); + var guard = faction.getMilitary().getRegiment("guard"); + faction.getMilitary().addQueueItem(guard, 1); + fixture.provincesEnabled(true); + faction.tick(); + assertEquals(1, guard.getCurrentSlots()); + assertTrue(faction.getMilitary().getQueue().isEmpty()); + assertEquals(5, faction.getTaxRate()); + } + + @Test + void onlineCountProsperityAndCapacityUseTheCurrentRosterAndTerritory() { + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + faction.addMember("Cara"); + fixture.player("Alice"); + var offline = fixture.player("Bob"); + when(offline.isOnline()).thenReturn(false); + fixture.player("Visitor"); + assertEquals(1, faction.numOnline()); + var north = new Province(7, "PLAINS", 1); + var south = new Province(8, "PLAINS", 1); + north.setProsperity(12.345); + south.setProsperity(20.0); + fixture.provinceData.put(7, north); + fixture.provinceData.put(8, south); + faction.addProvince(7); + faction.addProvince(8); + assertEquals(32.35, faction.getProsperity()); + assertEquals(List.of(7, 8), faction.getUntitledProvinces()); + faction.provinceCap(); + assertEquals(List.of(7, 8), faction.getProvinces()); + } + + @Test + void taxMultipliersUseCurrentOverlordAndTradeRegionsAndCombinedModifiersSumTheirSources() { + fixture.lawGroup( + "finance", + Map.of( + "effects.faction.modifiers", + List.of("TAX_MULTIPLIER(50)", "TRADE_POWER(10)"), + "effects.faction.OUR_TERRITORY", + List.of("TRADE_POWER(15)"), + "effects.vassals.modifiers", + List.of("TRADE_POWER(20)"))); + Faction ruler = fixture.saved("ruler", "Alice"); + Faction child = fixture.saved("child", "Bob"); + assertEquals(0, child.getOverlordTaxRate(child)); + fixture.subject(ruler, child); + ruler.getTaxHandler().setTaxRate(TaxTarget.VASSAL_ID, "child", 40); + assertEquals(0.6, child.getOverlordTaxRate(child), 0.0001); + assertEquals( + 1.25, + child.getModifier(FactionModifiers.TRADE_POWER, null, Scope.FACTION, Region.OUR_TERRITORY)); + assertEquals(1.0, child.getModifier(FactionModifiers.LEVY, null, Scope.FACTION, null)); + var combined = child.getCombinedModifiers(); + assertEquals( + 30, + combined.stream() + .filter(m -> m.getType() == FactionModifiers.TRADE_POWER) + .findFirst() + .orElseThrow() + .getAmount()); + assertEquals( + 1, combined.stream().filter(m -> m.getType() == FactionModifiers.TRADE_POWER).count()); + assertEquals(50, child.getModifier(FactionModifiers.TAX_MULTIPLIER).getAmount()); + } + + @Test + void foreignTributeIncludesOnlyExistingRecipientsWithBanksAndCapsCitizenTaxesAtTick() { + fixture.lawGroup("local", Map.of("effects.faction.modifiers", List.of("TRIBUTE(80)"))); + var tributary = + fixture.relationType( + "tributary", Map.of("recieve-modifiers", List.of("TRIBUTE(30)", "TRADE_POWER(10)"))); + Faction payer = fixture.saved("payer", "Alice"); + Faction recipient = fixture.saved("recipient", "Bob"); + Faction bankless = fixture.saved("bankless", "Cara"); + payer.setRelation(recipient, new Relation(tributary, RelationLoader.getDefaultAttitude())); + payer.setRelation(bankless, new Relation(tributary, RelationLoader.getDefaultAttitude())); + bankless.setBank(null); + assertEquals(30, payer.getTotalForeignTaxRate()); + assertEquals(90, payer.setTaxRate(90)); + payer.tick(); + assertEquals(70, payer.getTaxRate()); + assertEquals(30, payer.getTotalForeignTaxRate()); + } + + @Test + void prestigeRanksRiseAndFallWithRealWealthAndPersistentAchievements() { + Faction faction = fixture.saved("home", "Alice"); + var common = RankLoader.getByLevel(1); + var renowned = RankLoader.getByLevel(2); + faction.setRank(common); + faction.setPrestige(123.0); + assertEquals(123, faction.getPrestige()); + faction.setWealth(5000.0); + faction.setPersistentPrestigeModifier("Achievement", 20000); + faction.updatePrestige(); + assertSame(renowned, faction.getRank()); + assertEquals( + 1000, + faction.getPrestigeModifiers().stream() + .filter(m -> m.getType().equals("Wealth")) + .findFirst() + .orElseThrow() + .getAmount()); + faction.setPersistentPrestigeModifier("Achievement", 0); + faction.setWealth(0.0); + faction.updatePrestige(); + assertSame(common, faction.getRank()); + assertTrue(faction.getPrestige() < 10000); + } + + @Test + void subjectPrestigeAndRankAndBranchBonusesAreReflectedExactlyOnce() { + var vassalType = + fixture.relationType( + "prestige-vassal", Map.of("vassal", true, "give-modifiers", List.of("PRESTIGE(10)"))); + Faction ruler = fixture.saved("ruler", "Alice"); + Faction child = fixture.saved("child", "Bob"); + fixture.subject(ruler, child); + ruler.setRelation(child, new Relation(vassalType, RelationLoader.getDefaultAttitude())); + ruler.getRank().getModifiers().add(new FactionModifier(FactionModifiers.TRADE_POWER, 5)); + ruler + .getOrCreateMainGuild() + .getBranches() + .get(0) + .getModifiers() + .put(GuildModifier.PRESTIGE_BONUS, new BranchModifier(10, 0)); + child.updatePrestige(); + double expected = Formatter.formatDouble(child.getPrestige() * 0.1); + assertEquals( + expected, + ruler.getPrestigeModifiers().stream() + .filter(m -> m.getType().equals("Subjects")) + .findFirst() + .orElseThrow() + .getAmount()); + assertEquals(10, ruler.getModifier(FactionModifiers.PRESTIGE_BONUS).getAmount()); + double first = ruler.getPrestige(); + ruler.updatePrestige(); + assertEquals(first, ruler.getPrestige()); + assertEquals(5, ruler.getModifier(FactionModifiers.TRADE_POWER).getAmount()); + } + + @Test + void administrativeAndDiplomaticDeficitsBothContributeToTheFactionPenalty() { + fixture.lawGroup("expensive", Map.of("upkeep", 50)); + Faction faction = fixture.saved("home", "Alice"); + Faction neighbor = fixture.saved("neighbor", "Bob"); + var treaty = fixture.relationType("expensive-treaty", Map.of("cost", 25)); + faction.setRelation(neighbor, new Relation(treaty, RelationLoader.getDefaultAttitude())); + double administration = faction.getGovernment().getMaxPower(); + double diplomacy = faction.getDiplomacyHandler().getAvailableCapacity(); + assertTrue(administration < 0); + assertTrue(diplomacy < 0); + assertEquals(-administration - diplomacy, faction.getPenalty()); + assertTrue( + faction.getModifiers().stream() + .anyMatch(m -> m.getType() == FactionModifiers.PRESTIGE_MALUS && m.getAmount() > 0)); + } + + @Test + void countyLimitKeepsOnlyAsManyTitlesAsMembersAndDropsClaimsWithoutTerritory() { + Faction faction = fixture.saved("home", "Alice"); + faction.addProvince(7); + faction.addProvince(8); + Title first = fixture.title("first", "county", 7); + Title second = fixture.title("second", "county", 8); + faction.addTitle(first); + faction.addTitle(second); + faction.countyCheck(); + assertEquals(1, faction.getTitles().size()); + assertTrue(List.of(first, second).contains(faction.getTitles().getFirst())); + faction.removeProvince(7, false); + faction.removeProvince(8, false); + faction.countyCheck(); + assertTrue(faction.getTitles().isEmpty()); + } + + @Test + void freeTitlesIncludeSubjectTitlesAndARealmLosingTierReleasesHigherTierSubjects() { + Faction ruler = fixture.saved("ruler", "Alice"); + Faction child = fixture.saved("child", "Bob"); + Title duchy = fixture.title("duchy", "duchy", 7); + Title county = fixture.title("county", "county", 8); + ruler.addProvince(7); + ruler.addTitle(duchy); + child.addProvince(8); + child.addTitle(county); + fixture.subject(ruler, child); + assertEquals(List.of(county), ruler.getFreeTitles(TierLoader.getByString("county"))); + assertEquals(List.of(duchy), ruler.getFreeTitles(TierLoader.getByString("duchy"))); + var leader = fixture.player("Alice"); + ruler.stripTitle(duchy); + assertNull(child.getOverlord()); + assertTrue(ruler.getVassals().isEmpty()); + verify(leader).sendMessage(contains("rank difference")); + assertEquals(List.of(7), ruler.getProvinceHandler().getProvinces()); + } + + @Test + void restoringAndResettingEspionageStateKeepsOrClearsThePersistedReportSnapshot() { + Faction faction = fixture.saved("home", "Alice"); + var original = faction.getEspionage(); + var restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(original), EspionageState.class); + faction.setEspionage(restored); + assertSame(restored, faction.getEspionage()); + faction.setEspionage(null); + assertNotNull(faction.getEspionage()); + assertNotSame(restored, faction.getEspionage()); + assertEquals(JsonUtil.GSON.toJson(original), JsonUtil.GSON.toJson(faction.getEspionage())); + } + + @Test + void politicalLeadershipChangesPromoteAnEligibleMemberAndRejectAStaleTarget() { + Faction faction = fixture.saved("home", "Alice"); + faction.addMember("Bob"); + var proposal = new Proposal("Alice", faction.getGovernment()); + faction.applyPoliticalAction(null, proposal); + assertEquals("Alice", faction.getLeader()); + proposal.setPoliticalActionProposal(new PoliticalAction(Action.CHANGE_LEADER)); + proposal.setTarget("Visitor"); + faction.applyPoliticalAction(null, proposal); + assertEquals("Alice", faction.getLeader()); + proposal.setTarget("Bob"); + faction.applyPoliticalAction(null, proposal); + assertEquals("Bob", faction.getLeader()); + assertTrue(faction.isMember("Alice")); + assertTrue(faction.isMember("Bob")); + } + + @Test + void snapshotElectionActionStartsVotingWhileIncompletePoliticalActionsPreserveTheRealm() { + fixture.lawGroup( + "democracy", Map.of("effects.faction.rules", List.of("LEADER_ELECTIONS true"))); + Faction faction = fixture.saved("home", "Alice"); + var proposal = new Proposal("Alice", faction.getGovernment()); + proposal.setPoliticalActionProposal(new PoliticalAction(Action.SNAP_ELECTIONS)); + faction.applyPoliticalAction(null, proposal); + assertTrue(faction.getGovernment().getElection().isActive()); + assertEquals(List.of("Alice"), faction.getGovernment().getElection().getStoredEligibleVoters()); + for (var action : + List.of( + Action.NONE, + Action.LAW_CHANGE, + Action.TAX_CHANGE, + Action.INDEPENDENCE, + Action.NATIONHOOD, + Action.WHITE_PEACE, + Action.SURRENDER)) { + proposal.setPoliticalActionProposal(new PoliticalAction(action)); + proposal.setTarget("removed-war"); + faction.applyPoliticalAction(null, proposal); + assertEquals("Alice", faction.getLeader(), action.toString()); + assertEquals(List.of("Alice"), faction.getMembers(), action.toString()); + assertTrue(FactionManager.factions.contains(faction), action.toString()); + } + assertFalse(faction.canDissolve()); + } + + @Test + void factionBracketFacadeExposesTheEffectiveAppliedTaxAndFeeLawBounds() { + Map<String, Object> options = new HashMap<>(); + for (var kind : Brackets.values()) { + options.put("effects.faction.brackets." + kind.name(), "10-40"); + } + fixture.lawGroup("finance", options); + Faction faction = fixture.saved("home", "Alice"); + assertNull(faction.getBracket(null)); + for (var kind : Brackets.values()) { + Bracket bracket = faction.getBracket(kind); + assertNotNull(bracket, kind.toString()); + assertEquals(10, bracket.getMin(), kind.toString()); + assertEquals(40, bracket.getMax(), kind.toString()); + } + } + + @Test + void promotedFactionRetriesAConflictingMapColorAndKeepsItsLeaderAndRates() { + Faction host = fixture.saved("home", "Alice"); + Guild guild = fixture.guild(host, "merchants", "Bob"); + host.getGuildHandler().removeGuild("merchants", false, false); + try (MockedStatic<RandomRGB> rgb = mockStatic(RandomRGB.class)) { + rgb.when(() -> RandomRGB.isFree("4,5,6")).thenReturn(false); + rgb.when(RandomRGB::random).thenReturn("11,12,13"); + rgb.when(() -> RandomRGB.isFree("11,12,13")).thenReturn(true); + Faction promoted = new Faction(guild); + assertEquals("11,12,13", promoted.getRGB()); + assertEquals("Bob", promoted.getLeader()); + assertEquals(List.of("Bob"), promoted.getMembers()); + promoted.setVassalTaxRate(35); + assertEquals(35, promoted.getVassalTaxRate()); + } + } + + @Test + void inheritedCouncilPermissionWithoutATypeKeepsItsDefaultSizeAndNoElectionType() { + fixture.lawGroup("foreign", Map.of("effects.vassals.rules", List.of("HAS_COUNCIL true"))); + fixture.lawGroup("domestic", Map.of("effects.faction.rules", List.of("CAN_FAVOUR true"))); + Faction ruler = fixture.saved("ruler", "Alice"); + Faction child = fixture.saved("child", "Bob"); + fixture.subject(ruler, child); + assertTrue(child.hasFactionRule(Rules.HAS_COUNCIL)); + assertEquals(4, child.getCouncilSize()); + assertEquals(Rules.NO_COUNCIL, child.getCouncilType()); + assertEquals(Member.FOREIGNER, ruler.getRelationToFaction("Visitor")); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({ + "false,false", + "false,true", + "true,false", + "true,true" + }) + void independenceAndNationhoodMoveTheGuildAndRespectSubjectAndProvinceOwnership( + boolean nationhood, boolean withSeparateCapital) { + fixture.relationType( + "elevation", Map.of("vassal", true, "link", "overlord", "elevation-target", true)); + Faction ruler = fixture.saved("ruler", "Alice"); + Faction child = fixture.saved("child", "Cara"); + connectProvinces(7, 8); + ruler.addProvince(7); + ruler.addProvince(8); + ruler.setCapital(7); + fixture.subject(ruler, child); + Guild guild = fixture.guild(ruler, "merchants", "Bob"); + guild.setCapital(withSeparateCapital ? 8 : 7); + guild.getBank().deposit(37.0); + Faction observer = fixture.saved("observer", "Dana"); + observer.getBank().deposit(2000.0); + double existingShare = + observer.getPrestigeModifiers().stream() + .filter(m -> m.getType().equals("Wealth")) + .findFirst() + .orElseThrow() + .getAmount(); + assertEquals(981.84, existingShare); + assertEquals(withSeparateCapital, guild.canBeElevated(null)); + var action = nationhood ? Action.NATIONHOOD : Action.INDEPENDENCE; + var proposal = new Proposal("Bob", ruler.getGovernment()); + proposal.setPoliticalActionProposal(new PoliticalAction(action)); + var movementData = new MovementData(); + movementData.id = "split"; + movementData.leader = "Bob"; + var movement = new Movement(ruler, movementData); + var cause = new Cause(movement, proposal, "Bob"); + assertEquals(List.of(guild), cause.getPool().getGuilds()); + ruler.applyPoliticalAction(cause, proposal); + Faction result = FactionManager.getByString("merchants"); + assertNotNull(result); + assertNotSame(ruler, result); + assertSame(result, guild.getFaction()); + assertSame(guild, result.getOrCreateMainGuild()); + assertEquals(List.of("Bob"), result.getMembers()); + assertFalse(ruler.isMember("Bob")); + assertEquals(37, result.getBank().getWealth()); + assertEquals(0, ruler.getWealth()); + assertEquals(37, result.getWealth()); + assertEquals( + 18.16, + result.getPrestigeModifiers().stream() + .filter(m -> m.getType().equals("Wealth")) + .findFirst() + .orElseThrow() + .getAmount()); + assertEquals( + existingShare, + observer.getPrestigeModifiers().stream() + .filter(m -> m.getType().equals("Wealth")) + .findFirst() + .orElseThrow() + .getAmount()); + assertEquals(withSeparateCapital ? List.of(8) : List.of(), result.getProvinces()); + assertEquals(!withSeparateCapital, ruler.hasProvince(8)); + assertTrue(ruler.hasProvince(7)); + assertSame(nationhood ? ruler : null, result.getOverlord()); + assertSame(nationhood ? ruler : null, child.getOverlord()); + } + + @Test + void + dissolutionTransfersTerritoryMembersAndPeacefulSubjectsToTheOverlordAndFreesWarParticipants() { + Faction ruler = fixture.saved("ruler", "Alice"); + Faction source = fixture.saved("source", "Bob"); + Faction peaceful = fixture.saved("peaceful", "Cara"); + Faction fighting = fixture.saved("fighting", "Dana"); + Faction enemy = fixture.saved("enemy", "Evan"); + for (int id : List.of(7, 8, 9)) { + var province = new Province(id, "PLAINS", 1); + fixture.provinceData.put(id, province); + } + ruler.addProvince(7); + ruler.setCapital(7); + source.addProvince(8); + source.addProvince(9); + source.setCapital(8); + Guild artisans = fixture.guild(source, "artisans", "Fay"); + artisans.setCapital(9); + source.getBank().deposit(20.0); + artisans.getBank().deposit(30.0); + Guild oldBase = source.getOrCreateMainGuild(); + fixture.subject(ruler, source); + fixture.subject(source, peaceful); + fixture.subject(source, fighting); + WarManager.get().add(new War(1, fighting, enemy)); + assertTrue(WarManager.isAtWar(fighting)); + try (var database = mockConstruction(Database.class)) { + Faction result = source.dissolve(source.getVassals(), source.getGuildHandler().getGuilds()); + assertSame(ruler, result); + assertEquals(1, database.constructed().size()); + verify(database.constructed().getFirst()).deleteFaction(source); + } + assertFalse(FactionManager.factions.contains(source)); + assertEquals(Set.of(7, 8, 9), Set.copyOf(ruler.getProvinces())); + assertTrue(source.getProvinces().isEmpty()); + assertSame(ruler, oldBase.getFaction()); + assertFalse(oldBase.isBase()); + assertEquals(20, oldBase.getBank().getWealth()); + assertSame(ruler, artisans.getFaction()); + assertEquals(30, artisans.getBank().getWealth()); + assertEquals(List.of("Alice", "Bob", "Fay"), ruler.getMembers()); + assertEquals(50, ruler.getWealth()); + assertSame(ruler, peaceful.getOverlord()); + assertNull(fighting.getOverlord()); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) + void anIndependentRealmDissolvesItsGuildsAndSubjectsWhilePreservingItsBaseRealm( + boolean withSeparateCapital) { + Faction ruler = fixture.saved("ruler", "Alice"); + Faction subject = fixture.saved("subject", "Cara"); + connectProvinces(7, 8); + ruler.addProvince(7); + ruler.addProvince(8); + ruler.setCapital(7); + fixture.subject(ruler, subject); + Guild guild = fixture.guild(ruler, "merchants", "Bob"); + guild.setCapital(withSeparateCapital ? 8 : 7); + guild.getBank().deposit(12.0); + var proposal = new Proposal("Alice", ruler.getGovernment()); + proposal.setPoliticalActionProposal(new PoliticalAction(Action.DISSOLVE)); + ruler.applyPoliticalAction(null, proposal); + assertTrue(FactionManager.factions.contains(ruler)); + assertEquals(List.of("Alice"), ruler.getMembers()); + Faction released = FactionManager.getByString("merchants"); + assertNotNull(released); + assertSame(released, guild.getFaction()); + assertEquals(12, guild.getBank().getWealth()); + assertEquals(withSeparateCapital ? List.of(8) : List.of(), released.getProvinces()); + assertNull(subject.getOverlord()); + assertNull(released.getOverlord()); + assertSame(ruler, ruler.dissolve(List.of(), ruler.getGuildHandler().getGuilds())); + } + + private void connectProvinces(int... ids) { + for (int id : ids) { + var province = new Province(id, "PLAINS", 1); + for (int neighbor : ids) if (neighbor != id) province.addNeighbour(neighbor); + fixture.provinceData.put(id, province); + } + } + + @Test + void promotedGuildWealthIsImmediatelyVisibleInTheNewFactionAndItsPrestigeBreakdown() { + Faction old = fixture.saved("old", "Alice"); + Guild guild = fixture.guild(old, "merchants", "Bob"); + guild.getBank().deposit(37.0); + assertEquals(37, guild.getWealth()); + old.getGuildHandler().removeGuild("merchants", false, false); + Faction promoted = new Faction(guild); + FactionManager.addFaction(promoted); + assertEquals(37, promoted.getBank().getWealth()); + assertEquals(37, promoted.getWealth()); + assertEquals( + 37, + promoted.getPrestigeModifiers().stream() + .filter(m -> m.getType().equals("Wealth")) + .findFirst() + .orElseThrow() + .getAmount()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/objects/handler/ProvinceClaimsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/objects/handler/ProvinceClaimsCoverageTest.java new file mode 100644 index 00000000..efb369ab --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/objects/handler/ProvinceClaimsCoverageTest.java @@ -0,0 +1,336 @@ +package net.tfminecraft.simplefactions.objects.handler; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.enums.Terrain; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.ProvinceManager; +import net.tfminecraft.simplefactions.managers.TitleManager; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +class ProvinceClaimsCoverageTest { + private FactionDomainFixture fixture; + private ProvinceManager manager; + private Map<Integer, Province> provinces; + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + manager = new ProvinceManager(); + provinces = new LinkedHashMap<>(); + manager.start(provinces); + when(fixture.ui.plugin.getProvinceManager()).thenReturn(manager); + } + + @AfterEach + void close() { + if (fixture != null) fixture.close(); + } + + private Province province(int id, Terrain terrain) { + Province province = new Province(id, terrain.name(), 50); + provinces.put(id, province); + return province; + } + + private void link(Province first, Province second) { + first.addNeighbour(second.getId()); + second.addNeighbour(first.getId()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void firstClaimStillRejectsLocationsWithoutARealProvince(boolean relocating) { + Faction faction = fixture.saved("home", "Alice"); + ProvinceHandler claims = faction.getProvinceHandler(); + Guild guild = fixture.guild(faction, "merchants", "Bob"); + assertFalse(relocating ? claims.canClaim(999, false, guild) : claims.canClaim(999, false)); + assertEquals("§cThis location has no province.", claims.getClaimDeniedReason(999, false)); + assertTrue(faction.getProvinces().isEmpty()); + } + + @Test + void claimExplanationAgreesThatTheFirstValidProvinceNeedsNoExistingBorder() { + Faction faction = fixture.saved("home", "Alice"); + province(1, Terrain.PLAINS); + assertTrue(faction.getProvinceHandler().canClaim(1, false)); + assertEquals("Success", faction.getProvinceHandler().getClaimDeniedReason(1, false)); + assertTrue(faction.getProvinces().isEmpty()); + } + + @ParameterizedTest + @EnumSource( + value = Terrain.class, + names = {"WATER", "SEA"}) + void excludingAnUnrelatedGuildCapitalPreservesValidBridgeClaims(Terrain bridge) { + Faction faction = fixture.saved("home", "Alice"); + Province capital = province(1, Terrain.PLAINS); + Province water = province(2, bridge); + Province destination = province(3, Terrain.PLAINS); + province(10, Terrain.PLAINS); + link(capital, water); + link(water, destination); + faction.addProvince(1); + faction.addProvince(10); + faction.setCapital(1, true); + Guild guild = fixture.guild(faction, "merchants", "Bob"); + guild.setCapital(10); + assertTrue(faction.getProvinceHandler().canClaim(3, false)); + assertTrue(faction.getProvinceHandler().canClaim(3, false, guild)); + assertEquals(10, guild.getCapital()); + assertFalse(faction.hasProvince(3)); + } + + @Test + void persistedClaimsDiscardAlreadyOwnedLandAndKeepTheirOwnIndependentList() { + Faction owner = fixture.saved("owner", "Alice"); + owner.addProvince(1); + Faction newcomer = fixture.saved("newcomer", "Bob"); + List<Integer> loaded = new ArrayList<>(List.of(1, 2, 3)); + ProvinceHandler claims = new ProvinceHandler(newcomer, 2, loaded); + loaded.clear(); + assertEquals(List.of(2, 3), claims.getProvinces()); + assertEquals(2, claims.getCapital()); + assertTrue(claims.hasCapital()); + assertFalse(claims.hasProvince(1)); + assertSame(owner, TitleManager.getByProvince(1)); + } + + @Test + void capitalMovesRespectOwnershipAndReleaseAnUnpopulatedFormerSettlement() { + Faction home = fixture.saved("home", "Alice"); + ProvinceHandler claims = home.getProvinceHandler(); + province(1, Terrain.PLAINS); + province(2, Terrain.PLAINS); + home.addProvince(1); + home.addProvince(2); + assertTrue(home.getSettlementHandler().found("Old", 1, 0, 0).isSuccess()); + claims.setCapital(1, false); + claims.setCapital(999, false); + assertEquals(1, claims.getCapital()); + claims.setCapital(1, false); + assertNotNull(home.getSettlementHandler().getByProvince(1)); + claims.setCapital(2, false); + assertEquals(2, claims.getCapital()); + assertNull(home.getSettlementHandler().getByProvince(1)); + assertTrue(home.getSettlementHandler().found("New", 2, 0, 0).isSuccess()); + claims.setCapital(-1, false, false); + assertFalse(claims.hasCapital()); + assertNotNull(home.getSettlementHandler().getByProvince(2)); + } + + @Test + void losingLandDropsItsTitleAndSettlementButRetainsOtherClaims() { + Faction home = fixture.saved("home", "Alice"); + province(1, Terrain.PLAINS); + province(2, Terrain.PLAINS); + home.addProvince(1); + home.addProvince(2); + home.addProvince(2); + var title = fixture.title("county", "county", 1); + home.addTitle(title); + home.getSettlementHandler().found("Town", 1, 0, 0); + assertTrue(home.hasTitle(title)); + home.getProvinceHandler().removeProvince(1, true); + assertEquals(List.of(2), home.getProvinces()); + assertFalse(home.hasTitle(title)); + assertNull(home.getSettlementHandler().getByProvince(1)); + home.getProvinceHandler().removeProvince(999, true); + assertEquals(List.of(2), home.getProvinces()); + } + + @Test + void untitledLandIncludesDirectSubjectsWithoutCountingTheirDeJureTitles() { + Faction home = fixture.saved("home", "Alice"); + Faction subject = fixture.saved("subject", "Bob"); + fixture.subject(home, subject); + home.addProvince(1); + home.addProvince(2); + subject.addProvince(3); + subject.addProvince(4); + fixture.title("home_title", "county", 1); + fixture.title("subject_title", "county", 3); + assertEquals(List.of(2, 4), home.getProvinceHandler().getUntitledProvinces()); + } + + @Test + void provinceCapRemovesANoncapitalAndNeverTheSoleCapital() { + int previousCost = Cache.provinceCost; + try { + Cache.provinceCost = 1_000_000; + Faction home = fixture.saved("home", "Alice"); + home.addProvince(1); + home.addProvince(2); + home.setCapital(2, true); + assertTrue(TitleManager.overProvinceCap(home)); + home.getProvinceHandler().provinceCap(); + assertEquals(List.of(2), home.getProvinces()); + home.getProvinceHandler().provinceCap(); + assertEquals(List.of(2), home.getProvinces()); + home.addProvince(3); + home.getProvinceHandler().provinceCap(); + assertEquals(List.of(2), home.getProvinces()); + } finally { + Cache.provinceCost = previousCost; + } + } + + @Test + void relocationCannotUseTheLeavingGuildCapitalOrTheLandBehindIt() { + Faction home = fixture.saved("home", "Alice"); + Province capital = province(1, Terrain.PLAINS); + Province leaving = province(2, Terrain.PLAINS); + Province frontier = province(3, Terrain.PLAINS); + Province destination = province(4, Terrain.PLAINS); + link(capital, leaving); + link(leaving, frontier); + link(frontier, destination); + for (int id : List.of(1, 2, 3)) home.addProvince(id); + home.setCapital(1, true); + Guild departing = fixture.guild(home, "departing", "Bob"); + departing.setCapital(2); + assertTrue(home.getProvinceHandler().canClaim(4, false)); + assertFalse(home.getProvinceHandler().canClaim(4, false, departing)); + Guild independent = fixture.guild(home, "independent", "Cara"); + independent.setCapital(3); + assertTrue(home.getProvinceHandler().canClaim(4, false, departing)); + assertEquals(List.of(1, 2, 3), home.getProvinces()); + assertEquals(2, departing.getCapital()); + } + + @Test + void relocationCanCrossAConnectedSeaButCannotUseLandAsASeaBridge() { + Faction home = fixture.saved("home", "Alice"); + Province capital = province(1, Terrain.PLAINS); + Province sea = province(2, Terrain.SEA); + Province water = province(3, Terrain.WATER); + Province coast = province(4, Terrain.PLAINS); + Province inland = province(5, Terrain.PLAINS); + Province unrelated = province(10, Terrain.PLAINS); + link(capital, sea); + link(sea, water); + link(water, coast); + link(coast, inland); + home.addProvince(1); + home.addProvince(10); + home.setCapital(1, true); + Guild departing = fixture.guild(home, "departing", "Bob"); + departing.setCapital(unrelated.getId()); + assertFalse(home.getProvinceHandler().canClaim(4, false, departing)); + assertTrue(home.getProvinceHandler().canClaim(4, true, departing)); + assertFalse(home.getProvinceHandler().canClaim(5, true, departing)); + assertTrue(home.getProvinceHandler().canClaim(4, true)); + assertFalse(home.getProvinceHandler().canClaim(5, true)); + assertEquals("Success", home.getProvinceHandler().getClaimDeniedReason(4, true)); + assertEquals( + "§cThis province does not border your current realm.", + home.getProvinceHandler().getClaimDeniedReason(4, false)); + assertEquals( + "§cThis province must be connected to your realm by land or by sea.", + home.getProvinceHandler().getClaimDeniedReason(5, true)); + assertEquals( + "§cThis province is already part of your realm.", + home.getProvinceHandler().getClaimDeniedReason(1, true)); + assertFalse(home.getProvinceHandler().canClaim(1, true, departing)); + } + + @Test + void relocationOfTheMainGuildUsesTheOrdinaryFactionBorderAndFirstClaimRules() { + Faction home = fixture.saved("home", "Alice"); + Province capital = province(1, Terrain.PLAINS); + Province next = province(2, Terrain.PLAINS); + link(capital, next); + Guild main = home.getOrCreateMainGuild(); + assertTrue(home.getProvinceHandler().canClaim(1, false, main)); + home.addProvince(1); + home.setCapital(1, true); + assertTrue(home.getProvinceHandler().canClaim(2, false, main)); + assertEquals("Success", home.getProvinceHandler().getClaimDeniedReason(2, false)); + assertFalse(home.getProvinceHandler().canClaim(1, false)); + } + + @Test + void revalidationDropsDisconnectedInlandClaimsAndInvalidGuildCapitals() { + Faction home = fixture.saved("home", "Alice"); + Province capital = province(1, Terrain.PLAINS); + Province connected = province(2, Terrain.PLAINS); + province(10, Terrain.PLAINS); + province(11, Terrain.WATER); + link(capital, connected); + for (int id : List.of(1, 2, 10, 11, 999)) home.addProvince(id); + home.setCapital(1, true); + Guild connectedGuild = fixture.guild(home, "connected", "Bob"); + connectedGuild.setCapital(2); + Guild disconnected = fixture.guild(home, "disconnected", "Cara"); + disconnected.setCapital(10); + Guild invalid = fixture.guild(home, "invalid", "Dave"); + invalid.setCapital(11); + Guild absent = fixture.guild(home, "absent", "Eve"); + absent.setCapital(800); + fixture.guild(home, "landless", "Frank"); + home.getProvinceHandler().revalidateClaims(); + assertEquals(List.of(1, 2), home.getProvinces()); + assertEquals(2, connectedGuild.getCapital()); + assertFalse(disconnected.hasCapital()); + assertFalse(invalid.hasCapital()); + assertFalse(absent.hasCapital()); + } + + @Test + void capitalPreviewReturnsSortedLossesWithoutMutatingAnyCapitalOrClaim() { + Faction home = fixture.saved("home", "Alice"); + Province one = province(1, Terrain.PLAINS); + Province two = province(2, Terrain.PLAINS); + Province five = province(5, Terrain.PLAINS); + Province four = province(4, Terrain.PLAINS); + link(one, two); + link(five, four); + for (int id : List.of(2, 1, 5, 4)) home.addProvince(id); + home.setCapital(1, true); + assertEquals(List.of(1, 2), home.getProvinceHandler().previewProvincesLostIfCapitalMoved(5)); + assertEquals(List.of(2, 1, 5, 4), home.getProvinces()); + assertEquals(1, home.getCapital()); + assertEquals(List.of(), home.getProvinceHandler().previewProvincesLostIfCapitalMoved(1)); + home.getProvinceHandler().setCapital(-1, false, false); + assertEquals(List.of(), home.getProvinceHandler().previewProvincesLostIfCapitalMoved(5)); + } + + @Test + void aNewHandlerStartsUnclaimedAndAnIndependentLandRouteRemainsAvailableDuringRelocation() { + Faction home = fixture.saved("home", "Alice"); + ProvinceHandler empty = new ProvinceHandler(home); + assertFalse(empty.hasCapital()); + assertEquals(-1, empty.getCapital()); + assertTrue(empty.getProvinces().isEmpty()); + assertFalse(empty.hasProvince(1)); + Province first = province(1, Terrain.PLAINS); + Province middle = province(2, Terrain.PLAINS); + Province edge = province(3, Terrain.PLAINS); + Province destination = province(4, Terrain.PLAINS); + province(10, Terrain.PLAINS); + link(first, middle); + link(middle, edge); + link(edge, destination); + for (int id : List.of(1, 2, 3, 10)) home.addProvince(id); + home.setCapital(1, true); + Guild leaving = fixture.guild(home, "leaving", "Bob"); + leaving.setCapital(10); + assertTrue(home.getProvinceHandler().canClaim(4, false, leaving)); + assertEquals(10, leaving.getCapital()); + assertFalse(home.hasProvince(4)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxLifecycleCoverageTest.java new file mode 100644 index 00000000..f606332b --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/objects/handler/TaxLifecycleCoverageTest.java @@ -0,0 +1,262 @@ +package net.tfminecraft.simplefactions.objects.handler; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.IncomePreviewContext; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.objects.Bracket; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.bukkit.ChatColor; +import org.bukkit.entity.Player; +import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; + +class TaxLifecycleCoverageTest { + private FactionDomainFixture fixture; + private Faction faction; + + @BeforeEach + void setUp() { + fixture = new FactionDomainFixture(); + fixture.player("Leader"); + faction = fixture.saved("home", "Leader"); + } + + @AfterEach + void tearDown() { + IncomePreviewContext.clear(); + fixture.close(); + } + + @ParameterizedTest + @CsvSource({"GUILDS,GUILD_ID", "VASSALS,VASSAL_ID", "TARIFFS,TARIFF_ID"}) + void specificTaxEditsRespectTheSameLawBracketAsTheirCategory( + TaxTarget category, TaxTarget specific) { + TaxHandler taxes = faction.getTaxHandler(); + Bracket bracket = new Bracket(5.0, 25.0); + assertEquals("[5.0-25.0]", ChatColor.stripColor(bracket.getString())); + taxes.applyBracket(category, bracket); + assertTrue(taxes.canCollectTax(specific)); + assertEquals(5.0, taxes.getMin(category)); + assertEquals(25.0, taxes.getMax(category)); + assertEquals(5.0, taxes.getMin(specific), "Specific rates must not bypass the law's minimum"); + assertEquals(25.0, taxes.getMax(specific), "Specific rates must not bypass the law's maximum"); + assertSame(bracket, taxes.getBracket(specific)); + } + + @ParameterizedTest + @CsvSource({"GUILDS,GUILD_ID", "VASSALS,VASSAL_ID", "TARIFFS,TARIFF_ID"}) + void outOfBracketSpecificTaxChatKeepsTheProposalPending(TaxTarget category, TaxTarget specific) { + TaxHandler taxes = faction.getTaxHandler(); + taxes.applyBracket(category, new Bracket(5, 25)); + double original = taxes.getTaxRate(specific, "target", false); + InventoryManager inventory = new InventoryManager(); + Player leader = fixture.online.get("Leader"); + inventory.setChanging(faction, leader, specific, "target"); + for (String message : List.of("4.99", "25.01")) { + AsyncPlayerChatEvent event = mock(AsyncPlayerChatEvent.class); + when(event.getMessage()).thenReturn(message); + inventory.taxChat(leader, event); + assertTrue(inventory.chatTrigger(leader)); + assertEquals(original, taxes.getTaxRate(specific, "target", false)); + assertTrue( + faction.getGovernment().getCouncil().getProposalHandler().getProposals().isEmpty()); + } + verify(leader).sendMessage(contains("minimum tax rate")); + verify(leader).sendMessage(contains("maximum tax rate")); + } + + @Test + void snapshotRestoresAllRatesAndIndependentOverridesExactlyOnce() { + TaxHandler taxes = faction.getTaxHandler(); + taxes.setCitizenTax(11.0); + taxes.setGuildTax(22.0); + taxes.setVassalTax(33.0); + taxes.setDividendTax(44.0); + taxes.setTariffs(55.0); + taxes.setTaxRate(TaxTarget.GUILD_ID, "merchant", 12.0); + taxes.setTaxRate(TaxTarget.VASSAL_ID, "duchy", 23.0); + taxes.setTaxRate(TaxTarget.TARIFF_ID, "ally", 34.0); + Map<TaxTarget, HashMap<String, Double>> original = new HashMap<>(); + taxes.getSpecificTaxes().forEach((key, value) -> original.put(key, new HashMap<>(value))); + taxes.saveState(); + HashMap<String, Double> displaced = taxes.getSpecificTaxes().get(TaxTarget.GUILDS); + displaced.put("merchant", 99.0); + for (TaxTarget category : + List.of( + TaxTarget.CITIZENS, + TaxTarget.GUILDS, + TaxTarget.VASSALS, + TaxTarget.DIVIDENDS, + TaxTarget.TARIFFS)) taxes.setTaxRate(category, null, 0); + taxes.setTaxRate(TaxTarget.GUILD_ID, "temporary", 98.0); + taxes.restoreState(); + assertEquals( + List.of(11.0, 22.0, 33.0, 44.0, 55.0), + List.of( + taxes.getCitizenTax(), + taxes.getGuildTax(), + taxes.getVassalTax(), + taxes.getDividendTax(), + taxes.getTariffs())); + assertEquals(original, taxes.getSpecificTaxes()); + displaced.put("merchant", 97.0); + assertEquals(12.0, taxes.getSpecificTax(TaxTarget.GUILDS, "merchant")); + taxes.setGuildTax(40.0); + taxes.restoreState(); + assertEquals(40.0, taxes.getGuildTax(), "A consumed snapshot must not roll back a later edit"); + } + + @Test + void loadingRedundantLegacyCitizenAndDividendOverridesKeepsTheScalarRates() { + var data = fixture.data("legacy", "LegacyLeader"); + data.citizenTax = 12.5; + data.dividendTax = 22.5; + data.specificTaxes.put("CITIZENS", new HashMap<>(Map.of("legacy-player", 12.5))); + data.specificTaxes.put("DIVIDENDS", new HashMap<>(Map.of("legacy-guild", 22.5))); + TaxHandler taxes = fixture.saved(data).getTaxHandler(); + assertEquals(12.5, taxes.getCitizenTax()); + assertEquals(22.5, taxes.getDividendTax()); + assertTrue(taxes.getSpecificTaxes().isEmpty()); + } + + @ParameterizedTest + @CsvSource({"GUILDS,GUILD_ID", "VASSALS,VASSAL_ID", "TARIFFS,TARIFF_ID"}) + void returningAnOverrideToTheDefaultRemovesOnlyThatOverride( + TaxTarget category, TaxTarget specific) { + TaxHandler taxes = faction.getTaxHandler(); + taxes.setTaxRate(category, null, 20.0); + taxes.setTaxRate(specific, "absent", 20.0); + assertFalse(taxes.getSpecificTaxes().containsKey(category)); + taxes.setTaxRate(specific, "first", 5.0); + taxes.setTaxRate(specific, "second", 15.0); + assertEquals(5.0, taxes.getTaxRate(category, "first", false)); + assertEquals(5.0, taxes.getTaxRate(specific, "first", false)); + assertEquals(20.0, taxes.getTaxRate(specific, null, false)); + assertEquals(20.0, taxes.getTaxRate(specific, "absent", false)); + taxes.setTaxRate(specific, "first", 20.0); + assertEquals(Map.of("second", 15.0), taxes.getSpecificTaxes().get(category)); + assertEquals(-1.0, taxes.getSpecificTax(category, "first")); + taxes.setTaxRate(specific, "second", 20.0); + assertFalse(taxes.getSpecificTaxes().containsKey(category)); + assertEquals(20.0, taxes.getTaxRate(category, "second", false)); + } + + @ParameterizedTest + @CsvSource({"GUILDS,GUILD_ID", "VASSALS,VASSAL_ID", "TARIFFS,TARIFF_ID"}) + void aLawBracketClampsOverridesAndDropsOnlyThoseEqualToTheNewDefault( + TaxTarget category, TaxTarget specific) { + TaxHandler taxes = faction.getTaxHandler(); + taxes.setTaxRate(category, null, 40.0); + taxes.setTaxRate(specific, "low", 2.0); + taxes.setTaxRate(specific, "high", 90.0); + taxes.setTaxRate(specific, "middle", 17.5); + taxes.applyBracket(category, new Bracket(5.0, 25.0)); + assertEquals(25.0, taxes.getTaxRate(category, null, false)); + assertEquals(Map.of("low", 5.0, "middle", 17.5), taxes.getSpecificTaxes().get(category)); + assertEquals(25.0, taxes.getTaxRate(specific, "high", false)); + taxes.applyBracket(category, new Bracket(0, 0)); + assertFalse(taxes.getSpecificTaxes().containsKey(category)); + assertEquals(0.0, taxes.getTaxRate(category, null, false)); + } + + @ParameterizedTest + @EnumSource( + value = TaxTarget.class, + names = {"CITIZENS", "DIVIDENDS"}) + void citizenAndDividendLawsClampBothEndsWithoutChangingOtherTaxes(TaxTarget target) { + TaxHandler taxes = faction.getTaxHandler(); + taxes.setTaxRate(target, null, 4.0); + taxes.applyBracket(target, new Bracket(10, 30)); + assertEquals(10.0, taxes.getTaxRate(target, null, false)); + taxes.setTaxRate(target, null, 99.0); + taxes.applyBracket(target, new Bracket(10, 30)); + assertEquals(30.0, taxes.getTaxRate(target, null, false)); + assertEquals(20.0, taxes.getVassalTax()); + } + + @ParameterizedTest + @EnumSource(TaxTarget.class) + void permittedTaxTargetsDefaultToTheFullRange(TaxTarget target) { + TaxHandler taxes = faction.getTaxHandler(); + assertTrue(taxes.canCollectTax(target)); + assertNull(taxes.getBracket(target)); + assertEquals(0.0, taxes.getMin(target)); + assertEquals(100.0, taxes.getMax(target)); + } + + @ParameterizedTest + @CsvSource({ + "CITIZENS,CITIZEN_TAX", + "GUILDS,GUILD_TAX", + "GUILD_ID,GUILD_TAX", + "VASSALS,VASSAL_TAX", + "VASSAL_ID,VASSAL_TAX", + "DIVIDENDS,DIVIDEND_TAX", + "TARIFFS,TARIFFS", + "TARIFF_ID,TARIFFS" + }) + void disabledTaxRulesHideBothLimitsEvenWhenABracketWasLoaded(TaxTarget target, String rule) { + fixture.lawGroup("limits", Map.of("effects.faction.rules", List.of(rule + " false"))); + Faction restricted = fixture.saved("restricted", "RestrictedLeader"); + TaxHandler taxes = restricted.getTaxHandler(); + taxes.applyBracket(target, new Bracket(5, 25)); + assertFalse(taxes.canCollectTax(target)); + assertEquals(0.0, taxes.getMin(target)); + assertEquals(0.0, taxes.getMax(target)); + } + + @Test + void effectiveTaxUsesTheCurrentGovernmentAndPreviewDoesNotMutateTheRate() { + TaxHandler taxes = faction.getTaxHandler(); + taxes.setCitizenTax(12.75); + double efficiency = faction.getGovernment().getTaxEfficiency(); + assertEquals( + Math.round(12.75 * efficiency * 100.0) / 100.0, + taxes.getTaxRate(TaxTarget.CITIZENS, null, true)); + IncomePreviewContext.open(IncomePreviewContext.tax(faction, TaxTarget.CITIZENS, null, 37.5)); + assertEquals(37.5, taxes.getTaxRate(TaxTarget.CITIZENS, null, false)); + assertEquals(12.75, taxes.getCitizenTax()); + IncomePreviewContext.clear(); + assertEquals(12.75, taxes.getTaxRate(TaxTarget.CITIZENS, null, false)); + } + + @Test + void taxChangePreviewConservesRealGuildBalancesAndTaxIncome() { + Guild realm = faction.getOrCreateMainGuild(); + Guild merchants = fixture.guild(faction, "merchants", "Merchant"); + realm.getBank().deposit(100.0); + merchants.getBank().deposit(100.0); + merchants.getTradeBreakdown().setIncome(200.0); + faction.getTaxHandler().setGuildTax(10.0); + faction.getGovernment().setPower(faction.getGovernment().getMaxPower()); + double efficiency = faction.getGovernment().getTaxEfficiency(); + double beforeRealm = realm.getLedger().getNetIncome(); + double beforeMerchants = merchants.getLedger().getNetIncome(); + Map<Guild, Double> deltas = + faction.getTaxHandler().getTaxChangeEffects(TaxTarget.GUILDS, null, 25.0); + double extraTax = Math.round(200.0 * 0.15 * efficiency * 100.0) / 100.0; + assertTrue(extraTax > 0); + assertEquals(extraTax, deltas.get(realm)); + assertEquals(-extraTax, deltas.get(merchants)); + assertEquals(0.0, deltas.values().stream().mapToDouble(Double::doubleValue).sum(), 0.0001); + assertEquals(100.0, realm.getBank().getWealth()); + assertEquals(100.0, merchants.getBank().getWealth()); + assertEquals(beforeRealm, realm.getLedger().getNetIncome()); + assertEquals(beforeMerchants, merchants.getLedger().getNetIncome()); + assertEquals(10.0, faction.getTaxHandler().getGuildTax()); + assertNull(IncomePreviewContext.current()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/objects/handler/VehicleFeeHandlerTest.java b/src/test/java/net/tfminecraft/simplefactions/objects/handler/VehicleFeeHandlerTest.java index 7e1b53ee..f7edece0 100644 --- a/src/test/java/net/tfminecraft/simplefactions/objects/handler/VehicleFeeHandlerTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/objects/handler/VehicleFeeHandlerTest.java @@ -100,4 +100,19 @@ void savedRatesAreReclampedOnLoad() { assertEquals(12.0, handler.getRate(FeeKind.VEHICLE_TAX, "cruiser")); assertEquals(Map.of("VEHICLE_TAX", 15.0), handler.serializeRates()); } + + @Test + void absentBracketsAndIncompleteSavedOverridesPreserveValidRates() { + handler.setRate(FeeKind.VEHICLE_TAX, "unknown", 0.0); + handler.applyBracket(FeeKind.VEHICLE_TAX, null); + assertFalse(handler.canCharge(FeeKind.VEHICLE_TAX)); + Map<String, Map<String, Double>> saved = new HashMap<>(); + saved.put(null, Map.of("cart", 3.0)); + saved.put("TRANSFER_FEE", null); + saved.put("VEHICLE_TAX", Map.of("cart", 2.0)); + handler.load(Map.of(), saved); + assertEquals(2.0, handler.getRate(FeeKind.VEHICLE_TAX, "cart")); + assertFalse(handler.hasTypeRate(FeeKind.TRANSFER_FEE, "cart")); + } + } diff --git a/src/test/java/net/tfminecraft/simplefactions/prestige/TradePrestigeTest.java b/src/test/java/net/tfminecraft/simplefactions/prestige/TradePrestigeTest.java index 8f05d47f..3349992d 100644 --- a/src/test/java/net/tfminecraft/simplefactions/prestige/TradePrestigeTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/prestige/TradePrestigeTest.java @@ -76,4 +76,11 @@ void fromTradePower_disabledRateIsZero() { Cache.prestigePerTradePower = 0; assertEquals(0.0, TradePrestige.fromTradePower(50000), 1e-9); } + + @Test + void diminishDoesNotTurnNegativeOrZeroIncomeIntoPrestige() { + assertEquals(0, TradePrestige.diminish(-1, 2000, .1)); + assertEquals(0, TradePrestige.diminish(0, 2000, .1)); + } + } diff --git a/src/test/java/net/tfminecraft/simplefactions/settlement/SettlementLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/settlement/SettlementLifecycleCoverageTest.java new file mode 100644 index 00000000..2418f91c --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/settlement/SettlementLifecycleCoverageTest.java @@ -0,0 +1,337 @@ +package net.tfminecraft.simplefactions.settlement; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import net.tfminecraft.simplefactions.database.FactionData; +import net.tfminecraft.simplefactions.database.SettlementData; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.settlement.handler.SettlementHandler; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** Persisted settlements and public capital/transfer flows with real faction state. */ +class SettlementLifecycleCoverageTest { + private FactionDomainFixture fixture; + private Faction home; + private SettlementHandler settlements; + private Player leader; + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + home = faction("home", "Leader", 10, 11, 12); + settlements = home.getSettlementHandler(); + leader = fixture.player("Leader"); + when(leader.getLocation()).thenReturn(new Location(fixture.ui.world, 27.75, 64, -8.25)); + } + + @AfterEach + void close() { + fixture.close(); + } + + @Test + void transferredCityDoesNotOverwriteADestinationCityWithTheSameLocalId() { + Faction destination = faction("destination", "Recipient", 20); + Settlement incoming = settlements.found("Harbor", 10, 15, 16).getSettlement(); + Settlement existing = + destination.getSettlementHandler().found("Harbor", 20, 31, 32).getSettlement(); + home.setCapital(10, true, false); + destination.setCapital(20, true, false); + Settlement transferred = settlements.detachOnProvince(10); + destination.getSettlementHandler().acceptTransferred(transferred); + + assertSame(incoming, transferred); + assertTrue(settlements.getAll().isEmpty()); + assertEquals(2, destination.getSettlementHandler().getAll().size()); + assertSame(existing, destination.getSettlementHandler().getByProvince(20)); + assertSame(existing, destination.getSettlementHandler().getById(existing.getId())); + Settlement received = destination.getSettlementHandler().getByProvince(10); + assertNotNull(received); + assertNotEquals(existing.getId(), received.getId()); + assertEquals(incoming.getName(), received.getName()); + assertEquals(15, received.getCenterX()); + assertEquals(16, received.getCenterZ()); + assertEquals(20, destination.getCapital()); + } + + @Test + void nullPersistedSettlementDoesNotPreventLaterValidCitiesLoading() { + SettlementData valid = new Settlement("valid", "Valid", 10, 3, 4).toData(); + settlements.load(Arrays.asList(null, new SettlementData(), valid)); + assertEquals(1, settlements.getAll().size()); + assertEquals("Valid", settlements.getByProvince(10).getName()); + assertEquals("valid", settlements.serialize().getFirst().id); + } + + @Test + void nullLegacyProvinceDoesNotPreventTheCenterBeingRestored() { + SettlementData data = new Settlement("legacy", "Legacy", 10, 3, 4).toData(); + data.provinces = new ArrayList<>(Arrays.asList(11, null, 12L)); + settlements.load(List.of(data)); + Settlement restored = settlements.getByProvince(10); + assertNotNull(restored); + assertEquals(java.util.Set.of(10), restored.getProvinces()); + assertNull(settlements.getByProvince(11)); + assertNull(settlements.getByProvince(12)); + } + + @Test + void persistedCitiesRoundTripCoordinatesAndLegacyProvinceListsBeforeNormalization() { + SettlementData data = new Settlement("capital", "Old Capital", 10, 123, -456).toData(); + data.provinces = new ArrayList<>(List.of(11L, 12.0)); + Settlement legacy = new Settlement(data); + assertEquals(Set.of(10, 11, 12), legacy.getProvinces()); + assertTrue(legacy.isCenter(10)); + assertFalse(legacy.isCenter(11)); + legacy.removeProvince(11); + assertFalse(legacy.contains(11)); + legacy.addProvince(10); + assertThrows(IllegalArgumentException.class, () -> legacy.addProvince(11)); + legacy.setName("New Capital"); + SettlementData saved = legacy.toData(); + assertEquals("capital", saved.id); + assertEquals("New Capital", saved.name); + assertEquals(123, saved.centerX); + assertEquals(-456, saved.centerZ); + assertEquals(Set.of(10, 12), Set.copyOf(saved.provinces)); + legacy.normalizeToCenterOnly(); + assertEquals(Set.of(10), legacy.getProvinces()); + } + + @Test + void legacyCoordinatesAndProvinceListDefaultToTheRequiredCenter() { + SettlementData data = new SettlementData(); + data.id = "legacy"; + data.name = "Legacy"; + data.centerProvince = 10; + data.provinces = null; + Settlement restored = new Settlement(data); + assertEquals(0, restored.getCenterX()); + assertEquals(0, restored.getCenterZ()); + assertEquals(Set.of(10), restored.getProvinces()); + } + + @ParameterizedTest + @ValueSource(strings = {"id", "name", "center"}) + void incompletePersistentCitiesAreRejectedWithoutReplacingValidLoadedCities(String missing) { + SettlementData invalid = new Settlement("invalid", "Invalid", 10, 0, 0).toData(); + if (missing.equals("id")) invalid.id = null; + if (missing.equals("name")) invalid.name = null; + if (missing.equals("center")) invalid.centerProvince = null; + assertThrows(IllegalArgumentException.class, () -> new Settlement(invalid)); + settlements.found("Obsolete", 12, 0, 0); + SettlementData valid = new Settlement("valid", "Valid", 11, 1, 2).toData(); + settlements.load(List.of(invalid, valid)); + assertNull(settlements.getByProvince(12)); + assertEquals("valid", settlements.getByProvince(11).getId()); + assertEquals(1, settlements.serialize().size()); + settlements.load(null); + assertTrue(settlements.getAll().isEmpty()); + assertTrue(settlements.serialize().isEmpty()); + assertNull(settlements.getById(null)); + } + + @Test + void factionCapitalDryRunPreservesStateAndApplyUsesThePlayersBlockCoordinates() { + clearInvocations(fixture.map); + var preview = settlements.validateFactionCapital(leader, 10, "River City"); + assertTrue(preview.isSuccess()); + assertTrue(preview.getMessage().contains("Ready")); + assertNull(preview.getSettlement()); + assertTrue(settlements.requiresFoundingName(10)); + assertTrue(settlements.getAll().isEmpty()); + verify(fixture.map, never()).enqueue(anyString(), anyString()); + + var applied = settlements.resolveFactionCapital(leader, 10, "River City"); + assertTrue(applied.isSuccess()); + Settlement city = applied.getSettlement(); + assertSame(city, settlements.getByProvince(10)); + assertEquals(27, city.getCenterX()); + assertEquals(-9, city.getCenterZ()); + assertFalse(settlements.requiresFoundingName(10)); + assertEquals(-1, home.getCapital(), "The caller applies the validated capital separately"); + verify(fixture.map).enqueue("nation", home.getRGB()); + var same = settlements.resolveFactionCapital(leader, 10, null); + assertTrue(same.isSuccess()); + assertSame(city, same.getSettlement()); + assertEquals(1, settlements.getAll().size()); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " "}) + void aFirstFactionCityNeedsANameForBothPreviewAndApplication(String name) { + assertFalse(settlements.validateFactionCapital(leader, 10, name).isSuccess()); + assertFalse(settlements.applyFactionCapital(leader, 10, name).isSuccess()); + assertTrue(settlements.getAll().isEmpty()); + assertEquals(-1, home.getCapital()); + } + + @Test + void unownedCapitalRequestsAndBaseGuildRequestsLeaveStateUnchanged() { + Guild guild = fixture.guild(home, "craft", "Smith"); + assertFalse(settlements.validateFactionCapital(leader, 99, "Faraway").isSuccess()); + assertFalse(settlements.applyFactionCapital(leader, 99, "Faraway").isSuccess()); + assertFalse(settlements.resolveGuildCapital(leader, guild, 99, "Faraway").isSuccess()); + assertFalse(settlements.onGuildRelocateTo(leader, guild, 99, "Faraway").isSuccess()); + assertFalse( + settlements + .resolveGuildCapital(leader, home.getOrCreateMainGuild(), 10, "Town") + .isSuccess()); + assertTrue(settlements.getAll().isEmpty()); + assertEquals(-1, guild.getCapital()); + } + + @ParameterizedTest + @ValueSource(strings = {"missing", "water"}) + void invalidProvinceDefinitionsCannotBecomeCapitals(String state) { + if (state.equals("missing")) fixture.provinceData.remove(11); + else fixture.provinceData.put(11, new Province(11, "SEA", 0)); + var result = settlements.applyFactionCapital(leader, 11, "Town"); + assertFalse(result.isSuccess()); + assertTrue(result.getMessage().contains(state.equals("missing") ? "no province" : "water")); + assertTrue(settlements.getAll().isEmpty()); + } + + @Test + void guildCapitalCannotMoveToADifferentCityWithoutRelocation() { + Settlement original = settlements.found("Original", 10, 0, 0).getSettlement(); + Settlement other = settlements.found("Other", 11, 5, 6).getSettlement(); + Guild guild = fixture.guild(home, "craft", "Smith"); + guild.setCapital(10, false); + assertFalse(settlements.resolveGuildCapital(leader, guild, 11, null).isSuccess()); + assertFalse(settlements.resolveGuildCapital(leader, guild, 12, "Third").isSuccess()); + assertSame(original, settlements.resolveGuildCapital(leader, guild, 10, null).getSettlement()); + assertSame(other, settlements.getByProvince(11)); + assertEquals(10, guild.getCapital()); + var relocated = settlements.onGuildRelocateTo(leader, guild, 12, "Third"); + assertTrue(relocated.isSuccess()); + assertSame(relocated.getSettlement(), settlements.getByProvince(12)); + assertEquals(10, guild.getCapital()); + } + + @Test + void invalidAndCollidingNamesNeverAllocateOrRenameCities() { + Settlement city = settlements.found("Original", 10, 0, 0).getSettlement(); + String originalName = city.getName(); + assertFalse(settlements.validateFactionCapital(leader, 11, "!!!").isSuccess()); + assertFalse(settlements.validateFactionCapital(leader, 11, "Original").isSuccess()); + assertFalse(settlements.found("!!!", 11, 0, 0).isSuccess()); + assertFalse(settlements.found("Original", 11, 0, 0).isSuccess()); + assertFalse(settlements.found("Other", 10, 0, 0).isSuccess()); + assertFalse(settlements.rename(10, "!!!", false).isSuccess()); + assertSame(city, settlements.getByProvince(10)); + assertEquals(originalName, city.getName()); + assertEquals(1, settlements.getAll().size()); + } + + @Test + void validationRemovesStalePersistedCitiesAndClearsOnlyTheirCapitalReferences() { + Faction foreign = faction("foreign", "Foreign", 20); + Guild visitor = fixture.guild(foreign, "visitors", "Guest"); + visitor.setCapital(99, false); + home.setCapital(99, true, false); + settlements.load( + List.of( + new Settlement("stale", "Stale", 99, 1, 2).toData(), + new Settlement("retained", "Retained", 10, 3, 4).toData())); + clearInvocations(leader, fixture.map); + settlements.validate(); + assertNull(settlements.getByProvince(99)); + assertNotNull(settlements.getByProvince(10)); + assertEquals(-1, home.getCapital()); + assertEquals(-1, visitor.getCapital()); + verify(leader).sendMessage(contains("Stale")); + verify(fixture.map, atLeastOnce()).enqueue("nation", home.getRGB()); + } + + @Test + void departureChecksLeaveOccupiedCitiesAndNullTransfersUntouched() { + Settlement city = settlements.found("Home", 10, 0, 0).getSettlement(); + Guild resident = fixture.guild(home, "resident", "Resident"); + resident.setCapital(10, false); + settlements.onGuildDepartedCapital(-1); + settlements.onGuildDepartedCapital(99); + settlements.acceptTransferred(null); + assertNull(settlements.detachOnProvince(99)); + settlements.onGuildDepartedCapital(10); + assertSame(city, settlements.getByProvince(10)); + assertEquals(List.of(resident), settlements.getPopulation(city)); + assertEquals(1, settlements.populationSize(city)); + resident.setCapital(-1, false); + settlements.onGuildDepartedCapital(10); + assertTrue(settlements.getAll().isEmpty()); + assertEquals(-1, resident.getCapital()); + } + + @Test + void foundingWithoutTheOptionalMapStillRegistersTheSettlement() throws Exception { + var field = + net.tfminecraft.simplefactions.managers.FactionManager.class.getDeclaredField("map"); + field.setAccessible(true); + Object original = field.get(null); + try { + field.set(null, null); + var founded = settlements.found("Town", 10, 3, 4); + assertTrue(founded.isSuccess()); + assertSame(founded.getSettlement(), settlements.getByProvince(10)); + } finally { + field.set(null, original); + } + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " "}) + void aGuildNeedsANameWhenFoundingOutsideTheExistingCity(String name) { + Settlement city = settlements.found("Existing", 10, 0, 0).getSettlement(); + Guild guild = fixture.guild(home, "resident", "Resident"); + assertFalse(settlements.resolveGuildCapital(leader, guild, 11, name).isSuccess()); + assertSame(city, settlements.getByProvince(10)); + assertNull(settlements.getByProvince(11)); + assertEquals(-1, guild.getCapital()); + } + + @Test + void losingACityProvinceClearsItsResidentsCapitalsAndKeepsOtherSettlements() { + settlements.found("Capital", 10, 0, 0); + Settlement retained = settlements.found("Retained", 11, 3, 4).getSettlement(); + Guild resident = fixture.guild(home, "resident", "Resident"); + resident.setCapital(10, false); + home.setCapital(10, true, false); + settlements.onProvinceLost(99); + home.removeProvince(10, false); + assertNull(settlements.getByProvince(10)); + assertEquals(-1, home.getCapital()); + assertEquals(-1, resident.getCapital()); + assertSame(retained, settlements.getByProvince(11)); + verify(leader).sendMessage(contains("has been destroyed")); + } + + private Faction faction(String id, String name, int... provinces) { + FactionData data = fixture.data(id, name); + for (int province : provinces) { + data.provinces.add(province); + fixture.provinceData.put(province, new Province(province, "PLAINS", 50)); + } + Faction faction = fixture.saved(data); + faction.getOrCreateMainGuild(); + return faction; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/testsupport/FactionDomainFixture.java b/src/test/java/net/tfminecraft/simplefactions/testsupport/FactionDomainFixture.java new file mode 100644 index 00000000..1021564d --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/testsupport/FactionDomainFixture.java @@ -0,0 +1,390 @@ +package net.tfminecraft.simplefactions.testsupport; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.logging.Logger; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.database.FactionData; +import net.tfminecraft.simplefactions.database.GuildData; +import net.tfminecraft.simplefactions.diplomacy.Attitude; +import net.tfminecraft.simplefactions.diplomacy.Relation; +import net.tfminecraft.simplefactions.diplomacy.RelationType; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.GuildType; +import net.tfminecraft.simplefactions.guild.branch.Branch; +import net.tfminecraft.simplefactions.identity.LeaderCharacters; +import net.tfminecraft.simplefactions.inactivity.InactivityService; +import net.tfminecraft.simplefactions.laws.Law; +import net.tfminecraft.simplefactions.laws.LawGroup; +import net.tfminecraft.simplefactions.loaders.BranchLoader; +import net.tfminecraft.simplefactions.loaders.GuildLoader; +import net.tfminecraft.simplefactions.loaders.LawLoader; +import net.tfminecraft.simplefactions.loaders.RankLoader; +import net.tfminecraft.simplefactions.loaders.RegimentLoader; +import net.tfminecraft.simplefactions.loaders.RelationLoader; +import net.tfminecraft.simplefactions.loaders.TierLoader; +import net.tfminecraft.simplefactions.loaders.TitleLoader; +import net.tfminecraft.simplefactions.loaders.UpgradeLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.InventoryManager; +import net.tfminecraft.simplefactions.managers.LogManager; +import net.tfminecraft.simplefactions.managers.ProvinceManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.map.MapSystem; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.Modifier; +import net.tfminecraft.simplefactions.objects.PrestigeRank; +import net.tfminecraft.simplefactions.prestige.MemberPlaytime; +import net.tfminecraft.simplefactions.rest.BannerFetcher; +import net.tfminecraft.simplefactions.tiers.Tier; +import net.tfminecraft.simplefactions.tiers.Title; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.plugin.PluginManager; +import org.mockito.MockedStatic; + +/** Real faction/handler/configuration graph with scoped Paper and map-service boundaries. */ +public final class FactionDomainFixture implements AutoCloseable { + public final GuiTestFixture ui = new GuiTestFixture(); + public final ProvinceManager provinces = mock(ProvinceManager.class); + public final MapSystem map = mock(MapSystem.class); + public final InventoryManager inventory = mock(InventoryManager.class, RETURNS_DEEP_STUBS); + public final Map<Integer, Province> provinceData = new LinkedHashMap<>(); + public final Map<String, Player> online = new LinkedHashMap<>(); + public final List<Consumer<List<String>>> bannerCallbacks = new ArrayList<>(); + private final Map<Field, Object> originalGlobals = new LinkedHashMap<>(); + private final Map<Map<Object, Object>, Map<Object, Object>> originalMapContents = + new java.util.IdentityHashMap<>(); + private MockedStatic<BannerFetcher> banners; + + public FactionDomainFixture() { + try { + initialise(); + } catch (RuntimeException | Error failure) { + try { + close(); + } catch (RuntimeException | Error cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + throw failure; + } + } + + private void initialise() { + replace(FactionManager.class, "factions", new ArrayList<Faction>()); + replace(FactionManager.class, "map", map); + replace(FactionManager.class, "inv", inventory); + replace(FactionManager.class, "loading", false); + replace(RankLoader.class, "ranks", new ArrayList<PrestigeRank>()); + replace(TierLoader.class, "oList", new ArrayList<Tier>()); + replace(TitleLoader.class, "titles", new ArrayList<Title>()); + replace(GuildLoader.class, "map", new LinkedHashMap<>()); + replace(BranchLoader.class, "map", new LinkedHashMap<>()); + replace(UpgradeLoader.class, "map", new LinkedHashMap<>()); + replace(LawLoader.class, "map", new LinkedHashMap<>()); + replace(RegimentLoader.class, "oList", new ArrayList<Regiment>()); + replace(RelationLoader.class, "types", new ArrayList<RelationType>()); + replace(RelationLoader.class, "attitudes", new ArrayList<Attitude>()); + replace(WarManager.class, "wars", new ArrayList<>()); + replace(MemberPlaytime.class, "probe", MemberPlaytime.Probe.NONE); + replace(LeaderCharacters.class, "probe", (LeaderCharacters.Probe) player -> null); + // Keep the real inactivity calculation, with an explicitly loaded empty persistence snapshot. + replace(InactivityService.class, "loaded", true); + replace(InactivityService.class, "saveBlocked", false); + replace(InactivityService.class, "guildClocks", new HashMap<>()); + replace(InactivityService.class, "factionClocks", new HashMap<>()); + replace(InactivityService.class, "earliestDue", Long.MAX_VALUE); + replace(LogManager.class, "enabled", false); + replace(Cache.class, "worldName", "world"); + replace(Cache.class, "provincesEnabled", false); + replace(Cache.class, "branchUpgradeCost", 100.0); + replace(Cache.class, "branchUpgradeExponent", 2.0); + replace(Cache.class, "maxWealthPrestige", 1000); + replace(Cache.class, "maxMembers", 100); + replace(Cache.class, "maxExtraNodeCapacity", 10); + replace(Cache.class, "maxUntitledProvinces", 10); + replace(Cache.class, "maxFreeTitles", 10); + replace(Cache.class, "baseEffects", new HashMap<>()); + when(Bukkit.getLogger()).thenReturn(Logger.getLogger("FactionDomainFixture")); + when(Bukkit.getPluginManager()).thenReturn(mock(PluginManager.class)); + when(Bukkit.getPlayerExact(anyString())).thenAnswer(call -> online.get(call.getArgument(0))); + Chunk origin = mock(Chunk.class); + when(origin.getWorld()).thenReturn(ui.world); + when(ui.world.getChunkAt(anyInt(), anyInt())).thenReturn(origin); + when(ui.plugin.getProvinceManager()).thenReturn(provinces); + when(provinces.get(anyInt())) + .thenAnswer(call -> provinceData.getOrDefault(call.getArgument(0), new Province())); + when(provinces.getProvinces()).thenAnswer(call -> new ArrayList<>(provinceData.values())); + when(provinces.contains(anyInt())) + .thenAnswer(call -> provinceData.containsKey(call.getArgument(0))); + banners = mockStatic(BannerFetcher.class); + banners.when(BannerFetcher::placeholder).thenAnswer(call -> new ArrayList<>(List.of("white"))); + banners + .when(() -> BannerFetcher.isPlaceholder(anyList())) + .thenAnswer(call -> List.of("white").equals(call.getArgument(0))); + banners + .when(() -> BannerFetcher.fetch(anyString(), any())) + .thenAnswer( + call -> { + bannerCallbacks.add(call.getArgument(1)); + return null; + }); + rank("common", 1, 0); + rank("renowned", 2, 10000); + tier("landless", 0, 0); + tier("province", 1, 10); + tier("county", 2, 50); + tier("duchy", 3, 100); + YamlConfiguration types = new YamlConfiguration(); + types.set("realm.base", true); + types.set("guild.default", true); + for (String id : List.of("realm", "guild")) + GuildLoader.map.put(id, new GuildType(id, types.getConfigurationSection(id))); + YamlConfiguration branches = new YamlConfiguration(); + branches.set("commerce.name", "Commerce"); + branches.set("commerce.allowed-types", List.of("realm", "guild")); + branches.set("commerce.group", 0); + branches.set("commerce.modifiers", List.of("TRADE_POWER 5 2", "PRESTIGE 3 1")); + BranchLoader.map.put( + "commerce", new Branch("commerce", branches.getConfigurationSection("commerce"))); + relationType("neutral", Map.of("default", true)); + relationType("vassal", Map.of("vassal", true, "link", "overlord")); + relationType("overlord", Map.of("overlord", true, "link", "vassal")); + YamlConfiguration attitude = new YamlConfiguration(); + attitude.set("neutral.default", true); + RelationLoader.attitudes.add( + new Attitude("neutral", attitude.getConfigurationSection("neutral"))); + } + + public Player player(String name) { + Player player = ui.player(name); + when(player.isOnline()).thenReturn(true); + online.put(name, player); + return player; + } + + /** Lets callback tests update the actual open inventory while HTTP fetching remains isolated. */ + public void useRealBannerRefresh() { + banners.when(() -> BannerFetcher.refreshOpenView(any(), any(), anyString(), any())) + .thenCallRealMethod(); + } + + public PrestigeRank rank(String id, int level, double minimum) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set(id + ".name", id); + yaml.set(id + ".level", level); + yaml.set(id + ".minimum-prestige", minimum); + PrestigeRank rank = new PrestigeRank(id, yaml.getConfigurationSection(id)); + RankLoader.ranks.add(rank); + return rank; + } + + public Tier tier(String id, int level, int prestige) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set(id + ".tier", level); + yaml.set(id + ".prestige", prestige); + Tier tier = new Tier(id, yaml.getConfigurationSection(id)); + TierLoader.oList.add(tier); + return tier; + } + + public RelationType relationType(String id, Map<String, ?> options) { + YamlConfiguration yaml = new YamlConfiguration(); + options.forEach((key, value) -> yaml.set(id + "." + key, value)); + RelationType type = new RelationType(id, yaml.getConfigurationSection(id)); + RelationLoader.types.add(type); + return type; + } + + public LawGroup lawGroup(String id, Map<String, ?> options) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set(id + ".laws.current.name", "Current " + id); + options.forEach((key, value) -> yaml.set(id + ".laws.current." + key, value)); + LawGroup group = new LawGroup(id, yaml.getConfigurationSection(id)); + LawLoader.map.put(id, group); + return group; + } + + public void provincesEnabled(boolean enabled) { + replace(Cache.class, "provincesEnabled", enabled); + } + + public Law law(String groupId, String id, Map<String, ?> options) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set(id + ".name", id); + options.forEach((key, value) -> yaml.set(id + "." + key, value)); + var law = new Law(groupId, id, yaml.getConfigurationSection(id)); + LawGroup template = LawLoader.map.get(groupId); + if (template != null) template.getLaws().put(id, law); + for (Faction faction : FactionManager.factions) { + LawGroup group = faction.getLawHandler().getGroup(groupId); + if (group != null) group.getLaws().put(id, law); + } + return law; + } + + public Regiment regiment(String id, boolean levy, int slots, double upkeep) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set(id + ".item.material", "PAPER"); + yaml.set(id + ".default-slots", slots); + yaml.set(id + ".upkeep", upkeep); + yaml.set(id + ".levy", levy); + yaml.set(id + ".expansion-time", 2); + Regiment regiment = new Regiment(id, yaml.getConfigurationSection(id)); + RegimentLoader.oList.add(regiment); + return regiment; + } + + public FactionData data(String id, String leader) { + FactionData data = new FactionData(); + data.id = id; + data.name = id; + data.leader = leader; + data.rgb = "1,2,3"; + data.rulerTitle = "Governor"; + data.government = "Community"; + data.culture = "Riverfolk"; + data.religion = "Old Faith"; + data.banner = new ArrayList<>(List.of("white")); + data.capital = -1; + data.citizenTax = 5.0; + data.guildTax = 10.0; + data.vassalTax = 20.0; + data.dividendTax = 15.0; + data.tariffs = 30.0; + data.extraNodeCapacity = 0.0; + return data; + } + + public Faction saved(String id, String leader) { + return saved(data(id, leader)); + } + + public Faction saved(FactionData data) { + List<Title> titles = data.titles.stream().map(TitleLoader::getById).toList(); + List<Modifier> modifiers = Database.loadModifiers(data.prestigeModifiers); + Faction faction = + new Faction( + data.id, + data.rgb, + data.provinces.stream().map(Number::intValue).toList(), + titles, + data.leader, + data.name, + data.rulerTitle, + new ArrayList<>(data.banner), + data.government, + data.culture, + data.religion, + data.extraNodeCapacity.intValue(), + new ArrayList<>(modifiers), + data.citizenTax, + data.guildTax, + data.vassalTax, + data.dividendTax, + data.tariffs, + data.specificTaxes, + data.capital, + data.laws, + data.governmentData); + FactionManager.factions.add(faction); + for (GuildData guild : data.guilds) + faction.getGuildHandler().addGuild(new Guild(guild, faction)); + return faction; + } + + public Guild guild(Faction faction, String id, String leader) { + GuildData data = new GuildData(); + data.id = id; + data.name = id; + data.leader = leader; + data.type = "guild"; + data.rgb = "4,5,6"; + data.capital = -1; + data.banner = new ArrayList<>(List.of("white")); + data.members = new ArrayList<>(List.of(leader)); + Guild guild = new Guild(data, faction); + faction.getGuildHandler().addGuild(guild); + return guild; + } + + public Title title(String id, String tier, int... provinceIds) { + JsonObject data = new JsonObject(); + data.addProperty("name", id); + data.addProperty("rgb", "4,5,6"); + JsonArray provinces = new JsonArray(); + for (int province : provinceIds) provinces.add(province); + data.add("provinces", provinces); + Title title = new Title(TierLoader.getByString(tier), id, data); + TitleLoader.getTitles().add(title); + return title; + } + + public void subject(Faction overlord, Faction vassal) { + overlord.setRelation( + vassal, + new Relation(RelationLoader.getType("vassal"), RelationLoader.getDefaultAttitude())); + vassal.setRelation( + overlord, + new Relation(RelationLoader.getType("overlord"), RelationLoader.getDefaultAttitude())); + } + + /** Restores named global fields only; it never calls production methods through reflection. */ + @SuppressWarnings("unchecked") + private void replace(Class<?> owner, String name, Object value) { + try { + Field field = owner.getDeclaredField(name); + field.setAccessible(true); + if (java.lang.reflect.Modifier.isFinal(field.getModifiers())) { + Map<Object, Object> contents = (Map<Object, Object>) field.get(null); + originalMapContents.putIfAbsent(contents, new LinkedHashMap<>(contents)); + contents.clear(); + contents.putAll((Map<?, ?>) value); + return; + } + originalGlobals.putIfAbsent(field, field.get(null)); + field.set(null, value); + } catch (ReflectiveOperationException failure) { + throw new AssertionError(failure); + } + } + + @Override + public void close() { + try { + if (banners != null) banners.close(); + } finally { + try { + originalMapContents.forEach( + (contents, original) -> { + contents.clear(); + contents.putAll(original); + }); + for (Map.Entry<Field, Object> entry : originalGlobals.entrySet()) { + try { + entry.getKey().set(null, entry.getValue()); + } catch (IllegalAccessException failure) { + throw new AssertionError(failure); + } + } + } finally { + ui.close(); + } + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/testsupport/FactionDomainFixtureTest.java b/src/test/java/net/tfminecraft/simplefactions/testsupport/FactionDomainFixtureTest.java new file mode 100644 index 00000000..e383e178 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/testsupport/FactionDomainFixtureTest.java @@ -0,0 +1,172 @@ +package net.tfminecraft.simplefactions.testsupport; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mockStatic; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.time.Duration; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.identity.LeaderCharacters; +import net.tfminecraft.simplefactions.inactivity.DecayClock; +import net.tfminecraft.simplefactions.inactivity.InactivityService; +import net.tfminecraft.simplefactions.loaders.*; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.LogManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.prestige.MemberPlaytime; +import net.tfminecraft.simplefactions.rest.BannerFetcher; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; +import org.mockito.exceptions.base.MockitoException; + +class FactionDomainFixtureTest { + @Test + void failedConstructionRestoresGlobalsAndOnlyClosesItsOwnMocks() { + // Isolate thread-scoped leaks if this regression fails, without clearing other tests' mocks. + assertTimeoutPreemptively( + Duration.ofSeconds(30), + () -> { + try (Globals original = new Globals()) { + Cache.worldName = "retained-world"; + Cache.maxMembers = 237; + Cache.branchUpgradeCost = 321.0; + RankLoader.ranks = new ArrayList<>(RankLoader.ranks); + clocks("guildClocks").put("retained-guild", new DecayClock(10, 12345)); + clocks("factionClocks").put("retained-faction", new DecayClock(20, 23456)); + Globals expected = new Globals(); + List<String> externalBanner = List.of("externally-owned-banner"); + + try (var external = mockStatic(BannerFetcher.class)) { + external.when(BannerFetcher::placeholder).thenReturn(externalBanner); + + MockitoException failure = + assertThrows(MockitoException.class, FactionDomainFixture::new); + + assertTrue(failure.getMessage().contains("already registered")); + assertEquals(0, failure.getSuppressed().length); + assertAll( + expected::assertRestored, + () -> assertFalse(external.isClosed()), + () -> assertSame(externalBanner, BannerFetcher.placeholder()), + () -> { + try (var freshUi = new GuiTestFixture()) { + assertSame(freshUi.world, Bukkit.getWorld("world")); + assertEquals(Material.PAPER, new ItemStack(Material.PAPER).getType()); + } + }); + } + + try (var fresh = new FactionDomainFixture()) { + assertSame(fresh.ui.plugin, SimpleFactions.plugin); + assertEquals(List.of("white"), BannerFetcher.placeholder()); + assertEquals( + List.of("common", "renowned"), + RankLoader.ranks.stream().map(rank -> rank.getId()).toList()); + } + expected.assertRestored(); + } + }); + } + + @SuppressWarnings("unchecked") + private static Map<String, DecayClock> clocks(String name) throws ReflectiveOperationException { + Field field = InactivityService.class.getDeclaredField(name); + field.setAccessible(true); + return (Map<String, DecayClock>) field.get(null); + } + + /** Snapshot only the named globals that the fixture owns; never invoke methods reflectively. */ + private static final class Globals implements AutoCloseable { + private final Map<Field, Object> values = new LinkedHashMap<>(); + private final Map<Map<Object, Object>, Map<Object, Object>> contents = new IdentityHashMap<>(); + + private Globals() throws ReflectiveOperationException { + capture(SimpleFactions.class, "plugin"); + capture(FactionManager.class, "factions", "map", "inv", "loading"); + capture(RankLoader.class, "ranks"); + capture(TierLoader.class, "oList"); + capture(TitleLoader.class, "titles"); + capture(GuildLoader.class, "map"); + capture(BranchLoader.class, "map"); + capture(UpgradeLoader.class, "map"); + capture(LawLoader.class, "map"); + capture(RegimentLoader.class, "oList"); + capture(RelationLoader.class, "types", "attitudes"); + capture(WarManager.class, "wars"); + capture(MemberPlaytime.class, "probe"); + capture(LeaderCharacters.class, "probe"); + capture( + InactivityService.class, + "loaded", + "saveBlocked", + "guildClocks", + "factionClocks", + "earliestDue"); + capture(LogManager.class, "enabled"); + capture( + Cache.class, + "worldName", + "provincesEnabled", + "branchUpgradeCost", + "branchUpgradeExponent", + "maxWealthPrestige", + "maxMembers", + "maxExtraNodeCapacity", + "maxUntitledProvinces", + "maxFreeTitles", + "baseEffects"); + } + + @SuppressWarnings("unchecked") + private void capture(Class<?> owner, String... names) throws ReflectiveOperationException { + for (String name : names) { + Field field = owner.getDeclaredField(name); + field.setAccessible(true); + Object value = field.get(null); + values.put(field, value); + if (value instanceof Map<?, ?> map) { + contents.put((Map<Object, Object>) map, new LinkedHashMap<>((Map<Object, Object>) map)); + } + } + } + + private void assertRestored() { + assertAll( + values.entrySet().stream() + .map( + entry -> + () -> { + Field field = entry.getKey(); + if (field.getType().isPrimitive()) { + assertEquals(entry.getValue(), field.get(null), field.toString()); + } else { + assertSame(entry.getValue(), field.get(null), field.toString()); + } + })); + contents.forEach((map, saved) -> assertEquals(saved, map)); + } + + @Override + public void close() throws ReflectiveOperationException { + contents.forEach( + (map, saved) -> { + map.clear(); + map.putAll(saved); + }); + for (var entry : values.entrySet()) { + if (!Modifier.isFinal(entry.getKey().getModifiers())) { + entry.getKey().set(null, entry.getValue()); + } + } + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/testsupport/GuiTestFixture.java b/src/test/java/net/tfminecraft/simplefactions/testsupport/GuiTestFixture.java new file mode 100644 index 00000000..7c6a03d5 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/testsupport/GuiTestFixture.java @@ -0,0 +1,695 @@ +package net.tfminecraft.simplefactions.testsupport; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Logger; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.tfminecraft.simplefactions.SimpleFactions; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.OfflinePlayer; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.block.banner.Pattern; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.meta.BannerMeta; +import org.bukkit.inventory.meta.BookMeta; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.SkullMeta; +import org.bukkit.inventory.meta.components.CustomModelDataComponent; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.mockito.Answers; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import org.mockito.invocation.InvocationOnMock; + +/** + * Scoped Paper boundary for GUI tests. Production holders and menu builders remain real. + * Inventories retain their contents, and item amounts, metadata and PDC survive independent + * item/meta copies. Only the GUI API used here is simulated; this is not a replacement for a server + * integration test. + */ +@SuppressWarnings({"deprecation", "unchecked"}) +public final class GuiTestFixture implements AutoCloseable { + public final SimpleFactions plugin = mock(SimpleFactions.class); + public final Server server = mock(Server.class); + public final BukkitScheduler scheduler = mock(BukkitScheduler.class); + public final World world = mock(World.class); + public final List<Runnable> tasks = new ArrayList<>(); + public final List<Runnable> repeatingTasks = new ArrayList<>(); + public final List<Runnable> asyncTasks = new ArrayList<>(); + + private final SimpleFactions previousPlugin = SimpleFactions.plugin; + private final Map<ItemStack, ItemState> items = new IdentityHashMap<>(); + private final Map<ItemMeta, MetaState> metas = new IdentityHashMap<>(); + private final Map<Inventory, String> titles = new IdentityHashMap<>(); + private final MockedConstruction<ItemStack> itemConstruction; + private final MockedStatic<Bukkit> bukkit; + + public GuiTestFixture() { + when(world.getName()).thenReturn("world"); + when(plugin.getName()).thenReturn("SimpleFactions"); + when(plugin.namespace()).thenReturn("simplefactions"); + when(plugin.getServer()).thenReturn(server); + when(plugin.getLogger()).thenReturn(Logger.getLogger("GuiTestFixture")); + when(server.getScheduler()).thenReturn(scheduler); + when(server.createInventory(nullable(InventoryHolder.class), anyInt(), anyString())) + .thenAnswer( + call -> inventory(call.getArgument(0), call.getArgument(1), call.getArgument(2))); + when(scheduler.runTask(any(), any(Runnable.class))) + .thenAnswer(call -> schedule(tasks, call.getArgument(1))); + when(scheduler.runTaskAsynchronously(any(), any(Runnable.class))) + .thenAnswer(call -> schedule(asyncTasks, call.getArgument(1))); + when(scheduler.runTaskLater(any(), any(Runnable.class), anyLong())) + .thenAnswer(call -> schedule(tasks, call.getArgument(1))); + when(scheduler.runTaskTimer(any(), any(Runnable.class), anyLong(), anyLong())) + .thenAnswer(call -> schedule(repeatingTasks, call.getArgument(1))); + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + Logger logger = plugin.getLogger(); + bukkit.when(Bukkit::getLogger).thenReturn(logger); + bukkit.when(() -> Bukkit.getWorld(anyString())).thenReturn(world); + bukkit + .when(() -> Bukkit.createInventory(nullable(InventoryHolder.class), anyInt(), anyString())) + .thenAnswer( + call -> inventory(call.getArgument(0), call.getArgument(1), call.getArgument(2))); + itemConstruction = + mockConstruction( + ItemStack.class, + (item, context) -> { + Object first = + context.arguments().isEmpty() ? Material.AIR : context.arguments().get(0); + ItemState state; + if (first instanceof ItemStack source) { + state = state(source).copy(); + } else { + state = new ItemState((Material) first); + if (context.arguments().size() > 1) + state.amount = (Integer) context.arguments().get(1); + } + bind(item, state); + }); + SimpleFactions.plugin = plugin; + } + + private BukkitTask schedule(List<Runnable> queue, Runnable runnable) { + queue.add(runnable); + BukkitTask task = mock(BukkitTask.class); + when(task.getTaskId()).thenReturn(queue.size()); + return task; + } + + /** Runs the currently queued main-thread tasks; tasks queued by them wait for the next call. */ + public void runTasks() { + List<Runnable> pending = new ArrayList<>(tasks); + tasks.clear(); + pending.forEach(Runnable::run); + } + + public NamespacedKey key(String name) { + return new NamespacedKey(plugin, name); + } + + public String data(ItemStack item, String key) { + return item.getItemMeta().getPersistentDataContainer().get(key(key), PersistentDataType.STRING); + } + + public Inventory inventory(InventoryHolder holder, int size, String title) { + ItemStack[] contents = new ItemStack[size]; + Inventory inventory = mock(Inventory.class, call -> inventoryCall(call, contents, holder)); + titles.put(inventory, title); + return inventory; + } + + private Object inventoryCall(InvocationOnMock call, ItemStack[] contents, InventoryHolder holder) + throws Throwable { + return switch (call.getMethod().getName()) { + case "getSize" -> contents.length; + case "getType" -> holder instanceof Player ? InventoryType.PLAYER : InventoryType.CHEST; + case "getHolder" -> holder; + case "getItem" -> contents[(Integer) call.getArgument(0)]; + case "setItem" -> { + contents[(Integer) call.getArgument(0)] = call.getArgument(1); + yield null; + } + case "getContents", "getStorageContents" -> contents.clone(); + case "addItem" -> { + ItemStack[] incoming = (ItemStack[]) call.getRawArguments()[0]; + HashMap<Integer, ItemStack> leftovers = new HashMap<>(); + for (int index = 0; index < incoming.length; index++) { + ItemStack item = incoming[index]; + if (item == null || item.getAmount() <= 0 || item.getType() == Material.AIR) continue; + int remaining = item.getAmount(); + int maximum = item.getMaxStackSize(); + for (ItemStack present : contents) { + if (present != null && present.isSimilar(item) && present.getAmount() < maximum) { + int moved = Math.min(remaining, maximum - present.getAmount()); + present.setAmount(present.getAmount() + moved); + remaining -= moved; + } + } + for (int slot = 0; slot < contents.length && remaining > 0; slot++) { + if (contents[slot] == null || contents[slot].getType() == Material.AIR) { + ItemStack inserted = item.clone(); + inserted.setAmount(Math.min(remaining, maximum)); + contents[slot] = inserted; + remaining -= inserted.getAmount(); + } + } + if (remaining > 0) { + ItemStack leftover = item.clone(); + leftover.setAmount(remaining); + leftovers.put(index, leftover); + } + } + yield leftovers; + } + case "setContents", "setStorageContents" -> { + ItemStack[] values = call.getArgument(0); + if (values.length > contents.length) + throw new IllegalArgumentException("Inventory is too small"); + Arrays.fill(contents, null); + System.arraycopy(values, 0, contents, 0, values.length); + yield null; + } + case "clear" -> { + if (call.getArguments().length == 0) Arrays.fill(contents, null); + else contents[(Integer) call.getArgument(0)] = null; + yield null; + } + case "isEmpty" -> Arrays.stream(contents).allMatch(item -> item == null || item.isEmpty()); + case "firstEmpty" -> { + int slot = 0; + while (slot < contents.length && contents[slot] != null && !contents[slot].isEmpty()) + slot++; + yield slot == contents.length ? -1 : slot; + } + case "getMaxStackSize" -> 64; + default -> Answers.RETURNS_DEFAULTS.answer(call); + }; + } + + public Player player(String name) { + Player player = mock(Player.class); + when(player.getName()).thenReturn(name); + when(player.getUniqueId()) + .thenReturn(UUID.nameUUIDFromBytes(name.getBytes(StandardCharsets.UTF_8))); + when(player.getLocation()).thenReturn(new Location(world, 0, 64, 0)); + when(player.isOnline()).thenReturn(true); + ItemStack[] contents = new ItemStack[41]; + PlayerInventory bottom = + mock(PlayerInventory.class, call -> inventoryCall(call, contents, player)); + when(player.getInventory()).thenReturn(bottom); + InventoryView[] current = {view(player, inventory(null, 0, "Crafting"), bottom)}; + when(player.getOpenInventory()).thenAnswer(call -> current[0]); + when(player.openInventory(any(Inventory.class))) + .thenAnswer(call -> current[0] = view(player, call.getArgument(0), bottom)); + doAnswer( + call -> { + current[0] = view(player, inventory(null, 0, "Crafting"), bottom); + return null; + }) + .when(player) + .closeInventory(); + return player; + } + + private InventoryView view(Player player, Inventory top, PlayerInventory bottom) { + InventoryView view = mock(InventoryView.class); + when(view.getPlayer()).thenReturn(player); + when(view.getTopInventory()).thenReturn(top); + when(view.getType()).thenAnswer(call -> top.getType()); + when(view.getBottomInventory()).thenReturn(bottom); + when(view.getTitle()).thenReturn(titles.get(top)); + when(view.getOriginalTitle()).thenReturn(titles.get(top)); + when(view.countSlots()).thenAnswer(call -> top.getSize() + bottom.getSize()); + when(view.getInventory(anyInt())) + .thenAnswer( + call -> { + int slot = call.getArgument(0); + return slot < 0 ? null : slot < top.getSize() ? top : bottom; + }); + when(view.convertSlot(anyInt())) + .thenAnswer( + call -> { + int slot = call.getArgument(0); + return slot < top.getSize() ? slot : slot - top.getSize(); + }); + when(view.getItem(anyInt())) + .thenAnswer( + call -> { + int slot = call.getArgument(0); + Inventory owner = view.getInventory(slot); + return owner == null ? null : owner.getItem(view.convertSlot(slot)); + }); + doAnswer( + call -> { + int slot = call.getArgument(0); + Inventory owner = view.getInventory(slot); + if (owner != null) owner.setItem(view.convertSlot(slot), call.getArgument(1)); + return null; + }) + .when(view) + .setItem(anyInt(), nullable(ItemStack.class)); + return view; + } + + public InventoryClickEvent click(Player player, int rawSlot) { + return new InventoryClickEvent( + player.getOpenInventory(), + rawSlot < 0 ? InventoryType.SlotType.OUTSIDE : InventoryType.SlotType.CONTAINER, + rawSlot, + ClickType.LEFT, + InventoryAction.PICKUP_ALL); + } + + private ItemState state(ItemStack item) { + ItemState state = items.get(item); + if (state == null) + throw new IllegalArgumentException("Create GUI items inside GuiTestFixture's scope"); + return state; + } + + private void bind(ItemStack item, ItemState state) { + items.put(item, state); + when(item.getType()).thenAnswer(call -> state.type); + doAnswer( + call -> { + state.type = call.getArgument(0); + return null; + }) + .when(item) + .setType(any()); + when(item.getAmount()).thenAnswer(call -> state.amount); + when(item.getMaxStackSize()).thenReturn(64); + doAnswer( + call -> { + state.amount = call.getArgument(0); + return null; + }) + .when(item) + .setAmount(anyInt()); + when(item.getItemMeta()).thenAnswer(call -> meta(state.meta.copy(), state.type)); + when(item.setItemMeta(nullable(ItemMeta.class))) + .thenAnswer( + call -> { + ItemMeta supplied = call.getArgument(0); + if (supplied != null && !metas.containsKey(supplied)) + throw new IllegalArgumentException("Use item.getItemMeta() for GUI metadata"); + state.meta = supplied == null ? new MetaState() : metas.get(supplied).copy(); + return true; + }); + when(item.hasItemMeta()).thenAnswer(call -> !state.meta.empty()); + when(item.isEmpty()) + .thenAnswer( + call -> + (state.type == Material.AIR + || state.type == Material.CAVE_AIR + || state.type == Material.VOID_AIR) + || state.amount <= 0); + when(item.getEnchantments()).thenAnswer(call -> Map.copyOf(state.meta.enchantments)); + when(item.containsEnchantment(any())) + .thenAnswer(call -> state.meta.enchantments.containsKey(call.getArgument(0))); + when(item.getEnchantmentLevel(any())) + .thenAnswer(call -> state.meta.enchantments.getOrDefault(call.getArgument(0), 0)); + doAnswer( + call -> { + state.meta.enchantments.put(call.getArgument(0), call.getArgument(1)); + return null; + }) + .when(item) + .addUnsafeEnchantment(any(), anyInt()); + when(item.clone()).thenAnswer(call -> new ItemStack(item)); + when(item.isSimilar(nullable(ItemStack.class))) + .thenAnswer( + call -> { + ItemState other = items.get((ItemStack) call.getArgument(0)); + return other != null && state.type == other.type && state.meta.same(other.meta); + }); + } + + private ItemMeta meta(MetaState state, Material material) { + Class<? extends ItemMeta> type = + material.name().endsWith("_BANNER") + ? BannerMeta.class + : material == Material.PLAYER_HEAD + ? SkullMeta.class + : material == Material.WRITTEN_BOOK || material == Material.WRITABLE_BOOK + ? BookMeta.class + : ItemMeta.class; + ItemMeta meta = + mock( + type, + call -> + switch (call.getMethod().getName()) { + case "getDisplayName" -> state.name == null ? "" : state.name; + case "hasDisplayName" -> state.name != null; + case "setDisplayName" -> { + state.name = call.getArgument(0); + state.display = + state.name == null + ? null + : LegacyComponentSerializer.legacySection().deserialize(state.name); + yield null; + } + case "displayName" -> { + if (call.getArguments().length == 0) yield state.display; + state.display = call.getArgument(0); + state.name = + state.display == null + ? null + : LegacyComponentSerializer.legacySection().serialize(state.display); + yield null; + } + case "getLore" -> state.lore == null ? null : new ArrayList<>(state.lore); + case "hasLore" -> state.lore != null && !state.lore.isEmpty(); + case "setLore" -> { + List<String> lore = call.getArgument(0); + state.lore = lore == null ? null : new ArrayList<>(lore); + state.componentLore = + lore == null + ? null + : lore.stream() + .<Component>map( + LegacyComponentSerializer.legacySection()::deserialize) + .toList(); + yield null; + } + case "lore" -> { + if (call.getArguments().length == 0) + yield state.componentLore == null + ? null + : new ArrayList<>(state.componentLore); + List<Component> lore = call.getArgument(0); + state.componentLore = lore == null ? null : List.copyOf(lore); + state.lore = + lore == null + ? null + : lore.stream() + .map(LegacyComponentSerializer.legacySection()::serialize) + .toList(); + yield null; + } + case "getPages" -> new ArrayList<>(state.pages); + case "getPageCount" -> state.pages.size(); + case "hasPages" -> !state.pages.isEmpty(); + case "getPage" -> state.pages.get((Integer) call.getArgument(0) - 1); + case "setPage" -> { + state.pages.set((Integer) call.getArgument(0) - 1, call.getArgument(1)); + yield null; + } + case "setPages", "addPage" -> { + if (call.getMethod().getName().equals("setPages")) state.pages.clear(); + Object pages = call.getRawArguments()[0]; + state.pages.addAll( + pages instanceof List<?> list + ? (List<String>) list + : Arrays.asList((String[]) pages)); + yield null; + } + case "getTitle" -> state.title; + case "hasTitle" -> state.title != null; + case "setTitle" -> { + state.title = call.getArgument(0); + yield true; + } + case "getAuthor" -> state.author; + case "hasAuthor" -> state.author != null; + case "setAuthor" -> { + state.author = call.getArgument(0); + yield null; + } + case "getOwningPlayer" -> state.owner; + case "hasOwner" -> state.owner != null; + case "setOwningPlayer" -> { + state.owner = call.getArgument(0); + yield true; + } + case "getPersistentDataContainer" -> container(state.data); + case "getEnchants" -> Map.copyOf(state.enchantments); + case "hasEnchants" -> !state.enchantments.isEmpty(); + case "hasEnchant" -> state.enchantments.containsKey(call.getArgument(0)); + case "getEnchantLevel" -> state.enchantments.getOrDefault(call.getArgument(0), 0); + case "addEnchant" -> { + Integer previous = + state.enchantments.put(call.getArgument(0), call.getArgument(1)); + yield !Objects.equals(previous, call.getArgument(1)); + } + case "removeEnchant" -> state.enchantments.remove(call.getArgument(0)) != null; + case "getItemFlags" -> Set.copyOf(state.flags); + case "hasItemFlag" -> state.flags.contains(call.getArgument(0)); + case "addItemFlags", "removeItemFlags" -> { + var flags = Arrays.asList((org.bukkit.inventory.ItemFlag[]) call.getRawArguments()[0]); + if (call.getMethod().getName().equals("addItemFlags")) state.flags.addAll(flags); + else state.flags.removeAll(flags); + yield null; + } + case "clone" -> meta(state.copy(), material); + case "hasCustomModelData" -> state.model != null; + case "getCustomModelData" -> state.model; + case "setCustomModelData" -> { + state.model = call.getArgument(0); + state.modelFloats = + state.model == null ? List.of() : List.of(state.model.floatValue()); + yield null; + } + case "getCustomModelDataComponent" -> modelComponent(state.modelFloats); + case "setCustomModelDataComponent" -> { + CustomModelDataComponent component = call.getArgument(0); + state.modelFloats = + component == null ? List.of() : List.copyOf(component.getFloats()); + state.model = + state.modelFloats.isEmpty() + ? null + : state.modelFloats.getFirst().intValue(); + yield null; + } + case "getPatterns" -> new ArrayList<>(state.patterns); + case "numberOfPatterns" -> state.patterns.size(); + case "getPattern" -> state.patterns.get((Integer) call.getArgument(0)); + case "addPattern" -> { + state.patterns.add(call.getArgument(0)); + yield null; + } + case "removePattern" -> + state.patterns.remove((int) (Integer) call.getArgument(0)); + case "setPattern" -> { + state.patterns.set(call.getArgument(0), call.getArgument(1)); + yield null; + } + case "setPatterns" -> { + state.patterns.clear(); + state.patterns.addAll(call.getArgument(0)); + yield null; + } + default -> Answers.RETURNS_DEFAULTS.answer(call); + }); + metas.put(meta, state); + return meta; + } + + private CustomModelDataComponent modelComponent(List<Float> initial) { + List<Float> values = new ArrayList<>(initial); + return mock( + CustomModelDataComponent.class, + call -> + switch (call.getMethod().getName()) { + case "getFloats" -> List.copyOf(values); + case "setFloats" -> { + values.clear(); + values.addAll(call.getArgument(0)); + yield null; + } + default -> Answers.RETURNS_DEFAULTS.answer(call); + }); + } + + private PersistentDataContainer container(Map<NamespacedKey, StoredValue> data) { + return mock( + PersistentDataContainer.class, + call -> + switch (call.getMethod().getName()) { + case "set" -> { + data.put( + call.getArgument(0), + new StoredValue(call.getArgument(1), copyValue(call.getArgument(2)))); + yield null; + } + case "get", "getOrDefault" -> { + StoredValue stored = data.get((NamespacedKey) call.getArgument(0)); + Object value = + stored != null && stored.type().equals(call.getArgument(1)) + ? copyValue(stored.value()) + : null; + yield value != null + ? value + : call.getArguments().length == 3 ? call.getArgument(2) : null; + } + case "has" -> { + StoredValue stored = data.get((NamespacedKey) call.getArgument(0)); + yield stored != null + && (call.getArguments().length == 1 + || stored.type().equals(call.getArgument(1))); + } + case "getKeys" -> new java.util.HashSet<>(data.keySet()); + case "isEmpty" -> data.isEmpty(); + case "remove" -> { + data.remove((NamespacedKey) call.getArgument(0)); + yield null; + } + default -> Answers.RETURNS_DEFAULTS.answer(call); + }); + } + + private static Object copyValue(Object value) { + if (value instanceof byte[] bytes) return bytes.clone(); + if (value instanceof int[] ints) return ints.clone(); + if (value instanceof long[] longs) return longs.clone(); + return value; + } + + private record StoredValue(PersistentDataType<?, ?> type, Object value) {} + + private static final class MetaState { + String name; + Component display; + List<String> lore; + List<Component> componentLore; + OfflinePlayer owner; + String title; + String author; + final List<String> pages = new ArrayList<>(); + Integer model; + final Map<NamespacedKey, StoredValue> data = new LinkedHashMap<>(); + final Map<Enchantment, Integer> enchantments = new LinkedHashMap<>(); + final Set<org.bukkit.inventory.ItemFlag> flags = new java.util.HashSet<>(); + List<Float> modelFloats = List.of(); + final List<Pattern> patterns = new ArrayList<>(); + + MetaState copy() { + MetaState copy = new MetaState(); + copy.name = name; + copy.display = display; + copy.lore = lore == null ? null : new ArrayList<>(lore); + copy.componentLore = componentLore == null ? null : List.copyOf(componentLore); + copy.owner = owner; + copy.title = title; + copy.author = author; + copy.pages.addAll(pages); + copy.model = model; + copy.enchantments.putAll(enchantments); + copy.flags.addAll(flags); + copy.modelFloats = List.copyOf(modelFloats); + copy.patterns.addAll(patterns); + data.forEach( + (key, value) -> + copy.data.put(key, new StoredValue(value.type(), copyValue(value.value())))); + return copy; + } + + boolean same(MetaState other) { + return Objects.equals(name, other.name) + && Objects.equals(display, other.display) + && Objects.equals(lore, other.lore) + && Objects.equals(componentLore, other.componentLore) + && Objects.equals(owner, other.owner) + && Objects.equals(title, other.title) + && Objects.equals(author, other.author) + && pages.equals(other.pages) + && Objects.equals(model, other.model) + && enchantments.equals(other.enchantments) + && flags.equals(other.flags) + && modelFloats.equals(other.modelFloats) + && patterns.equals(other.patterns) + && data.keySet().equals(other.data.keySet()) + && data.entrySet().stream() + .allMatch( + entry -> { + StoredValue value = other.data.get(entry.getKey()); + return entry.getValue().type().equals(value.type()) + && Objects.deepEquals(entry.getValue().value(), value.value()); + }); + } + + boolean empty() { + return name == null + && (lore == null || lore.isEmpty()) + && model == null + && owner == null + && title == null + && author == null + && pages.isEmpty() + && enchantments.isEmpty() + && flags.isEmpty() + && patterns.isEmpty() + && data.isEmpty(); + } + } + + private static final class ItemState { + Material type; + int amount = 1; + MetaState meta = new MetaState(); + + ItemState(Material type) { + this.type = type; + } + + ItemState copy() { + ItemState copy = new ItemState(type); + copy.amount = amount; + copy.meta = meta.copy(); + return copy; + } + } + + @Override + public void close() { + try { + itemConstruction.close(); + } finally { + try { + bukkit.close(); + } finally { + SimpleFactions.plugin = previousPlugin; + } + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/testsupport/PersistenceFilesFixture.java b/src/test/java/net/tfminecraft/simplefactions/testsupport/PersistenceFilesFixture.java new file mode 100644 index 00000000..c031a79d --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/testsupport/PersistenceFilesFixture.java @@ -0,0 +1,68 @@ +package net.tfminecraft.simplefactions.testsupport; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.UUID; + +/** Isolates legacy relative persistence paths and restores any pre-existing checkout data. */ +public final class PersistenceFilesFixture implements AutoCloseable { + public final Path root = Path.of("plugins", "SimpleFactions").toAbsolutePath(); + private final Path backup = + root.resolveSibling("SimpleFactions.coverage-backup-" + UUID.randomUUID()); + private final boolean hadOriginal; + private boolean closed; + + public PersistenceFilesFixture() throws IOException { + Files.createDirectories(root.getParent()); + hadOriginal = Files.exists(root); + if (hadOriginal) Files.move(root, backup); + try { + Files.createDirectory(root); + } catch (IOException failure) { + if (hadOriginal) Files.move(backup, root); + throw failure; + } + } + + public Path write(String relative, String contents) throws IOException { + Path path = root.resolve(relative); + Files.createDirectories(path.getParent()); + return Files.writeString(path, contents); + } + + public void remove(String relative) throws IOException { + Path path = root.resolve(relative); + if (!Files.exists(path)) return; + try (var entries = Files.walk(path)) { + for (Path entry : entries.sorted(Comparator.reverseOrder()).toList()) Files.delete(entry); + } + } + + @Override + public void close() throws IOException { + if (closed) return; + closed = true; + IOException failure = null; + try { + remove(""); + } catch (IOException cleanupFailure) { + failure = cleanupFailure; + } + if (hadOriginal) { + try { + if (Files.exists(root)) { + Path quarantine = + root.resolveSibling("SimpleFactions.coverage-leftovers-" + UUID.randomUUID()); + Files.move(root, quarantine); + } + Files.move(backup, root); + } catch (IOException restoreFailure) { + if (failure == null) failure = restoreFailure; + else failure.addSuppressed(restoreFailure); + } + } + if (failure != null) throw failure; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/testsupport/PersistenceFilesFixtureTest.java b/src/test/java/net/tfminecraft/simplefactions/testsupport/PersistenceFilesFixtureTest.java new file mode 100644 index 00000000..8161090c --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/testsupport/PersistenceFilesFixtureTest.java @@ -0,0 +1,203 @@ +package net.tfminecraft.simplefactions.testsupport; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mockStatic; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class PersistenceFilesFixtureTest { + private static final byte[] ORIGINAL = {0, 7, -1, 20, 0}; + + @Test + void normalCloseRestoresOriginalBytesAndARepeatedCloseCannotDeleteThem() throws Exception { + try (var outer = new PersistenceFilesFixture()) { + Path original = originalFile(outer); + Set<Path> before = artifacts(outer.root.getParent()); + var inner = new PersistenceFilesFixture(); + Path backup = newArtifact(outer.root.getParent(), before, "backup"); + try { + inner.write("fixture-created.txt", "temporary"); + inner.close(); + assertArrayEquals(ORIGINAL, Files.readAllBytes(original)); + assertFalse(Files.exists(outer.root.resolve("fixture-created.txt"))); + assertEquals(before, artifacts(outer.root.getParent())); + + assertDoesNotThrow(inner::close); + assertArrayEquals(ORIGINAL, Files.readAllBytes(original)); + } finally { + restoreAndCleanTestArtifacts(outer.root, backup, before); + } + } + } + + @Test + void normalCloseWithoutAnOriginalRemovesOnlyItsTestTree() throws Exception { + try (var outer = new PersistenceFilesFixture()) { + outer.remove(""); + Set<Path> before = artifacts(outer.root.getParent()); + var inner = new PersistenceFilesFixture(); + inner.write("nested/fixture-created.txt", "temporary"); + + inner.close(); + + assertFalse(Files.exists(outer.root)); + assertEquals(before, artifacts(outer.root.getParent())); + } + } + + @Test + void failedCleanupRestoresOriginalBytesAndRetainsOnlyTheTestLeftoversInQuarantine() + throws Exception { + try (var outer = new PersistenceFilesFixture()) { + Path original = originalFile(outer); + Set<Path> before = artifacts(outer.root.getParent()); + var inner = new PersistenceFilesFixture(); + Path backup = newArtifact(outer.root.getParent(), before, "backup"); + Path blocked = inner.write("fixture-created.txt", "retain this test file"); + IOException cleanupFailure = new IOException("Injected fixture file deletion failure"); + try { + IOException reported; + try (var files = + mockStatic( + Files.class, + invocation -> { + if (invocation.getMethod().getName().equals("delete") + && blocked.equals(invocation.getArgument(0))) throw cleanupFailure; + return invocation.callRealMethod(); + })) { + reported = assertThrows(IOException.class, inner::close); + } + + assertSame(cleanupFailure, reported); + assertEquals(0, reported.getSuppressed().length); + assertTrue( + Files.isRegularFile(original), "The original must return despite cleanup failure"); + assertArrayEquals(ORIGINAL, Files.readAllBytes(original)); + assertFalse(Files.exists(backup)); + Path quarantine = newArtifact(outer.root.getParent(), before, "leftovers"); + assertEquals( + "retain this test file", Files.readString(quarantine.resolve("fixture-created.txt"))); + assertFalse(Files.exists(quarantine.resolve("original/data.bin"))); + assertFalse(Files.exists(outer.root.resolve("fixture-created.txt"))); + assertDoesNotThrow(inner::close); + assertArrayEquals(ORIGINAL, Files.readAllBytes(original)); + } finally { + restoreAndCleanTestArtifacts(outer.root, backup, before); + } + assertEquals(before, artifacts(outer.root.getParent())); + } + } + + @ParameterizedTest + @ValueSource(strings = {"quarantine", "restore"}) + void aFailedRecoveryPreservesTheOriginalBackupAndReportsBothFailures(String failureStage) + throws Exception { + try (var outer = new PersistenceFilesFixture()) { + originalFile(outer); + Set<Path> before = artifacts(outer.root.getParent()); + var inner = new PersistenceFilesFixture(); + Path backup = newArtifact(outer.root.getParent(), before, "backup"); + Path blocked = inner.write("fixture-created.txt", "retain this test file"); + IOException cleanupFailure = new IOException("Injected fixture file deletion failure"); + IOException recoveryFailure = new IOException("Injected " + failureStage + " move failure"); + try { + IOException reported; + try (var files = + mockStatic( + Files.class, + invocation -> { + String method = invocation.getMethod().getName(); + if (method.equals("delete") && blocked.equals(invocation.getArgument(0))) { + throw cleanupFailure; + } + if (method.equals("move")) { + Path source = invocation.getArgument(0); + if ((failureStage.equals("quarantine") && source.equals(outer.root)) + || (failureStage.equals("restore") && source.equals(backup))) { + throw recoveryFailure; + } + } + return invocation.callRealMethod(); + })) { + reported = assertThrows(IOException.class, inner::close); + } + + assertSame(cleanupFailure, reported); + assertArrayEquals(new Throwable[] {recoveryFailure}, reported.getSuppressed()); + assertArrayEquals(ORIGINAL, Files.readAllBytes(backup.resolve("original/data.bin"))); + if (failureStage.equals("quarantine")) { + assertEquals("retain this test file", Files.readString(blocked)); + assertEquals(Set.of(backup), newArtifacts(outer.root.getParent(), before)); + } else { + assertFalse(Files.exists(outer.root)); + Path quarantine = newArtifact(outer.root.getParent(), before, "leftovers"); + assertEquals( + "retain this test file", Files.readString(quarantine.resolve("fixture-created.txt"))); + } + } finally { + restoreAndCleanTestArtifacts(outer.root, backup, before); + } + assertArrayEquals(ORIGINAL, Files.readAllBytes(outer.root.resolve("original/data.bin"))); + assertEquals(before, artifacts(outer.root.getParent())); + } + } + + private Path originalFile(PersistenceFilesFixture outer) throws IOException { + Path file = outer.root.resolve("original/data.bin"); + Files.createDirectories(file.getParent()); + return Files.write(file, ORIGINAL); + } + + private Set<Path> artifacts(Path parent) throws IOException { + try (var files = Files.list(parent)) { + return new HashSet<>( + files + .filter(path -> path.getFileName().toString().startsWith("SimpleFactions.coverage-")) + .toList()); + } + } + + private Set<Path> newArtifacts(Path parent, Set<Path> before) throws IOException { + Set<Path> result = artifacts(parent); + result.removeAll(before); + return result; + } + + private Path newArtifact(Path parent, Set<Path> before, String kind) throws IOException { + var matching = + newArtifacts(parent, before).stream() + .filter( + path -> + path.getFileName() + .toString() + .startsWith("SimpleFactions.coverage-" + kind + "-")) + .toList(); + assertEquals(1, matching.size(), "Expected exactly one new " + kind + " directory"); + return matching.getFirst(); + } + + private void restoreAndCleanTestArtifacts(Path root, Path backup, Set<Path> before) + throws IOException { + // Only the outer fixture owns real checkout data; this backup contains our synthetic bytes. + if (Files.exists(backup)) { + deleteTestTree(root); + Files.move(backup, root); + } + for (Path testArtifact : newArtifacts(root.getParent(), before)) deleteTestTree(testArtifact); + } + + private void deleteTestTree(Path root) throws IOException { + if (!Files.exists(root)) return; + try (var entries = Files.walk(root)) { + for (Path entry : entries.sorted(Comparator.reverseOrder()).toList()) Files.delete(entry); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/testsupport/TestRegistryAccess.java b/src/test/java/net/tfminecraft/simplefactions/testsupport/TestRegistryAccess.java index 966594f1..03ec0945 100644 --- a/src/test/java/net/tfminecraft/simplefactions/testsupport/TestRegistryAccess.java +++ b/src/test/java/net/tfminecraft/simplefactions/testsupport/TestRegistryAccess.java @@ -1,61 +1,181 @@ package net.tfminecraft.simplefactions.testsupport; +import io.papermc.paper.registry.RegistryAccess; +import io.papermc.paper.registry.RegistryKey; import java.lang.reflect.Proxy; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; - import org.bukkit.Keyed; +import org.bukkit.block.BlockType; import org.bukkit.NamespacedKey; import org.bukkit.Registry; import org.bukkit.Sound; - -import io.papermc.paper.registry.RegistryAccess; -import io.papermc.paper.registry.RegistryKey; +import org.bukkit.attribute.Attribute; +import org.bukkit.block.banner.PatternType; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.MenuType; /** * Stands in for the server's registries, which Paper finds through {@link java.util.ServiceLoader}. * Without it the first {@code Sound} constant a test touches fails to initialise, and every later - * test in the same JVM gets {@code NoClassDefFoundError}. Only sounds resolve; any other registry - * lookup fails as it would without a server. + * test in the same JVM gets {@code NoClassDefFoundError}. Sound, enchantment, banner-pattern and + * menu/attribute identifiers and block air classification resolve; all other registries and server-backed operations remain unavailable. */ public final class TestRegistryAccess implements RegistryAccess { - private static final Map<NamespacedKey, Sound> SOUNDS = new ConcurrentHashMap<>(); - - @Override - @Deprecated - public <T extends Keyed> Registry<T> getRegistry(Class<T> type) { - return registry(Sound.class.equals(type)); - } - - @Override - public <T extends Keyed> Registry<T> getRegistry(RegistryKey<T> key) { - return registry(RegistryKey.SOUND_EVENT.equals(key)); - } - - @SuppressWarnings("unchecked") - private static <T extends Keyed> Registry<T> registry(boolean sounds) { - return (Registry<T>) Proxy.newProxyInstance(Registry.class.getClassLoader(), new Class<?>[] { Registry.class }, - (self, method, args) -> switch (method.getName()) { - case "equals" -> self == args[0]; - case "hashCode" -> System.identityHashCode(self); - case "toString" -> sounds ? "TestRegistry[sounds]" : "TestRegistry[unavailable]"; - case "get", "getOrThrow" -> { - if (!sounds) throw new IllegalStateException("Only sounds are available without a server"); - yield SOUNDS.computeIfAbsent((NamespacedKey) args[0], TestRegistryAccess::sound); - } - default -> throw new UnsupportedOperationException(method.getName()); - }); - } - - private static Sound sound(NamespacedKey key) { - return (Sound) Proxy.newProxyInstance(Sound.class.getClassLoader(), new Class<?>[] { Sound.class }, - (self, method, args) -> switch (method.getName()) { - case "equals" -> self == args[0]; - case "hashCode" -> key.hashCode(); - case "toString" -> "Sound[" + key + "]"; - case "getKey", "key" -> key; - default -> throw new UnsupportedOperationException(method.getName()); - }); - } + private static final Map<NamespacedKey, Sound> SOUNDS = new ConcurrentHashMap<>(); + private static final Map<NamespacedKey, Enchantment> ENCHANTMENTS = new ConcurrentHashMap<>(); + private static final Map<NamespacedKey, PatternType> PATTERNS = new ConcurrentHashMap<>(); + + private static final Map<NamespacedKey, BlockType> BLOCKS = new ConcurrentHashMap<>(); + + private static final Map<NamespacedKey, MenuType> MENUS = new ConcurrentHashMap<>(); + private static final Map<NamespacedKey, Attribute> ATTRIBUTES = new ConcurrentHashMap<>(); + + @Override + @Deprecated + public <T extends Keyed> Registry<T> getRegistry(Class<T> type) { + return registry( + Sound.class.equals(type), + Enchantment.class.equals(type), + PatternType.class.equals(type), + MenuType.class.equals(type), BlockType.class.equals(type), Attribute.class.equals(type)); + } + + @Override + public <T extends Keyed> Registry<T> getRegistry(RegistryKey<T> key) { + return registry( + RegistryKey.SOUND_EVENT.equals(key), + RegistryKey.ENCHANTMENT.equals(key), + RegistryKey.BANNER_PATTERN.equals(key), + RegistryKey.MENU.equals(key), RegistryKey.BLOCK.equals(key), RegistryKey.ATTRIBUTE.equals(key)); + } + + @SuppressWarnings("unchecked") + private static <T extends Keyed> Registry<T> registry( + boolean sounds, boolean enchantments, boolean patterns, boolean menus, boolean blocks, boolean attributes) { + return (Registry<T>) + Proxy.newProxyInstance( + Registry.class.getClassLoader(), + new Class<?>[] {Registry.class}, + (self, method, args) -> + switch (method.getName()) { + case "equals" -> self == args[0]; + case "hashCode" -> System.identityHashCode(self); + case "toString" -> sounds ? "TestRegistry[sounds]" : "TestRegistry[unavailable]"; + case "getKey" -> ((Keyed) args[0]).getKey(); + case "get", "getOrThrow" -> { + if (attributes) + yield ATTRIBUTES.computeIfAbsent( + (NamespacedKey) args[0], TestRegistryAccess::attribute); + if (blocks) { + Class.forName(BlockType.class.getName(), true, BlockType.class.getClassLoader()); + NamespacedKey blockKey = NamespacedKey.fromString(args[0].toString()); + yield method.getName().equals("getOrThrow") + ? BLOCKS.computeIfAbsent(blockKey, TestRegistryAccess::block) + : BLOCKS.get(blockKey); + } + if (menus) + yield MENUS.computeIfAbsent( + (NamespacedKey) args[0], TestRegistryAccess::menu); + if (patterns) + yield method.getName().equals("get") + ? PATTERNS.get((NamespacedKey) args[0]) + : registerPattern((NamespacedKey) args[0]); + if (enchantments) + yield ENCHANTMENTS.computeIfAbsent( + (NamespacedKey) args[0], TestRegistryAccess::enchantment); + if (!sounds) + throw new IllegalStateException( + "This registry is unavailable without a server"); + yield SOUNDS.computeIfAbsent( + (NamespacedKey) args[0], TestRegistryAccess::sound); + } + default -> throw new UnsupportedOperationException(method.getName()); + }); + } + + private static Attribute attribute(NamespacedKey key) { + return (Attribute) Proxy.newProxyInstance(Attribute.class.getClassLoader(), + new Class<?>[] {Attribute.class}, (self, method, args) -> switch (method.getName()) { + case "equals" -> self == args[0]; + case "hashCode" -> key.hashCode(); + case "toString" -> key.toString(); + case "getKey", "key" -> key; + default -> throw new UnsupportedOperationException("Attribute identifier only: " + method.getName()); + }); + } + + private static BlockType block(NamespacedKey key) { + return (BlockType) Proxy.newProxyInstance(BlockType.class.getClassLoader(), + new Class<?>[] {BlockType.Typed.class}, (self, method, args) -> switch (method.getName()) { + case "equals" -> self == args[0]; + case "hashCode" -> key.hashCode(); + case "toString" -> key.toString(); + case "getKey", "key" -> key; + case "typed" -> self; + case "isAir" -> java.util.Set.of("air", "cave_air", "void_air").contains(key.getKey()); + default -> throw new UnsupportedOperationException("Block air classification only: " + method.getName()); + }); + } + + private static MenuType menu(NamespacedKey key) { + return (MenuType) + Proxy.newProxyInstance( + MenuType.class.getClassLoader(), + new Class<?>[] {MenuType.Typed.class}, + (self, method, args) -> + switch (method.getName()) { + case "equals" -> self == args[0]; + case "hashCode" -> key.hashCode(); + case "toString" -> key.toString(); + case "getKey", "key" -> key; + case "typed" -> self; + case "getInventoryViewClass" -> InventoryView.class; + default -> + throw new UnsupportedOperationException( + "Menu identifier only: " + method.getName()); + }); + } + + public static PatternType registerPattern(NamespacedKey key) { + return PATTERNS.computeIfAbsent( + key, + k -> + org.mockito.Mockito.mock( + PatternType.class, + invocation -> + switch (invocation.getMethod().getName()) { + case "getKey", "key" -> k; + case "getIdentifier" -> k.getKey(); + default -> org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation); + })); + } + + private static Enchantment enchantment(NamespacedKey key) { + return org.mockito.Mockito.mock( + Enchantment.class, + invocation -> + switch (invocation.getMethod().getName()) { + case "getKey", "key" -> key; + case "getName" -> key.getKey(); + default -> org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation); + }); + } + + private static Sound sound(NamespacedKey key) { + return (Sound) + Proxy.newProxyInstance( + Sound.class.getClassLoader(), + new Class<?>[] {Sound.class}, + (self, method, args) -> + switch (method.getName()) { + case "equals" -> self == args[0]; + case "hashCode" -> key.hashCode(); + case "toString" -> "Sound[" + key + "]"; + case "getKey", "key" -> key; + default -> throw new UnsupportedOperationException(method.getName()); + }); + } } diff --git a/src/test/java/net/tfminecraft/simplefactions/utils/DisplayNameLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/utils/DisplayNameLifecycleCoverageTest.java new file mode 100644 index 00000000..0881f83f --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/utils/DisplayNameLifecycleCoverageTest.java @@ -0,0 +1,104 @@ +package net.tfminecraft.simplefactions.utils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.utils.DisplayNameGate.NameOperation; +import net.tfminecraft.simplefactions.utils.DisplayNameGate.Result; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerQuitEvent; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class DisplayNameLifecycleCoverageTest { + private FactionDomainFixture fixture; + private Player player; + private Map<Object, Object> pending; + private Map<Object, Object> original; + + @BeforeEach + @SuppressWarnings("unchecked") + void setup() throws Exception { + fixture = new FactionDomainFixture(); + player = fixture.player("Alice"); + var field = DisplayNameGate.class.getDeclaredField("pending"); + field.setAccessible(true); + pending = (Map<Object, Object>) field.get(null); + original = new LinkedHashMap<>(pending); + pending.clear(); + } + + @AfterEach + void close() { + try { + pending.clear(); + pending.putAll(original); + } finally { + fixture.close(); + } + } + + @ParameterizedTest + @ValueSource(strings = {"lowerCamel", "UpperCamel", "lower_name"}) + void repeatingTheSameNameConfirmsAllOfItsSoftWarnings(String name) { + assertEquals( + Result.NEEDS_CONFIRM, DisplayNameGate.check(player, NameOperation.FACTION_CREATE, name)); + assertEquals(Result.OK, DisplayNameGate.check(player, NameOperation.FACTION_CREATE, name)); + assertTrue(pending.isEmpty()); + } + + @Test + void aStaleTimeoutDoesNotClearTheNewerNameConfirmation() { + assertEquals( + Result.NEEDS_CONFIRM, + DisplayNameGate.check(player, NameOperation.FACTION_CREATE, "UpperCamel")); + Runnable old = fixture.ui.tasks.removeLast(); + assertEquals( + Result.NEEDS_CONFIRM, + DisplayNameGate.check(player, NameOperation.FACTION_CREATE, "OtherCamel")); + old.run(); + assertEquals( + Result.OK, DisplayNameGate.check(player, NameOperation.FACTION_CREATE, "OtherCamel")); + } + + @Test + void theCurrentTimeoutExpiresTheNameConfirmation() { + DisplayNameGate.check(player, NameOperation.FACTION_CREATE, "UpperCamel"); + fixture.ui.tasks.removeLast().run(); + assertEquals( + Result.NEEDS_CONFIRM, + DisplayNameGate.check(player, NameOperation.FACTION_CREATE, "UpperCamel")); + } + + @Test + void changingOperationsOrQuittingRequiresFreshConsent() { + DisplayNameGate.check(player, NameOperation.FACTION_CREATE, "lower_name", true); + verify(player).sendMessage("§7Type the same name again in chat to keep it anyway."); + assertEquals( + Result.NEEDS_CONFIRM, + DisplayNameGate.check(player, NameOperation.GUILD_CREATE, "lower_name", false)); + new DisplayNameGate().onPlayerQuit(new PlayerQuitEvent(player, "quit")); + assertEquals( + Result.NEEDS_CONFIRM, + DisplayNameGate.check(player, NameOperation.GUILD_CREATE, "lower_name")); + } + + @Test + void wellFormedNamesClearPreviousWarningsAndEmptyFormattingIsSafe() { + DisplayNameGate.check(player, NameOperation.FACTION_CREATE, "lower_name"); + assertEquals( + Result.OK, DisplayNameGate.check(player, NameOperation.FACTION_CREATE, "Upper_Name")); + assertTrue(pending.isEmpty()); + assertEquals(Result.OK, DisplayNameGate.check(null, NameOperation.FACTION_CREATE, "lower")); + assertEquals(Result.OK, DisplayNameGate.check(player, null, "lower")); + assertFalse(DisplayNameGate.looksLikeMissingSpaces(null)); + assertTrue(DisplayNameGate.findUncapitalizedWord(null).isEmpty()); + assertTrue(DisplayNameGate.findUncapitalizedWord(" ").isEmpty()); + assertTrue(DisplayNameGate.findUncapitalizedWord("§a").isEmpty()); + assertTrue(DisplayNameGate.findUncapitalizedWord("1_Upper_Name").isEmpty()); + assertEquals("", DisplayNameGate.suggestUnderscores(null)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/utils/EconomicImpactCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/utils/EconomicImpactCoverageTest.java new file mode 100644 index 00000000..b73fb70a --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/utils/EconomicImpactCoverageTest.java @@ -0,0 +1,289 @@ +package net.tfminecraft.simplefactions.utils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import net.tfminecraft.simplefactions.diplomacy.RelationType; +import net.tfminecraft.simplefactions.government.proposal.TaxTarget; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.EconomicPreview; +import net.tfminecraft.simplefactions.laws.Law; +import net.tfminecraft.simplefactions.laws.LawGroup; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.ProvinceManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.handler.TaxHandler; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.MockedStatic; + +@SuppressWarnings("deprecation") +class EconomicImpactCoverageTest { + enum Kind { + LAW, + TAX, + TARIFF, + FAVOUR, + TRADE + } + + GuiTestFixture gui; + MockedStatic<FactionManager> factions; + MockedStatic<EconomicPreview> previews; + Player player; + Faction faction, target; + Guild ours; + ProvinceManager provinces, snapshot; + TaxHandler taxes; + LawGroup group; + Law law; + RelationType agreement; + EconomicPreview.Prepared prepared; + + @BeforeEach + void setUp() { + // The legacy stateless facade is also publicly constructible. + new EconomicImpact(); + gui = new GuiTestFixture(); + player = gui.player("Reader"); + faction = mock(Faction.class); + target = mock(Faction.class); + ours = guild("Our Guild", "Our Realm"); + provinces = mock(ProvinceManager.class); + snapshot = mock(ProvinceManager.class); + taxes = mock(TaxHandler.class); + group = mock(LawGroup.class); + law = mock(Law.class); + agreement = mock(RelationType.class); + prepared = mock(EconomicPreview.Prepared.class); + when(gui.plugin.getProvinceManager()).thenReturn(provinces); + when(faction.getTaxHandler()).thenReturn(taxes); + factions = mockStatic(FactionManager.class); + factions.when(() -> FactionManager.getGuildByMember("Reader")).thenReturn(ours); + previews = mockStatic(EconomicPreview.class); + previews.when(EconomicPreview::current).thenReturn(prepared); + previews.when(() -> EconomicPreview.copyOf(prepared)).thenReturn(snapshot); + Map<Guild, Double> result = Map.of(ours, 12.5); + when(provinces.previewLawIncomeExact(faction, group, law)).thenReturn(result); + when(taxes.getTaxChangeEffects(TaxTarget.GUILDS, "subject", 17.0)).thenReturn(result); + when(provinces.previewTariffRateChange(faction, "subject", 17.0)).thenReturn(result); + when(snapshot.previewTariffRateChange(faction, "subject", 17.0)).thenReturn(result); + when(provinces.previewFavourRepressIncomeExact(faction, ours, true)).thenReturn(result); + when(provinces.previewTradeAgreementIncomeExact(faction, target, agreement)).thenReturn(result); + previews.when(() -> EconomicPreview.law(prepared, faction, group, law)).thenReturn(result); + previews + .when(() -> EconomicPreview.tax(faction, TaxTarget.GUILDS, "subject", 17.0)) + .thenReturn(result); + previews.when(() -> EconomicPreview.favour(prepared, ours, true)).thenReturn(result); + previews + .when(() -> EconomicPreview.trade(prepared, faction, target, agreement)) + .thenReturn(result); + } + + @AfterEach + void close() { + gui.runTasks(); + previews.close(); + factions.close(); + gui.close(); + } + + static Stream<Arguments> variants() { + return Stream.of(Kind.values()) + .flatMap(kind -> Stream.of(0, 1, 2).map(form -> Arguments.of(kind, form))); + } + + @ParameterizedTest + @MethodSource("variants") + void eachPublicOverloadShowsTheRequestedEstimate(Kind kind, int form) { + List<String> lore = new ArrayList<>(List.of("Existing description")); + apply(kind, lore, player, form, form == 2 ? new ItemStack(Material.PAPER).getItemMeta() : null); + String rendered = plain(lore); + assertTrue(rendered.startsWith("Existing description\n")); + assertTrue(rendered.contains("Our Guild: +12.50" + (form == 1 ? "d" : "d/day")), rendered); + assertTrue(rendered.contains(form == 1 ? "Impacts:" : "Estimated Economic Impacts:")); + verifyCalculation(kind, false); + assertTrue( + gui.tasks.isEmpty(), "Disabled plugin and metadata-free calls compute synchronously"); + } + + @ParameterizedTest + @EnumSource(Kind.class) + void estimatesAreDeferredAndReplaceThePlaceholderOnTheSameItem(Kind kind) { + when(gui.plugin.isEnabled()).thenReturn(true); + ItemStack item = new ItemStack(Material.PAPER); + ItemMeta meta = item.getItemMeta(); + List<String> lore = new ArrayList<>(List.of("Proposal")); + apply(kind, lore, player, 2, meta); + lore.add("Click to propose"); + meta.setLore(lore); + item.setItemMeta(meta); + player.getInventory().setItem(3, item); + assertTrue(plain(lore).contains("Calculating economic impact...")); + verifyNoInteractions(provinces, taxes); + gui.runTasks(); + assertEquals(1, gui.asyncTasks.size()); + gui.asyncTasks.removeFirst().run(); + assertTrue(plain(item.getItemMeta().getLore()).contains("Calculating")); + gui.runTasks(); + String rendered = plain(player.getInventory().getItem(3).getItemMeta().getLore()); + assertTrue(rendered.startsWith("Proposal\n")); + assertTrue(rendered.endsWith("Click to propose")); + assertTrue(rendered.contains("Our Guild: +12.50d/day")); + assertFalse(rendered.contains("Calculating")); + verifyCalculation(kind, true); + } + + @ParameterizedTest + @EnumSource(Kind.class) + void absentPlayerOrGuildLeavesExistingLoreAlone(Kind kind) { + List<String> lore = new ArrayList<>(List.of("Unchanged")); + apply(kind, lore, null, 0, null); + factions.when(() -> FactionManager.getGuildByMember("Reader")).thenReturn(null); + apply(kind, lore, player, 2, new ItemStack(Material.PAPER).getItemMeta()); + assertEquals(List.of("Unchanged"), lore); + verifyNoInteractions(provinces, taxes); + } + + @Test + void rendererPrioritizesOurGuildThenTheFiveLargestOtherChanges() { + LinkedHashMap<Guild, Double> changes = new LinkedHashMap<>(); + changes.put(guild("Tiny", "Realm"), 0.25); + changes.put(guild("Zero", "Realm"), 0.0); + changes.put(ours, -2.0); + changes.put(guild("Third", "C"), 30.0); + changes.put(guild("First", "A"), -50.0); + changes.put(guild("Fifth", "E"), 10.0); + changes.put(guild("Second", "B"), 40.0); + changes.put(guild("Fourth", "D"), -20.0); + List<String> lore = new ArrayList<>(); + EconomicImpact.write(lore, changes, ours, false); + List<String> rows = + lore.stream().map(ChatColor::stripColor).filter(line -> line.contains("d/day")).toList(); + assertEquals( + List.of( + " Our Guild: -2.00d/day", + " First (A): -50.00d/day", + " Second (B): +40.00d/day", + " Third (C): +30.00d/day", + " Fourth (D): -20.00d/day", + " Fifth (E): +10.00d/day"), + rows); + assertFalse(plain(lore).contains("Tiny")); + assertFalse(plain(lore).contains("Zero")); + } + + @Test + void compactAndUnchangedEstimatesHaveReadableDistinctFormats() { + List<String> compact = new ArrayList<>(); + EconomicImpact.write(compact, Map.of(guild("Other", "Realm"), -3.0), ours, true); + assertTrue(plain(compact).contains("Other: -3.00d")); + assertFalse(plain(compact).contains("Realm")); + assertFalse(plain(compact).contains("No Change")); + List<String> unchanged = new ArrayList<>(); + EconomicImpact.write(unchanged, Map.of(ours, 0.0), ours, false); + assertTrue(plain(unchanged).endsWith(" No Economic Change")); + List<String> empty = new ArrayList<>(); + EconomicImpact.write(empty, Map.of(), ours, true); + assertTrue(plain(empty).endsWith("No Change")); + } + + private void verifyCalculation(Kind kind, boolean async) { + if (async) { + switch (kind) { + case LAW -> previews.verify(() -> EconomicPreview.law(prepared, faction, group, law)); + case TAX -> + previews.verify(() -> EconomicPreview.tax(faction, TaxTarget.GUILDS, "subject", 17.0)); + case TARIFF -> verify(snapshot).previewTariffRateChange(faction, "subject", 17.0); + case FAVOUR -> previews.verify(() -> EconomicPreview.favour(prepared, ours, true)); + case TRADE -> + previews.verify(() -> EconomicPreview.trade(prepared, faction, target, agreement)); + } + } else { + switch (kind) { + case LAW -> verify(provinces).previewLawIncomeExact(faction, group, law); + case TAX -> verify(taxes).getTaxChangeEffects(TaxTarget.GUILDS, "subject", 17.0); + case TARIFF -> verify(provinces).previewTariffRateChange(faction, "subject", 17.0); + case FAVOUR -> verify(provinces).previewFavourRepressIncomeExact(faction, ours, true); + case TRADE -> + verify(provinces).previewTradeAgreementIncomeExact(faction, target, agreement); + } + } + } + + private void apply(Kind kind, List<String> lore, Player player, int form, ItemMeta meta) { + switch (kind) { + case LAW -> { + if (form == 0) EconomicImpact.applyEconomicChange(lore, player, faction, group, law); + else if (form == 1) + EconomicImpact.applyEconomicChange(lore, player, faction, group, law, true); + else + EconomicImpact.applyEconomicChange(lore, player, faction, group, law, false, meta, false); + } + case TAX -> { + if (form == 0) + EconomicImpact.applyTaxImpact(lore, player, faction, TaxTarget.GUILDS, "subject", 17.0); + else if (form == 1) + EconomicImpact.applyTaxImpact( + lore, player, faction, TaxTarget.GUILDS, "subject", 17.0, true); + else + EconomicImpact.applyTaxImpact( + lore, player, faction, TaxTarget.GUILDS, "subject", 17.0, false, meta, false); + } + case TARIFF -> { + if (form == 0) EconomicImpact.applyTariffImpact(lore, player, faction, "subject", 17.0); + else if (form == 1) + EconomicImpact.applyTariffImpact(lore, player, faction, "subject", 17.0, true); + else + EconomicImpact.applyTariffImpact( + lore, player, faction, "subject", 17.0, false, meta, false); + } + case FAVOUR -> { + if (form == 0) EconomicImpact.applyFavourRepressChange(lore, player, faction, ours, true); + else if (form == 1) + EconomicImpact.applyFavourRepressChange(lore, player, faction, ours, true, true); + else + EconomicImpact.applyFavourRepressChange( + lore, player, faction, ours, true, false, meta, false); + } + case TRADE -> { + if (form == 0) + EconomicImpact.applyTradeAgreementChange(lore, player, faction, target, agreement); + else if (form == 1) + EconomicImpact.applyTradeAgreementChange(lore, player, faction, target, agreement, true); + else + EconomicImpact.applyTradeAgreementChange( + lore, player, faction, target, agreement, false, meta, false); + } + } + } + + private Guild guild(String name, String realm) { + Guild guild = mock(Guild.class); + Faction host = mock(Faction.class); + when(guild.getName()).thenReturn(name); + when(guild.getFaction()).thenReturn(host); + when(host.getName()).thenReturn(realm); + return guild; + } + + private static String plain(List<String> lore) { + return ChatColor.stripColor(String.join("\n", lore)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/utils/EconomicImpactServiceCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/utils/EconomicImpactServiceCoverageTest.java new file mode 100644 index 00000000..0f571a17 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/utils/EconomicImpactServiceCoverageTest.java @@ -0,0 +1,355 @@ +package net.tfminecraft.simplefactions.utils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.EconomicPreview; +import net.tfminecraft.simplefactions.keys.Keys; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BookMeta; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +@SuppressWarnings("deprecation") +class EconomicImpactServiceCoverageTest { + enum Place { + TOP, + BOTTOM, + CURSOR + } + + GuiTestFixture gui; + Player player; + Guild viewer; + Inventory top; + AtomicReference<ItemStack> cursor; + MockedStatic<EconomicPreview> previews; + EconomicPreview.Prepared prepared; + AtomicInteger calculations; + + @BeforeEach + void setUp() { + gui = new GuiTestFixture(); + when(gui.plugin.isEnabled()).thenReturn(true); + player = gui.player("Reader"); + top = gui.inventory(null, 9, "Proposal"); + player.openInventory(top); + cursor = new AtomicReference<>(); + when(player.getItemOnCursor()).thenAnswer(call -> cursor.get()); + doAnswer( + call -> { + cursor.set(call.getArgument(0)); + return null; + }) + .when(player) + .setItemOnCursor(nullable(ItemStack.class)); + viewer = mock(Guild.class); + when(viewer.getName()).thenReturn("Our Guild"); + previews = mockStatic(EconomicPreview.class); + prepared = mock(EconomicPreview.Prepared.class); + previews.when(EconomicPreview::current).thenReturn(prepared); + calculations = new AtomicInteger(); + } + + @AfterEach + void close() { + when(player.isOnline()).thenReturn(false); + gui.runTasks(); + previews.close(); + gui.close(); + } + + @ParameterizedTest + @EnumSource(Place.class) + void updatesTheStampedItemInEachSupportedLocation(Place place) { + ItemStack item = + enqueue( + Material.PAPER, + false, + List.of("Before", EconomicImpact.calculatingLine(), "After"), + 7.25); + put(place, item); + gui.runTasks(); + assertEquals(0, calculations.get()); + assertEquals(1, gui.asyncTasks.size()); + runAsync(); + assertEquals(1, calculations.get()); + assertTrue(text(item).contains("Calculating"), "The worker cannot write menu state"); + gui.runTasks(); + assertTrue(text(item).startsWith("Before\n")); + assertTrue(text(item).endsWith("After")); + assertTrue(text(item).contains("Our Guild: +7.25d/day")); + assertFalse(text(item).contains("Calculating")); + if (place == Place.CURSOR) verify(player).setItemOnCursor(item); + } + + @Test + void followsTheItemWhenItMovesWhileTheWorkerRuns() { + ItemStack item = enqueue(Material.PAPER, false, List.of(EconomicImpact.calculatingLine()), 9); + top.setItem(1, item); + gui.runTasks(); + runAsync(); + top.setItem(1, new ItemStack(Material.STONE)); + player.getInventory().setItem(7, item); + gui.runTasks(); + assertEquals(Material.STONE, top.getItem(1).getType()); + assertTrue(text(player.getInventory().getItem(7)).contains("+9.00d/day")); + } + + @Test + void anOlderResultCannotOverwriteANewerPreviewOnTheSameItem() { + ItemStack item = enqueue(Material.PAPER, false, List.of(EconomicImpact.calculatingLine()), 9); + top.setItem(1, item); + gui.runTasks(); + Runnable olderWorker = gui.asyncTasks.removeFirst(); + ItemMeta newer = item.getItemMeta(); + EconomicImpactService.enqueue( + player, newer, viewer, false, false, state -> Map.of(viewer, 22.0)); + item.setItemMeta(newer); + gui.runTasks(); + runAsync(); + gui.runTasks(); + assertTrue(text(item).contains("+22.00d/day")); + olderWorker.run(); + gui.runTasks(); + assertTrue(text(item).contains("+22.00d/day")); + assertFalse(text(item).contains("+9.00d/day")); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void aRemovedItemDoesNotReceiveAResult(boolean afterWorkerStarts) { + ItemStack item = enqueue(Material.PAPER, false, List.of(EconomicImpact.calculatingLine()), 4); + top.setItem(1, item); + if (afterWorkerStarts) { + gui.runTasks(); + runAsync(); + } + top.setItem(1, new ItemStack(Material.STONE)); + gui.runTasks(); + assertEquals(afterWorkerStarts ? 1 : 0, calculations.get()); + assertEquals(Material.STONE, top.getItem(1).getType()); + assertTrue(text(item).contains("Calculating")); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void disconnectedReadersAreNotCalculatedOrUpdated(boolean afterWorkerStarts) { + ItemStack item = enqueue(Material.PAPER, false, List.of(EconomicImpact.calculatingLine()), 4); + top.setItem(1, item); + if (afterWorkerStarts) { + gui.runTasks(); + runAsync(); + } + when(player.isOnline()).thenReturn(false); + gui.runTasks(); + assertEquals(afterWorkerStarts ? 1 : 0, calculations.get()); + assertTrue(text(item).contains("Calculating")); + verify(player, never()).openBook(any(ItemStack.class)); + } + + @Test + void disabledPluginDoesNotQueueAPublishAfterCalculation() { + ItemStack item = enqueue(Material.PAPER, false, List.of(EconomicImpact.calculatingLine()), 4); + top.setItem(1, item); + gui.runTasks(); + when(gui.plugin.isEnabled()).thenReturn(false); + runAsync(); + assertEquals(1, calculations.get()); + assertTrue(gui.tasks.isEmpty()); + assertTrue(text(item).contains("Calculating")); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void unavailableCalculationsReplaceThePlaceholderWithoutDiscardingDescriptions( + boolean throwsFailure) { + ItemStack item = new ItemStack(Material.PAPER); + ItemMeta meta = item.getItemMeta(); + meta.setLore(List.of("Before", EconomicImpact.calculatingLine(), "After")); + EconomicImpactService.enqueue( + player, + meta, + viewer, + false, + false, + state -> { + if (throwsFailure) throw new IllegalStateException("Preview unavailable"); + return null; + }); + item.setItemMeta(meta); + top.setItem(1, item); + finish(); + assertEquals("Before\nIncome estimate unavailable\nAfter", text(item)); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void existingItemsWithoutAPlaceholderAppendTheEstimate(boolean hasLore) { + ItemStack item = enqueue(Material.PAPER, false, hasLore ? List.of("Description") : null, -3); + top.setItem(1, item); + finish(); + assertTrue(text(item).contains("Our Guild: -3.00d/day")); + if (hasLore) assertTrue(text(item).startsWith("Description\n")); + } + + static Stream<Arguments> bookViews() { + return Stream.of(InventoryType.CRAFTING, InventoryType.CREATIVE, InventoryType.CHEST) + .flatMap(type -> Stream.of(0, 1, 2, 3).map(pages -> Arguments.of(type, pages))); + } + + @ParameterizedTest + @MethodSource("bookViews") + void aBoundCouncilBookUpdatesOnlyItsEstimateAndRespectsOpenMenus( + InventoryType openType, int pageCount) { + ItemStack book = enqueue(Material.WRITTEN_BOOK, true, null, 13); + BookMeta meta = (BookMeta) book.getItemMeta(); + List<String> pages = + new ArrayList<>(List.of("Proposal", "Calculating", "Other details").subList(0, pageCount)); + meta.setPages(pages); + meta.setTitle("Council proposal"); + meta.setAuthor("Council"); + book.setItemMeta(meta); + EconomicImpactService.bindBook(meta, book); + when(player.getOpenInventory().getType()).thenReturn(openType); + finish(); + BookMeta updated = (BookMeta) book.getItemMeta(); + assertEquals(pageCount < 2 ? pageCount + 1 : pageCount, updated.getPageCount()); + assertTrue( + ChatColor.stripColor(updated.getPage(pageCount == 0 ? 1 : 2)).contains("+13.00d/day")); + if (pageCount > 0) assertEquals("Proposal", updated.getPage(1)); + if (pageCount == 3) assertEquals("Other details", updated.getPage(3)); + assertEquals("Council proposal", updated.getTitle()); + assertEquals("Council", updated.getAuthor()); + if (openType == InventoryType.CHEST) verify(player, never()).openBook(any(ItemStack.class)); + else verify(player).openBook(book); + } + + @Test + void anUnboundBookCanBeFoundInInventoryAndThenPublished() { + ItemStack book = enqueue(Material.WRITTEN_BOOK, true, null, 5); + top.setItem(2, book); + finish(); + assertTrue( + ChatColor.stripColor(((BookMeta) book.getItemMeta()).getPage(1)).contains("+5.00d/day")); + EconomicImpactService.bindBook(book.getItemMeta(), book); // Already consumed pending request. + assertTrue(gui.tasks.isEmpty()); + } + + @Test + void anUnboundBookMissingFromInventoryDoesNotCalculate() { + enqueue(Material.WRITTEN_BOOK, true, null, 5); + gui.runTasks(); + assertTrue(gui.asyncTasks.isEmpty()); + assertEquals(0, calculations.get()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void aBoundBookWhoseMetadataWasReplacedIsNotReopened(boolean noMetadata) { + ItemStack book = enqueue(Material.WRITTEN_BOOK, true, null, 5); + EconomicImpactService.bindBook(book.getItemMeta(), book); + gui.runTasks(); + runAsync(); + if (noMetadata) when(book.getItemMeta()).thenReturn(null); + else { + ItemMeta meta = book.getItemMeta(); + meta.getPersistentDataContainer().remove(Keys.ECONOMY_PREVIEW); + book.setItemMeta(meta); + } + gui.runTasks(); + verify(player, never()).openBook(any(ItemStack.class)); + } + + @Test + void nonBookMetadataStillReceivesLoreWhenTheCallerRequestsABook() { + ItemStack item = enqueue(Material.PAPER, true, List.of("Description"), 6); + EconomicImpactService.bindBook(item.getItemMeta(), item); + finish(); + assertTrue(text(item).contains("+6.00d/day")); + } + + @Test + void invalidEnqueueAndBookBindingInputsDoNotScheduleWork() { + ItemStack book = new ItemStack(Material.WRITTEN_BOOK); + ItemMeta meta = book.getItemMeta(); + EconomicImpactService.Calculator calculator = state -> Map.of(); + EconomicImpactService.enqueue(null, meta, viewer, false, false, calculator); + EconomicImpactService.enqueue(player, null, viewer, false, false, calculator); + EconomicImpactService.enqueue(player, meta, viewer, false, false, null); + when(gui.plugin.isEnabled()).thenReturn(false); + EconomicImpactService.enqueue(player, meta, viewer, false, false, calculator); + SimpleFactions.plugin = null; + EconomicImpactService.enqueue(player, meta, viewer, false, false, calculator); + SimpleFactions.plugin = gui.plugin; + EconomicImpactService.bindBook(null, book); + EconomicImpactService.bindBook(meta, null); + EconomicImpactService.bindBook(meta, book); + assertTrue(gui.tasks.isEmpty()); + assertFalse(meta.getPersistentDataContainer().has(Keys.ECONOMY_PREVIEW)); + } + + private ItemStack enqueue(Material type, boolean book, List<String> lore, double delta) { + ItemStack item = new ItemStack(type); + ItemMeta meta = item.getItemMeta(); + if (lore != null) meta.setLore(lore); + EconomicImpactService.enqueue( + player, + meta, + viewer, + false, + book, + state -> { + assertSame( + prepared, state, "The worker receives the snapshot captured when the menu was built"); + calculations.incrementAndGet(); + return Map.of(viewer, delta); + }); + item.setItemMeta(meta); + return item; + } + + private void put(Place place, ItemStack item) { + switch (place) { + case TOP -> top.setItem(1, item); + case BOTTOM -> player.getInventory().setItem(2, item); + case CURSOR -> cursor.set(item); + } + } + + private void runAsync() { + gui.asyncTasks.removeFirst().run(); + } + + private void finish() { + gui.runTasks(); + runAsync(); + gui.runTasks(); + } + + private String text(ItemStack item) { + return ChatColor.stripColor(String.join("\n", item.getItemMeta().getLore())); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/utils/FactionCleanupCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/utils/FactionCleanupCoverageTest.java new file mode 100644 index 00000000..4ad67a8f --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/utils/FactionCleanupCoverageTest.java @@ -0,0 +1,101 @@ +package net.tfminecraft.simplefactions.utils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.GuiTestFixture; +import net.tfminecraft.simplefactions.testsupport.PersistenceFilesFixture; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.*; + +class FactionCleanupCoverageTest { + PersistenceFilesFixture disk; + GuiTestFixture ui; + Field daysField; + Object previous; + + @BeforeEach void setUp() throws Exception { + new FactionCleanup();disk=new PersistenceFilesFixture();ui=new GuiTestFixture(); + when(ui.plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + daysField=FactionCleanup.class.getDeclaredField("offlineDays");daysField.setAccessible(true); + previous=daysField.get(null);daysField.set(null,null); + } + @AfterEach void close() throws Exception { + try {daysField.set(null,previous);ui.close();}finally{disk.close();} + } + + @Test void missingEmptyAndNullFilesInitializeWithoutInventingOfflineTime() throws Exception { + assertEquals(0,FactionCleanup.daysOffline(null));assertFalse(Files.exists(disk.root.resolve("Cache"))); + assertEquals(0,FactionCleanup.daysOffline("Alice"));assertTrue(Files.isRegularFile(disk.root.resolve("Cache/logins.json"))); + for(String contents:List.of("","null")) { + disk.write("Cache/logins.json",contents);daysField.set(null,null); + assertEquals(0,FactionCleanup.daysOffline("Alice")); + } + FactionCleanup.ping(null);FactionCleanup.ping(" "); + assertEquals("null",Files.readString(disk.root.resolve("Cache/logins.json"))); + FactionCleanup.ping("ALICE"); + assertEquals(0,FactionCleanup.daysOffline("alice")); + assertTrue(Files.readString(disk.root.resolve("Cache/logins.json")).contains("\"alice\":0")); + } + + @Test void dailyAccountingMarksInactiveMembersWithoutRemovingThemAndResetsOnlinePlayers() throws Exception { + disk.write("Cache/logins.json","{\"alice\":20,\"bob\":null,\"cara\":30,\"former\":5}"); + Faction faction=mock(Faction.class);when(faction.getMembers()).thenReturn(Arrays.asList("Alice","Bob","Cara","New",null," ","dummy_guard")); + Player bob=ui.player("BOB");when(Bukkit.getOnlinePlayers()).thenAnswer(call -> Arrays.asList(null,bob)); + FactionCleanup.advanceOfflineDays(Arrays.asList(null,faction)); + assertEquals(21,FactionCleanup.daysOffline("ALICE"));assertEquals(0,FactionCleanup.daysOffline("Bob")); + assertEquals(31,FactionCleanup.daysOffline("Cara"));assertEquals(6,FactionCleanup.daysOffline("former")); + assertEquals(1,FactionCleanup.daysOffline("New"));assertEquals(0,FactionCleanup.daysOffline("dummy_guard")); + verify(faction,never()).forceRemoveMember(anyString()); + verify(ui.plugin.getLogger()).log(eq(Level.INFO),contains("alice is inactive after 21 days")); + FactionCleanup.ping("Alice");assertEquals(0,FactionCleanup.daysOffline("alice")); + } + + @Test void unreadableOrMalformedHistoryIsPreservedWhenAPlayerLogsIn() throws Exception { + String original="{\"alice\":18, broken}"; + disk.write("Cache/logins.json",original); + assertEquals(0,FactionCleanup.daysOffline("Alice")); + FactionCleanup.advanceOfflineDays(null);assertEquals(original,Files.readString(disk.root.resolve("Cache/logins.json"))); + FactionCleanup.ping("Bob"); + assertEquals(original,Files.readString(disk.root.resolve("Cache/logins.json")),"Login must not replace unreadable history with one player's record"); + } + + @Test void missingServerAndUnavailableOnlineLookupTreatPlayersAsOffline() throws Exception { + disk.write("Cache/logins.json","{\"alice\":2}"); + when(Bukkit.getServer()).thenReturn(null); + FactionCleanup.advanceOfflineDays(null);assertEquals(3,FactionCleanup.daysOffline("alice")); + when(Bukkit.getServer()).thenReturn(ui.server); + when(Bukkit.getOnlinePlayers()).thenThrow(new IllegalStateException("server stopping")); + FactionCleanup.advanceOfflineDays(List.of());assertEquals(4,FactionCleanup.daysOffline("alice")); + } + + @Test void storageFailuresDoNotOverwriteConflictingPathsOrEscapeIntoTheTick() throws Exception { + disk.write("Cache","occupied"); + assertEquals(0,FactionCleanup.daysOffline("Alice")); + assertDoesNotThrow(() -> FactionCleanup.advanceOfflineDays(null)); + assertDoesNotThrow(() -> FactionCleanup.ping("Alice")); + assertEquals("occupied",Files.readString(disk.root.resolve("Cache"))); + disk.remove("Cache");daysField.set(null,null);FactionCleanup.ping("Alice"); + disk.remove("Cache/logins.json");Files.createDirectory(disk.root.resolve("Cache/logins.json")); + assertDoesNotThrow(() -> FactionCleanup.advanceOfflineDays(null)); + assertDoesNotThrow(() -> FactionCleanup.ping("Bob")); + assertTrue(Files.isDirectory(disk.root.resolve("Cache/logins.json"))); + } + + @Test void cachedHistoryCanRecreateADeletedCacheFolderAndLogWithoutALivePlugin() throws Exception { + disk.write("Cache/logins.json","{\"alice\":20}");assertEquals(20,FactionCleanup.daysOffline("Alice")); + disk.remove("Cache");Faction faction=mock(Faction.class);when(faction.getMembers()).thenReturn(List.of("Alice")); + SimpleFactions previousPlugin=SimpleFactions.plugin;SimpleFactions.plugin=null; + try {FactionCleanup.advanceOfflineDays(List.of(faction));}finally{SimpleFactions.plugin=previousPlugin;} + assertEquals(21,FactionCleanup.daysOffline("Alice"));assertTrue(Files.isRegularFile(disk.root.resolve("Cache/logins.json"))); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/utils/FactionEconomicsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/utils/FactionEconomicsCoverageTest.java new file mode 100644 index 00000000..31f8ddce --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/utils/FactionEconomicsCoverageTest.java @@ -0,0 +1,668 @@ +package net.tfminecraft.simplefactions.utils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.zip.GZIPOutputStream; +import net.tfminecraft.denareconomy.accounts.OfflineModifier; +import net.tfminecraft.denareconomy.enums.Accounts; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.enums.FactionModifiers; +import net.tfminecraft.simplefactions.enums.RankType; +import net.tfminecraft.simplefactions.enums.Region; +import net.tfminecraft.simplefactions.espionage.EspionageConfig; +import net.tfminecraft.simplefactions.espionage.EspionageMath; +import net.tfminecraft.simplefactions.espionage.IntelligenceReport; +import net.tfminecraft.simplefactions.espionage.SpecialPosition; +import net.tfminecraft.simplefactions.espionage.SpecialPositionAssignment; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.BankManager; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.map.ProvinceGrid; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.BankPlacementValidator; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.FactionModifier; +import net.tfminecraft.simplefactions.objects.ModifierScale; +import net.tfminecraft.simplefactions.player.PlayerEconomyManager; +import net.tfminecraft.simplefactions.player.income.PlayerCashflow; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank; +import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; +import org.bukkit.ChatColor; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +class FactionEconomicsCoverageTest { + @TempDir Path temporary; + + @Test + void editingAModifierRetainsTwoDecimalPlaces() { + FactionModifier modifier = new FactionModifier(FactionModifiers.TAX_MULTIPLIER, 12.5); + modifier.edit(0.25); + assertEquals(12.75, modifier.getAmount()); + modifier.edit(-0.125); + assertEquals(12.63, modifier.getAmount()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void configuredModifierIdentifiersDoNotDependOnTheServerLocale(boolean mapped) { + Locale original = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + YamlConfiguration config = new YamlConfiguration(); + Object entry = + mapped ? Map.of("type", "military_upkeep", "amount", 12.5) : "military_upkeep(12.5)"; + config.set("modifiers", List.of(entry)); + List<FactionModifier> loaded = new ArrayList<>(); + FactionModifier.addFromConfig(config, "modifiers", loaded); + assertEquals(1, loaded.size(), "A valid configured modifier must survive a locale change"); + assertEquals(FactionModifiers.MILITARY_UPKEEP, loaded.getFirst().getType()); + assertEquals(12.5, loaded.getFirst().getAmount()); + } finally { + Locale.setDefault(original); + } + } + + @ParameterizedTest + @CsvSource({ + "LEVY,Levy Contribution", + "MILITARY_UPKEEP,Military Upkeep", + "NODE_SPEED,Node Speed", + "PRESTIGE,Prestige to Overlord", + "PRESTIGE_BONUS,Prestige Bonus", + "PRESTIGE_MALUS,Prestige Malus", + "TRIBUTE,Tribute", + "TAX_MULTIPLIER,Tax Multiplier", + "DE_JURE,De Jure Requirement", + "STABILITY_INFLUENCE,Stability Influence", + "TRADE_POWER,Trade Power", + "PRODUCTION,Production", + "INSTALLATION_ACCESS,Installation Access", + "DIPLOMATIC_CAPACITY_MULTIPLIER,Diplomatic Capacity Multiplier", + "ADMIN_POWER_MULTIPLIER,Admin Power Multiplier", + "ADMIN_POWER_GAIN_MULTIPLIER,Admin Power Gain Multiplier" + }) + void eachConfiguredModifierHasADescriptivePlayerFacingLabel(FactionModifiers type, String label) { + double oldRequirement = Cache.deJureRequirement; + Cache.deJureRequirement = 50; + try { + String text = ChatColor.stripColor(new FactionModifier(type, 12.345).getString()); + assertTrue(text.startsWith(label + ": "), text); + String expected = + type == FactionModifiers.INSTALLATION_ACCESS + ? "1234.5" + : type == FactionModifiers.TAX_MULTIPLIER + ? "+12.35" + : type == FactionModifiers.PRESTIGE_MALUS ? "-12.35" : "12.35"; + assertTrue(text.endsWith("(" + expected + "%)"), text); + } finally { + Cache.deJureRequirement = oldRequirement; + } + } + + @Test + void modifierLoreDistinguishesBeneficialSignsAndFractionalAccess() { + FactionModifier upkeep = new FactionModifier(FactionModifiers.MILITARY_UPKEEP, -2.5); + assertTrue(upkeep.getString().contains(StringFormatter.formatHex("§7(#87d65c-2.5%§7)"))); + FactionModifier access = new FactionModifier(FactionModifiers.INSTALLATION_ACCESS, 0.125); + assertTrue( + ChatColor.stripColor(access.getString(null, Region.FOREIGN_TERRITORY)) + .endsWith("(+12.5%)")); + assertTrue(ChatColor.stripColor(access.getString()).endsWith("(12.5%)")); + assertFalse(access.isMultiplier()); + assertTrue(new FactionModifier(FactionModifiers.TAX_MULTIPLIER, 1).isMultiplier()); + assertTrue( + new FactionModifier(FactionModifiers.PRODUCTION, -1) + .getString() + .contains(StringFormatter.formatHex("§7(#d65c5c-1%§7)"))); + } + + @Test + void yamlModifiersSkipMalformedEntriesAndKeepValidLegacyAliases() { + YamlConfiguration yaml = new YamlConfiguration(); + List<Object> entries = new ArrayList<>(); + entries.add("infrastructure_access(0.25)"); + entries.add(Map.of("type", "production", "amount", "7.5")); + entries.add(Map.of("type", "trade_power", "amount", "not-a-number")); + entries.add(Map.of("amount", 10)); + entries.add(Map.of("type", "not_a_modifier", "amount", 10)); + entries.add("invalid"); + entries.add(42); + yaml.set("modifiers", entries); + List<FactionModifier> loaded = new ArrayList<>(); + FactionModifier.addFromConfig(yaml, "modifiers", loaded); + assertEquals(3, loaded.size()); + assertEquals(FactionModifiers.INSTALLATION_ACCESS, loaded.get(0).getType()); + assertEquals(0.25, loaded.get(0).getAmount()); + assertEquals(7.5, loaded.get(1).getAmount()); + assertEquals(0.0, loaded.get(2).getAmount()); + assertNull(FactionModifier.fromYamlEntry(null)); + FactionModifier.addFromConfig(null, "modifiers", loaded); + FactionModifier.addFromConfig(yaml, "missing", loaded); + yaml.set("empty", List.of()); + FactionModifier.addFromConfig(yaml, "empty", loaded); + yaml.set("scalar", "production(50)"); + FactionModifier.addFromConfig(yaml, "scalar", loaded); + assertEquals(3, loaded.size(), "Missing or non-list settings must not invent modifiers"); + } + + @ParameterizedTest + @ValueSource( + strings = { + "invalid", + "not_a_modifier(5)", + "production(NaN)", + "production(Infinity)", + "production(-Infinity)" + }) + void malformedLawModifiersCannotPoisonTheRemainingEconomicEffects(String invalid) { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + fixture.lawGroup( + "economy", Map.of("effects.faction.modifiers", List.of(invalid, "production(7.5)"))); + Faction faction = fixture.saved("home", "Leader"); + assertEquals(7.5, faction.getModifier(FactionModifiers.PRODUCTION).getAmount()); + assertTrue( + faction.getModifiers().stream() + .allMatch( + modifier -> modifier.getType() != null && Double.isFinite(modifier.getAmount()))); + assertTrue( + faction.getCombinedModifiers().stream() + .anyMatch( + modifier -> + ChatColor.stripColor(modifier.getString()).startsWith("Production:"))); + } + } + + @Test + void malformedRankModifiersDoNotDiscardValidSiblingBonuses() { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("name", "Artisan"); + yaml.set("level", 1); + yaml.set("modifiers", List.of("invalid", "production(NaN)", "trade_power(7.5)")); + var rank = new net.tfminecraft.simplefactions.objects.PrestigeRank("artisan", yaml); + assertEquals(1, rank.getModifiers().size()); + FactionModifier valid = rank.getModifiers().getFirst(); + assertEquals(FactionModifiers.TRADE_POWER, valid.getType()); + assertEquals(7.5, valid.getAmount()); + assertTrue(rank.hasModifiers()); + } + + @ParameterizedTest + @ValueSource(strings = {"amount", "at_equal", "at_weaker", "at_stronger"}) + void mappedNonFiniteModifierNumbersCannotReachTheEconomicCalculation(String key) { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + Faction owner = fixture.saved("owner", "Owner"); + Faction partner = fixture.saved("partner", "Partner"); + owner.setPrestige(100.0); + Map<String, Object> entry = + new HashMap<>(Map.of("type", "production", "scale", "relative_prestige", "amount", 3.0)); + entry.put(key, Double.NaN); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("modifiers", List.of(entry, "trade_power(4)")); + List<FactionModifier> loaded = new ArrayList<>(); + FactionModifier.addFromConfig(yaml, "modifiers", loaded); + assertTrue( + loaded.stream() + .anyMatch( + modifier -> + modifier.getType() == FactionModifiers.TRADE_POWER + && modifier.getAmount() == 4)); + for (FactionModifier modifier : loaded) { + FactionModifier bound = new FactionModifier(partner, modifier); + for (double prestige : List.of(0.0, 100.0, 1000.0)) { + partner.setPrestige(prestige); + assertTrue( + Double.isFinite(bound.resolve(owner)), + key + " must not introduce NaN into live faction modifiers"); + } + } + } + } + + @Test + void relativePrestigeCopiesUseTheirActualPartnerAndKeepTheTemplateUnbound() { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + Faction owner = fixture.saved("owner", "Owner"); + Faction partner = fixture.saved("partner", "Partner"); + owner.setPrestige(100.0); + partner.setPrestige(250.0); + FactionModifier template = + FactionModifier.fromYamlEntry( + Map.of( + "type", + "trade_power", + "scale", + "relative_prestige", + "at_weaker", + "-4", + "at_equal", + "10", + "at_stronger", + "20")); + FactionModifier bound = new FactionModifier(partner, template); + assertSame(partner, bound.getFrom()); + assertNull(template.getFrom()); + assertEquals(ModifierScale.Kind.RELATIVE_PRESTIGE, bound.getScale()); + assertEquals(20.0, bound.resolve(owner)); + assertEquals(10.0, template.resolve(owner)); + assertEquals(10.0, bound.resolve(null)); + assertTrue( + ChatColor.stripColor(bound.getString(owner)).endsWith("(20%) (vs their prestige)")); + partner.setPrestige(40.0); + assertEquals(-4.0, bound.resolve(owner), 1e-9); + partner.setPrestige(0.0); + assertEquals(-4.0, bound.resolve(owner)); + assertEquals("TRADE_POWER{from=partner, amount=10.0}", bound.toString()); + assertEquals("TRADE_POWER{from=null, amount=10.0}", template.toString()); + FactionModifier flat = new FactionModifier(partner, FactionModifiers.PRODUCTION, 3.5); + assertSame(partner, flat.getFrom()); + assertEquals(3.5, flat.resolve(owner)); + } + } + + @Test + void malformedScaleNumbersUseTheConfiguredEqualFallbackAndDeJureKeepsItsFloor() { + FactionModifier modifier = + FactionModifier.fromYamlEntry( + Map.of( + "type", + "production", + "scale", + "unknown", + "amount", + 6, + "at_equal", + "invalid", + "at_weaker", + List.of(1), + "at_stronger", + false)); + assertEquals(ModifierScale.Kind.NONE, modifier.getScale()); + assertEquals(6.0, modifier.getAmount()); + assertEquals(ModifierScale.Kind.NONE, ModifierScale.kindFrom(" ")); + double oldRequirement = Cache.deJureRequirement; + try { + Cache.deJureRequirement = 60; + FactionModifier deJure = new FactionModifier(FactionModifiers.DE_JURE, -90); + assertEquals(-40.0, deJure.getAmount()); + assertEquals(20.0, Cache.deJureRequirement + deJure.getAmount()); + } finally { + Cache.deJureRequirement = oldRequirement; + } + for (FactionModifiers type : FactionModifiers.values()) + assertEquals( + List.of( + FactionModifiers.TRADE_POWER, + FactionModifiers.PRODUCTION, + FactionModifiers.INSTALLATION_ACCESS) + .contains(type), + type.affectsEconomy(), + type.name()); + } + + @ParameterizedTest + @EnumSource( + value = RankType.class, + names = {"WEALTH", "PRESTIGE", "MEMBERS"}) + void legacyFactionRankingsUseIndependentSortedSnapshots(RankType type) { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + Faction large = fundedFaction(fixture, "large", "Large", 300); + Faction small = fundedFaction(fixture, "small", "Small", 100); + Faction middle = fundedFaction(fixture, "middle", "Middle", 200); + large.getOrCreateMainGuild().addMember("LargeTwo"); + large.getOrCreateMainGuild().addMember("LargeThree"); + middle.getOrCreateMainGuild().addMember("MiddleTwo"); + small.setPrestige(10.0); + middle.setPrestige(20.0); + large.setPrestige(30.0); + FactionRanker ranker = new FactionRanker(); + assertEquals(List.of(small, middle, large), ranker.getRankedList(type)); + assertEquals(List.of(large, small, middle), FactionManager.factions); + assertEquals(1, ranker.getWealthRank(large)); + assertEquals(3, ranker.getWealthRank(small)); + assertEquals(2, ranker.getPrestigeRank(middle)); + assertEquals(List.of(large, small, middle), ranker.getRankedList(RankType.TRADE_POWER)); + FactionManager.factions.remove(middle); + assertEquals(0, ranker.getPrestigeRank(middle)); + assertEquals(0, ranker.getWealthRank(middle)); + assertNull(ranker.getVisibleRank(fixture.online.get("Middle"), middle, type)); + } + } + + @ParameterizedTest + @EnumSource( + value = RankType.class, + names = {"WEALTH", "MEMBERS", "INCOME", "TRADE_POWER"}) + void guildRankingsAndVisibleValuesUseRealGuildEconomics(RankType type) { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + Faction faction = fundedFaction(fixture, "home", "Leader", 0); + Guild small = fixture.guild(faction, "small", "Small"); + Guild large = fixture.guild(faction, "large", "Large"); + small.getBank().deposit(100.0); + large.getBank().deposit(300.0); + large.addMember("Additional"); + small.getTradeBreakdown().setTradePower(5); + large.getTradeBreakdown().setTradePower(25); + small.getTradeBreakdown().setIncome(50); + large.getTradeBreakdown().setIncome(250); + faction.getTaxHandler().setGuildTax(0); + Player viewer = fixture.online.get("Leader"); + FactionRanker ranker = new FactionRanker(); + List<Guild> raw = ranker.getRankedGuildList(type); + assertTrue(raw.indexOf(small) < raw.indexOf(large)); + double smallMetric = + switch (type) { + case WEALTH -> 100.0; + case MEMBERS -> 1.0; + case INCOME -> small.getLedger().getNetIncome(); + case TRADE_POWER -> 5.0; + default -> throw new AssertionError(type); + }; + assertEquals(smallMetric, ranker.visibleGuildValue(viewer, small, type)); + List<Guild> visible = ranker.getVisibleRankedGuildList(viewer, type); + assertTrue(visible.indexOf(large) < visible.indexOf(small)); + assertEquals(visible.indexOf(small) + 1, ranker.getVisibleGuildRank(viewer, small, type)); + assertEquals(1, ranker.getWealthRank(large)); + faction.getGuildHandler().removeGuild("small", false, false); + assertEquals(0, ranker.getWealthRank(small)); + assertNull(ranker.getVisibleGuildRank(viewer, small, type)); + assertEquals(FactionManager.getAllGuilds(), ranker.getRankedGuildList(RankType.PRESTIGE)); + } + } + + @Test + void unknownForeignValuesSortLastAndNeverReceiveAnExactRank() { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + Faction hiddenZulu = fundedFaction(fixture, "Zulu", "ZuluLeader", 9000); + Faction home = fundedFaction(fixture, "home", "Leader", 100); + Faction hiddenAlpha = fundedFaction(fixture, "Alpha", "AlphaLeader", 8000); + Faction open = fundedFaction(fixture, "open", "OpenLeader", 200); + guard(hiddenZulu, fixture.online.get("ZuluLeader")); + guard(hiddenAlpha, fixture.online.get("AlphaLeader")); + Player viewer = fixture.online.get("Leader"); + FactionRanker ranker = new FactionRanker(); + assertNull(ranker.visibleValue(viewer, hiddenAlpha, RankType.WEALTH)); + assertNull(ranker.getVisibleRank(viewer, hiddenAlpha, RankType.WEALTH)); + assertNull( + ranker.getVisibleGuildRank(viewer, hiddenAlpha.getOrCreateMainGuild(), RankType.MEMBERS)); + assertEquals( + List.of(open, home, hiddenAlpha, hiddenZulu), + ranker.getVisibleRankedList(viewer, RankType.WEALTH)); + assertEquals( + List.of( + open.getOrCreateMainGuild(), + home.getOrCreateMainGuild(), + hiddenAlpha.getOrCreateMainGuild(), + hiddenZulu.getOrCreateMainGuild()), + ranker.getVisibleRankedGuildList(viewer, RankType.WEALTH)); + hiddenAlpha.setPrestige(4321.0); + assertEquals(4321.0, ranker.visibleValue(viewer, hiddenAlpha, RankType.PRESTIGE)); + assertEquals(1.0, ranker.visibleValue(viewer, home, RankType.MEMBERS)); + assertEquals(2, ranker.getVisibleRank(viewer, home, RankType.WEALTH)); + when(viewer.hasPermission(EspionageConfig.bypassPermission())).thenReturn(true); + assertEquals(1, ranker.getVisibleRank(viewer, hiddenZulu, RankType.WEALTH)); + } + } + + @Test + void dailyIntelligenceRanksTheStoredEstimateRatherThanTheLiveSecretBalance() { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + Faction home = fundedFaction(fixture, "home", "Leader", 100); + Faction foreign = fundedFaction(fixture, "foreign", "ForeignLeader", 9000); + guard(home, fixture.online.get("Leader")); + guard(foreign, fixture.online.get("ForeignLeader")); + IntelligenceReport report = new IntelligenceReport(); + report.quality = "detailed"; + report.estimates.put("Wealth", new EspionageMath.Estimate(180, 220)); + report.estimates.put( + "Guild:" + foreign.getOrCreateMainGuild().getId() + ":Wealth", + new EspionageMath.Estimate(40, 60)); + home.getEspionage() + .report( + foreign.getId(), + foreign.getFoundedAt(), + LocalDate.now(ZoneOffset.UTC).toEpochDay(), + () -> report); + FactionRanker ranker = new FactionRanker(); + Player viewer = fixture.online.get("Leader"); + assertEquals(200.0, ranker.visibleValue(viewer, foreign, RankType.WEALTH)); + assertEquals( + 50.0, ranker.visibleGuildValue(viewer, foreign.getOrCreateMainGuild(), RankType.WEALTH)); + assertEquals(1, ranker.getVisibleRank(viewer, foreign, RankType.WEALTH)); + assertEquals( + 2, ranker.getVisibleGuildRank(viewer, foreign.getOrCreateMainGuild(), RankType.WEALTH)); + foreign.getBank().deposit(5000.0); + assertEquals(200.0, ranker.visibleValue(viewer, foreign, RankType.WEALTH)); + assertEquals( + 50.0, ranker.visibleGuildValue(viewer, foreign.getOrCreateMainGuild(), RankType.WEALTH)); + } + } + + @Test + void tiedVisibleRankingsUseCaseInsensitiveIdsAndDoNotReorderTheRegistry() { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + Faction zulu = fundedFaction(fixture, "zulu", "Zulu", 100); + Faction bravo = fundedFaction(fixture, "bravo", "Bravo", 100); + Faction alpha = fundedFaction(fixture, "Alpha", "Alpha", 100); + Player viewer = fixture.online.get("Zulu"); + FactionRanker ranker = new FactionRanker(); + assertEquals( + List.of(alpha, bravo, zulu), ranker.getVisibleRankedList(viewer, RankType.WEALTH)); + assertEquals( + List.of( + alpha.getOrCreateMainGuild(), + bravo.getOrCreateMainGuild(), + zulu.getOrCreateMainGuild()), + ranker.getVisibleRankedGuildList(viewer, RankType.WEALTH)); + assertEquals(List.of(zulu, bravo, alpha), FactionManager.factions); + } + } + + @Test + void bankRegistryFollowsExplicitChunkMovesWithoutChangingItsBalance() { + List<Bank> oldBanks = BankManager.banks; + BankManager.banks = new ArrayList<>(); + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + Faction faction = fundedFaction(fixture, "home", "Leader", 80); + Bank bank = faction.getBank(); + BankManager.banks.add(bank); + Chunk original = bank.getChunk(); + Chunk moved = mock(Chunk.class); + BankManager manager = new BankManager(); + assertTrue(manager.hasBank(original)); + assertFalse(manager.hasBank(moved)); + bank.setChunk(moved); + assertFalse(manager.hasBank(original)); + assertTrue(manager.hasBank(moved)); + assertEquals(80.0, bank.getWealth()); + Bank openingBalance = new Bank(faction.getOrCreateMainGuild(), 25.0, original); + Bank empty = new Bank(faction.getOrCreateMainGuild(), original); + assertEquals(25.0, openingBalance.getWealth()); + assertEquals(0.0, empty.getWealth()); + assertSame(original, openingBalance.getChunk()); + bank.withdraw(12.5); + assertEquals(67.5, bank.getWealth()); + assertEquals(67.5, faction.getWealth()); + bank.setWealth(50.0); + assertEquals(50.0, bank.getWealth()); + } finally { + BankManager.banks = oldBanks; + } + } + + @Test + void publicPlayerLedgerAccessUsesThePluginsOneSharedManager() throws Exception { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + PlayerEconomyManager manager = new PlayerEconomyManager(); + Field installedManager = SimpleFactions.class.getDeclaredField("playerEconomyManager"); + installedManager.setAccessible(true); + Object original = installedManager.get(fixture.ui.plugin); + try { + installedManager.set(fixture.ui.plugin, manager); + UUID player = UUID.randomUUID(); + PlayerEconomyManager.get().getLedger(player).add(PlayerCashflow.EARNINGS, 35.0); + assertSame(manager, PlayerEconomyManager.get()); + assertEquals( + 35.0, SimpleFactions.getPlayerEconomyManager().getLedger(player).getNetDaily()); + } finally { + installedManager.set(fixture.ui.plugin, original); + } + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void bankPlacementCannotTreatAnotherWorldAsTheCapital(boolean guildBank) throws Exception { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + fixture.provinceData.put(10, new Province(10, "PLAINS", 50)); + var data = fixture.data("home", "Leader"); + data.provinces.add(10); + data.capital = 10; + Faction faction = fixture.saved(data); + Guild guild = fixture.guild(faction, "merchants", "Merchant"); + guild.setCapital(10, false); + Path file = temporary.resolve("grid.gz"); + try (var zipped = new GZIPOutputStream(Files.newOutputStream(file))) { + zipped.write( + ByteBuffer.allocate(10) + .order(ByteOrder.LITTLE_ENDIAN) + .putInt(1) + .putInt(1) + .putShort((short) 10) + .array()); + } + when(fixture.ui.plugin.getProvinceGrid()).thenReturn(ProvinceGrid.load(file.toFile())); + Location capital = new Location(fixture.ui.world, 0, 64, 0); + World otherWorld = mock(World.class); + when(otherWorld.getName()).thenReturn("the_nether"); + Location unrelated = new Location(otherWorld, 0, 64, 0); + assertNull( + guildBank + ? BankPlacementValidator.failureReasonForGuild(guild, capital) + : BankPlacementValidator.failureReasonForFaction(faction, capital)); + assertNotNull( + guildBank + ? BankPlacementValidator.failureReasonForGuild(guild, unrelated) + : BankPlacementValidator.failureReasonForFaction(faction, unrelated)); + } + } + + @Test + void legacyZeroCapitalDoesNotImposeAnUnresolvableBankRestriction() { + try (FactionDomainFixture fixture = new FactionDomainFixture()) { + var data = fixture.data("legacy", "Leader"); + data.capital = 0; + Faction faction = fixture.saved(data); + Guild guild = fixture.guild(faction, "merchant", "Merchant"); + guild.setCapital(0, false); + assertNull(BankPlacementValidator.failureReasonForFaction(faction, 99)); + assertNull(BankPlacementValidator.failureReasonForGuild(guild, 99)); + assertNull(BankPlacementValidator.failureReasonForFaction(null, 99)); + assertNull(BankPlacementValidator.failureReasonForGuild(null, 99)); + } + } + + @Test + void playerLedgersResolveAccountsWithoutSharingAnonymousEntries() { + UUID alice = UUID.randomUUID(); + PlayerEconomyManager manager = new PlayerEconomyManager(); + try (var accounts = mockStatic(OfflineModifier.class)) { + accounts.when(() -> OfflineModifier.playerId("Alice")).thenReturn(alice); + manager.getLedger("Alice").add(PlayerCashflow.EARNINGS, 50); + manager.getLedger(alice).add(PlayerCashflow.CITIZEN_TAX, -10); + assertSame(manager.getLedger(alice), manager.getLedger("Alice")); + assertEquals(40.0, manager.getLedger(alice).getNetDaily()); + manager.getLedger((UUID) null).add(PlayerCashflow.EARNINGS, 999); + manager.getLedger("Unknown").add(PlayerCashflow.EARNINGS, 888); + assertEquals(0.0, manager.getLedger((UUID) null).getNetDaily()); + assertEquals(0.0, manager.getLedger("Unknown").getNetDaily()); + assertEquals(40.0, manager.getLedger("Alice").getNetDaily()); + var previous = manager.getLedger(alice); + manager.clearAllDaily(); + assertEquals(0.0, previous.getNetDaily()); + assertEquals(0.0, manager.getLedger(alice).getNetDaily()); + assertNotSame(previous, manager.getLedger(alice)); + } + } + + @Test + void denarAdapterConservesSeparateBankAndPouchBalances() { + UUID account = UUID.randomUUID(); + Map<Accounts, Double> balances = + new HashMap<>(Map.of(Accounts.BANK, 100.0, Accounts.POUCH, 40.0)); + try (var external = mockStatic(OfflineModifier.class)) { + external.when(() -> OfflineModifier.playerId("Alice")).thenReturn(account); + external + .when(() -> OfflineModifier.balance(eq(account), any(Accounts.class))) + .thenAnswer(call -> balances.get(call.getArgument(1))); + external + .when(() -> OfflineModifier.apply(eq(account), any(Accounts.class), anyDouble())) + .thenAnswer( + call -> { + Accounts type = call.getArgument(1); + double next = balances.get(type) + (double) call.getArgument(2); + if (next < 0) return false; + balances.put(type, next); + return true; + }); + var bank = DenarEconomyPlayerBank.INSTANCE; + assertEquals(account, bank.resolve("Alice")); + assertEquals(100.0, bank.getBankBalance(account)); + assertEquals(40.0, bank.getPouchBalance(account)); + assertTrue(bank.withdrawFromBank(account, 30)); + assertTrue(bank.depositToBank(account, 5)); + assertTrue(bank.withdrawFromPouch(account, 8)); + assertEquals(75.0, bank.getBankBalance(account)); + assertEquals(32.0, bank.getPouchBalance(account)); + assertFalse(bank.withdrawFromBank(account, 100)); + assertFalse(bank.withdrawFromBank(account, 0)); + assertFalse(bank.withdrawFromPouch(account, -1)); + assertFalse(bank.depositToBank(account, -10)); + assertEquals(Map.of(Accounts.BANK, 75.0, Accounts.POUCH, 32.0), balances); + } + } + + private Faction fundedFaction( + FactionDomainFixture fixture, String id, String leader, double balance) { + fixture.player(leader); + Faction faction = fixture.saved(id, leader); + faction.getBank().deposit(balance); + return faction; + } + + private void guard(Faction faction, Player leader) { + SpecialPositionAssignment assignment = new SpecialPositionAssignment(); + assignment.playerId = leader.getUniqueId(); + assignment.playerName = leader.getName(); + assignment.characterId = faction.getId() + "-character"; + assignment.automatic = true; + faction.getEspionage().assignFounder(SpecialPosition.SPYMASTER, assignment, 75); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/utils/LoreWriterCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/utils/LoreWriterCoverageTest.java new file mode 100644 index 00000000..cfdc2db1 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/utils/LoreWriterCoverageTest.java @@ -0,0 +1,168 @@ +package net.tfminecraft.simplefactions.utils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.tfminecraft.simplefactions.enums.*; +import net.tfminecraft.simplefactions.government.movement.Action; +import net.tfminecraft.simplefactions.government.movement.PoliticalAction; +import net.tfminecraft.simplefactions.government.proposal.*; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.laws.*; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +class LoreWriterCoverageTest { + FactionDomainFixture fixture; + MockedStatic<EconomicImpact> estimates; + Faction faction; + Guild guild; + Faction subject; + Player reader; + ItemMeta meta; + List<String> lore; + + @BeforeEach void setUp() { + new LoreWriter(); + fixture = new FactionDomainFixture(); + fixture.lawGroup("government", Map.of()); + fixture.lawGroup("customs", Map.of()); + faction = fixture.saved("realm", "Alice"); + guild = fixture.guild(faction, "merchants", "Bob"); + subject = fixture.saved("islands", "Cara"); + fixture.subject(faction, subject); + reader = fixture.player("Alice"); + meta = new ItemStack(Material.PAPER).getItemMeta(); + lore = new ArrayList<>(List.of("Existing description")); + estimates = mockStatic(EconomicImpact.class); + } + + @AfterEach void close() { estimates.close(); fixture.close(); } + + private Proposal proposal() { return new Proposal("Alice", faction.getGovernment()); } + private void render(Proposal proposal) { LoreWriter.applyProposalLore(proposal,lore,reader,faction,meta); } + private String text() { return ChatColor.stripColor(String.join("\n", lore)); } + + @ParameterizedTest @ValueSource(strings={"government","customs"}) + void lawDescriptionsCompareTheCurrentAndProposedLawAndRequestOnlyEconomicEstimates(String id) { + Law law = fixture.law(id,"proposed",Map.of()); + Proposal proposal=proposal(); proposal.setLawProposal(law); + render(proposal); + assertTrue(text().contains("Group: "+id),text()); + assertTrue(text().contains("Current "+id+" -> proposed"),text()); + if(id.equals("government")) { + LawGroup group=faction.getLawHandler().getGroup(id); + estimates.verify(() -> EconomicImpact.applyEconomicChange(lore,reader,faction,group,law,false,meta,false)); + } else estimates.verifyNoInteractions(); + } + + @ParameterizedTest @EnumSource(TaxTarget.class) + void everyTaxTargetShowsItsCurrentRateAndUsesTheCorrectPreview(TaxTarget target) { + boolean specific=target==TaxTarget.GUILD_ID || target==TaxTarget.VASSAL_ID || target==TaxTarget.TARIFF_ID; + String id=specific ? target==TaxTarget.GUILD_ID ? guild.getId() : subject.getId() : null; + double previous=faction.getTaxRate(target,id,false); + Proposal proposal=proposal(); proposal.setTaxProposal(new TaxLawChange(target,id,17.5)); + render(proposal); + String name=specific ? target==TaxTarget.GUILD_ID ? guild.getName() : subject.getName() : target.getDisplayName(); + assertTrue(text().contains("Target: "+name),text()); + assertTrue(text().contains("Change: "+previous+"% -> 17.5%"),text()); + assertEquals(specific,text().contains("Base Rate:")); + if(target==TaxTarget.TARIFFS || target==TaxTarget.TARIFF_ID) + estimates.verify(() -> EconomicImpact.applyTariffImpact(lore,reader,faction,id,17.5,false,meta,false)); + else estimates.verify(() -> EconomicImpact.applyTaxImpact(lore,reader,faction,target,id,17.5,false,meta,false)); + } + + @ParameterizedTest @EnumSource(value=TaxTarget.class,names={"GUILD_ID","VASSAL_ID","TARIFF_ID"}) + void aSpecificRateIsShownAlongsideTheGeneralRate(TaxTarget target) { + String id=target==TaxTarget.GUILD_ID ? guild.getId() : subject.getId(); + faction.getTaxHandler().setTaxRate(target,id,7.5); + Proposal proposal=proposal(); proposal.setTaxProposal(new TaxLawChange(target,id,17.5)); + render(proposal); + assertTrue(text().contains("Change: 7.5% -> 17.5%"),text()); + assertTrue(text().contains("Base Rate: "+faction.getTaxRate(target,null,false)),text()); + } + + @ParameterizedTest @EnumSource(value=TaxTarget.class,names={"GUILD_ID","VASSAL_ID","TARIFF_ID"}) + void deletedTaxTargetsLeaveReadableProposalLoreWithoutCalculatingAnInvalidPreview(TaxTarget target) { + String id=target==TaxTarget.GUILD_ID ? guild.getId() : subject.getId(); + Proposal proposal=proposal(); proposal.setTaxProposal(new TaxLawChange(target,id,17.5)); + if(target==TaxTarget.GUILD_ID) faction.getGuildHandler().removeGuild(guild.getId()); + else FactionManager.factions.remove(subject); + assertDoesNotThrow(() -> render(proposal)); + assertTrue(text().startsWith("Existing description\n")); + assertTrue(text().contains(id),text()); + assertTrue(text().contains("no longer available"),text()); + estimates.verifyNoInteractions(); + } + + @ParameterizedTest @EnumSource(FeeKind.class) + void vehicleFeeDescriptionsUseTheSharedUnitAwareFormatter(FeeKind kind) { + FeeChange fee=new FeeChange(kind,"cart",1.5); + Proposal proposal=proposal();proposal.setFeeProposal(fee);render(proposal); + assertEquals(FeeProposalText.lines(faction,fee),lore.subList(1,lore.size())); + assertTrue(text().contains("Vehicle: cart")); + estimates.verifyNoInteractions(); + } + + @ParameterizedTest @EnumSource(Action.class) + void politicalDescriptionsExplainTheRequestedAction(Action action) { + Proposal proposal=proposal();proposal.setPoliticalActionProposal(new PoliticalAction(action));render(proposal); + assertTrue(text().contains("Action: "+ChatColor.stripColor(net.tfminecraft.tlibs.objects.api.subapi.StringFormatter.formatHex(action.getDisplay()))),text()); + String detail=switch(action) { + case CHANGE_LEADER -> "No target"; + case WHITE_PEACE -> "white peace offer"; + case SURRENDER -> "Surrender a chosen war"; + case DISSOLVE -> "Dissolves the faction"; + case INDEPENDENCE -> "granted independence"; + case NATIONHOOD -> "elevated to nationhood"; + case SNAP_ELECTIONS -> "voting lasts for a week"; + default -> "Action:"; + }; + assertTrue(text().contains(detail),text());estimates.verifyNoInteractions(); + } + + @Test void leadershipProposalNamesTheSelectedPlayerAndAnEmptyProposalAddsNothing() { + Proposal proposal=proposal();render(proposal);assertEquals(List.of("Existing description"),lore); + proposal.setPoliticalActionProposal(new PoliticalAction(Action.CHANGE_LEADER));proposal.setTarget("Bob");render(proposal); + assertTrue(text().contains("Target: Bob"));assertFalse(text().contains("No target")); + } + + @ParameterizedTest @EnumSource(Scope.class) + void effectsDescribeRulesRangesRegimentsAndRegionalModifiersWithinTheirScope(Scope scope) { + var regiment = fixture.regiment("guards",false,2,5); + YamlConfiguration config=new YamlConfiguration(); + config.set("rules",List.of("CAN_FAVOUR true","CAN_REPRESS false")); + config.set("brackets.guild_tax","2-25"); + config.set("regiments",List.of("guards 3")); + config.set("modifiers",List.of("PRESTIGE_BONUS(12.5)")); + config.set("foreign_territory",List.of("INSTALLATION_ACCESS(0.25)")); + lore.clear(); LoreWriter.writeEffect(scope,new LawEffect(scope,config),lore); + assertEquals(scope!=Scope.FACTION,text().contains(scope.getDisplay()+":")); + assertTrue(text().contains("✔")); assertTrue(text().contains("✖")); + assertTrue(text().contains("Guild Tax"));assertTrue(text().contains("[2.0-25.0]"),text()); + assertTrue(text().contains("Free "+regiment.getName()+" Regiments: 3"),text()); + assertTrue(text().contains("Prestige Bonus")&&text().contains("12.5%"),text()); + assertTrue(text().contains("Foreign Territory:")&&text().contains("+25%"),text()); + } + + @Test void anEmptyFactionEffectAddsNoRowsAndAnEmptySubjectEffectKeepsItsHeader() { + lore.clear(); LawEffect empty=new LawEffect(Scope.FACTION,new YamlConfiguration()); + LoreWriter.writeEffect(Scope.FACTION,empty,lore);assertTrue(lore.isEmpty()); + LoreWriter.writeEffect(Scope.VASSALS,empty,lore);assertEquals(" All Vassals:",text()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/utils/SettlementPaymentBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/utils/SettlementPaymentBoundaryCoverageTest.java new file mode 100644 index 00000000..f60f5638 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/utils/SettlementPaymentBoundaryCoverageTest.java @@ -0,0 +1,255 @@ +package net.tfminecraft.simplefactions.utils; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.database.LoanData; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.loans.Loan; +import net.tfminecraft.simplefactions.guild.loans.LoanStatus; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.player.PlayerEconomyManager; +import net.tfminecraft.simplefactions.player.income.PlayerCashflow; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank.PlayerBank; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class SettlementPaymentBoundaryCoverageTest { + private FactionDomainFixture fixture; + private Faction faction; + private Guild guild; + private boolean previousEligibility; + private final UUID player = + UUID.nameUUIDFromBytes("Bob".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + faction = fixture.saved("home", "Alice"); + guild = fixture.guild(faction, "traders", "Bob"); + faction.getOrCreateMainGuild(); + faction.getTaxHandler().setDividendTax(0); + guild.getBank().setWealth(100.0); + previousEligibility = Cache.dividendRequirePreviousTickMembership; + Cache.dividendRequirePreviousTickMembership = false; + } + + @AfterEach + void close() { + Cache.dividendRequirePreviousTickMembership = previousEligibility; + fixture.close(); + } + + private DailyGuildTransfers dividend(double amount) { + DailyGuildTransfers transfers = new DailyGuildTransfers(); + transfers.setPendingDividendPool(guild, amount); + return transfers; + } + + private DailyGuildTransfers wage(double amount) { + DailyGuildTransfers transfers = new DailyGuildTransfers(); + transfers.addPlayerPayout(guild, player, amount); + return transfers; + } + + @Test + void unavailablePlayerBankCannotConsumeDividendOrReportIncome() { + PlayerEconomyManager economy = new PlayerEconomyManager(); + PostSettlementPayouts.apply(dividend(25), null, economy, name -> player); + assertEquals(100, guild.getBank().getWealth()); + assertEquals(0, economy.getLedger(player).getNetDaily()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void rejectedExternalDepositCannotConsumeGuildFundsOrReportIncome(boolean dividends) { + RecordingBank bank = new RecordingBank(false); + PlayerEconomyManager economy = new PlayerEconomyManager(); + PostSettlementPayouts.apply(dividends ? dividend(25) : wage(25), bank, economy, name -> player); + assertEquals(1, bank.attempts); + assertEquals(0, bank.total()); + assertEquals(100, guild.getBank().getWealth()); + assertEquals(0, economy.getLedger(player).getNetDaily()); + } + + @Test + void successfulDividendsAndWagesBalanceAllAccountsAndLedger() { + DailyGuildTransfers transfers = dividend(30); + transfers.addPlayerPayout(guild, player, 20); + RecordingBank bank = new RecordingBank(true); + PlayerEconomyManager economy = new PlayerEconomyManager(); + PostSettlementPayouts.apply(transfers, bank, economy, name -> player); + assertEquals(50, guild.getBank().getWealth()); + assertEquals(50, bank.total()); + assertEquals(30, economy.getLedger(player).getAmount(PlayerCashflow.DIVIDEND_PAYOUT)); + assertEquals(20, economy.getLedger(player).getAmount(PlayerCashflow.WAGES)); + } + + @Test + void noBudgetAndNoKnownRecipientsPreserveMoney() { + RecordingBank bank = new RecordingBank(true); + PostSettlementPayouts.apply(null, bank, null, null); + PostSettlementPayouts.apply(new DailyGuildTransfers(), bank, null, null); + PostSettlementPayouts.apply(dividend(25), bank, null, name -> null); + assertEquals(100, guild.getBank().getWealth()); + guild.getBank().setWealth(0.0); + PostSettlementPayouts.apply(wage(25), bank, null, name -> player); + assertEquals(0, bank.attempts); + assertEquals(0, guild.getBank().getWealth()); + } + + @Test + void publicBufferRejectsEmptyTransfersAndClearDropsAllQueues() { + DailyGuildTransfers transfers = new DailyGuildTransfers(); + transfers.addPlayerPayout(null, player, 1); + transfers.addPlayerPayout(guild, null, 1); + transfers.addPlayerPayout(guild, player, 0); + transfers.setPendingDividendPool(null, 1); + transfers.setPendingDividendPool(guild, 0); + assertTrue(transfers.getPlayerPayouts().isEmpty()); + assertTrue(transfers.getPendingDividendPools().isEmpty()); + transfers.add(guild, faction.getOrCreateMainGuild(), 1); + transfers.addExternalDelta(guild, 2); + transfers.addPlayerPayout(guild, player, 3); + transfers.setPendingDividendPool(guild, 4); + transfers.clear(); + assertTrue(transfers.getTransfers().isEmpty()); + assertTrue(transfers.getExternalDeltas().isEmpty()); + assertTrue(transfers.getPlayerPayouts().isEmpty()); + assertTrue(transfers.getPendingDividendPools().isEmpty()); + } + + @Test + void corruptPublicBufferedRowsDoNotBlockValidWages() { + DailyGuildTransfers transfers = wage(10); + Map<UUID, Double> payouts = transfers.getPlayerPayouts().get(guild); + payouts.put(null, 5.0); + payouts.put(UUID.randomUUID(), null); + payouts.put(UUID.randomUUID(), -10.0); + transfers.getPendingDividendPools().put(null, 20.0); + RecordingBank bank = new RecordingBank(true); + PostSettlementPayouts.apply(transfers, bank, null, null); + assertEquals(10, bank.total()); + assertEquals(90, guild.getBank().getWealth()); + } + + @Test + void roundedSubCentWagesKeepMoneyInGuild() { + RecordingBank bank = new RecordingBank(true); + PostSettlementPayouts.apply(wage(0.001), bank, null, null); + assertEquals(0, bank.attempts); + assertEquals(100, guild.getBank().getWealth()); + } + + @Test + void restoredLoanRejectsUnknownAccountsAndOverdueCreditIsChargedOncePerDay() { + LoanData invalid = + new LoanData( + new Loan( + 100, + faction.getOrCreateMainGuild(), + guild, + System.currentTimeMillis(), + 1, + 0, + 0, + false)); + invalid.issuer = "missing"; + invalid.borrower = "unknown"; + assertThrows(IllegalArgumentException.class, () -> new Loan(invalid)); + Guild lender = faction.getOrCreateMainGuild(); + Loan overdue = + new Loan(100, lender, guild, System.currentTimeMillis() - 5 * 86400000L, 1, 0, 0, false); + int before = guild.getLoanHandler().getCreditScore(); + overdue.tickDay(); + assertTrue(guild.getLoanHandler().getCreditScore() < before); + assertEquals(LoanStatus.ACTIVE, overdue.getStatus()); + } + + @Test + void paymentOfAccruedInterestReducesDebtWithoutDoubleChargingTodaysInterest() { + Guild lender = faction.getOrCreateMainGuild(); + Loan loan = new Loan(100, lender, guild, System.currentTimeMillis(), 10, 7, 0, false); + loan.tickDay(); + assertEquals(1, loan.getUnpaidInterest(), 0.00001); + loan.setAutoPay(true); + double today = loan.getDailyInterestChange(); + loan.setTempInterestPayment(today + 1); + loan.tickDay(); + assertEquals(0, loan.getUnpaidInterest(), 0.00001); + assertEquals(1, loan.getPaidInterest(), 0.00001); + assertEquals(100, loan.getTotalOwed(), 0.00001); + } + + @Test + void dividendTaxAndMemberPaymentConserveTheBudget() { + faction.getTaxHandler().setDividendTax(20); + Guild capital = faction.getOrCreateMainGuild(); + double before = capital.getBank().getWealth(); + RecordingBank bank = new RecordingBank(true); + PlayerEconomyManager economy = new PlayerEconomyManager(); + PostSettlementPayouts.apply(dividend(40), bank, economy, name -> player); + double tax = capital.getBank().getWealth() - before; + assertTrue(tax > 0); + assertEquals(40, tax + bank.total(), 0.00001); + assertEquals(60, guild.getBank().getWealth(), 0.00001); + assertEquals(bank.total(), economy.getLedger(player).getAmount(PlayerCashflow.DIVIDEND_PAYOUT)); + } + + @Test + void invalidMemberNamesAndEmptyQueuedPayoutsCannotMoveMoney() { + RecordingBank bank = new RecordingBank(true); + guild.getMembers().add(""); + guild.getMembers().add(null); + PostSettlementPayouts.apply( + dividend(20), + bank, + null, + name -> { + assertNotNull(name); + assertFalse(name.isBlank()); + return null; + }); + DailyGuildTransfers empty = new DailyGuildTransfers(); + empty.getPlayerPayouts().put(guild, new HashMap<>()); + PostSettlementPayouts.apply(empty, bank, null, null); + assertEquals(100, guild.getBank().getWealth()); + assertEquals(0, bank.attempts); + } + + private static final class RecordingBank implements PlayerBank { + final Map<UUID, Double> paid = new HashMap<>(); + final boolean accepting; + int attempts; + + RecordingBank(boolean accepting) { + this.accepting = accepting; + } + + double total() { + return paid.values().stream().mapToDouble(Double::doubleValue).sum(); + } + + public double getBankBalance(UUID id) { + return paid.getOrDefault(id, 0.0); + } + + public boolean withdrawFromBank(UUID id, double amount) { + return false; + } + + public boolean depositToBank(UUID id, double amount) { + attempts++; + if (!accepting) return false; + paid.merge(id, amount, Double::sum); + return true; + } + + public UUID resolve(String name) { + return null; + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/utils/SharedDomainUtilitiesCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/utils/SharedDomainUtilitiesCoverageTest.java new file mode 100644 index 00000000..184a7550 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/utils/SharedDomainUtilitiesCoverageTest.java @@ -0,0 +1,100 @@ +package net.tfminecraft.simplefactions.utils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Map; +import net.tfminecraft.denareconomy.accounts.OfflineModifier; +import net.tfminecraft.denareconomy.enums.Accounts; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.bukkit.ChatColor; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +class SharedDomainUtilitiesCoverageTest { + FactionDomainFixture fixture; + @BeforeEach void setUp() { fixture=new FactionDomainFixture(); new Wealth();new Represents();new RandomRGB(); } + @AfterEach void close() { fixture.close(); } + + @Test void personalWealthIncludesBothAccountsAndTheApplicableShareOfGuildAssets() { + Faction realm=fixture.saved("realm","Alice");realm.addMember("Citizen"); + Guild guild=fixture.guild(realm,"merchants","Bob");guild.addMember("Dana"); + realm.getBank().deposit(100.0);guild.getBank().deposit(60.0); + try(MockedStatic<OfflineModifier> accounts=mockStatic(OfflineModifier.class)) { + accounts.when(() -> OfflineModifier.balance(anyString(),eq(Accounts.POUCH))).thenReturn(3.0); + accounts.when(() -> OfflineModifier.balance(anyString(),eq(Accounts.BANK))).thenReturn(7.0); + assertEquals(110,Wealth.wealth("Alice")); + assertEquals(10,Wealth.wealth("Citizen")); + assertEquals(40,Wealth.wealth("Bob")); + assertEquals(40,Wealth.wealth("Dana")); + assertEquals(10,Wealth.wealth("Visitor")); + } + } + + @Test void wealthRankingIncludesSubjectsOnlyWhenRequestedAndSortsByTotalAssets() { + Faction realm=fixture.saved("realm","Alice");realm.addMember("Citizen"); + Guild guild=fixture.guild(realm,"merchants","Bob");guild.addMember("Dana"); + Faction subject=fixture.saved("subject","Cara");fixture.subject(realm,subject); + realm.getBank().deposit(100.0);guild.getBank().deposit(60.0);subject.getBank().deposit(200.0); + Map<String,Double> pouch=Map.of("Alice",1.0,"Bob",10.0,"Dana",5.0,"Citizen",0.0,"Cara",2.0); + try(MockedStatic<OfflineModifier> accounts=mockStatic(OfflineModifier.class)) { + accounts.when(() -> OfflineModifier.balance(anyString(),eq(Accounts.POUCH))) + .thenAnswer(call -> pouch.getOrDefault(call.getArgument(0),0.0)); + assertEquals(List.of("Alice","Bob","Dana","Citizen"),Wealth.topWealth(realm,false)); + assertEquals(List.of("Cara","Alice","Bob","Dana","Citizen"),Wealth.topWealth(realm,true)); + } + } + + @Test void representationDistinguishesCapitalGuildSubjectsAndUnrelatedPlayers() { + Faction realm=fixture.saved("realm","Alice"); + Guild merchants=fixture.guild(realm,"merchants","Bob"); + Faction subject=fixture.saved("subject","Cara");fixture.subject(realm,subject); + Faction unrelated=fixture.saved("other","Dan"); + assertTrue(ChatColor.stripColor(Represents.represents(realm,"Alice")).contains("Capital")); + assertTrue(ChatColor.stripColor(Represents.represents(realm,"Bob")).contains(merchants.getType().getName())); + assertTrue(ChatColor.stripColor(Represents.represents(realm,"Cara")).contains("Vassal")); + assertTrue(ChatColor.stripColor(Represents.represents(unrelated,"Cara")).contains("Capital")); + assertEquals("",Represents.represents(realm,"Visitor")); + } + + @Test void availabilityReservesEveryFactionAndGuildColor() { + Faction realm=fixture.saved("realm","Alice");realm.setRGB("21,42,63"); + Guild guild=fixture.guild(realm,"merchants","Bob");guild.setRGB("84,105,126"); + assertFalse(RandomRGB.isFree("21,42,63")); + assertFalse(RandomRGB.isFree("84,105,126")); + assertTrue(RandomRGB.isFree("0,0,0")); + } + + @Test void randomFactionColorsRetryCollisionsAndRemainValidRgbValues() { + Faction occupied=mock(Faction.class); + try(MockedStatic<FactionManager> lookup=mockStatic(FactionManager.class)) { + lookup.when(() -> FactionManager.getByRGB(anyString())).thenReturn(occupied,null); + assertColor(RandomRGB.random()); + lookup.verify(() -> FactionManager.getByRGB(anyString()),times(2)); + } + } + + @ParameterizedTest @ValueSource(strings={"0,0,0","255,255,255","128,128,128","255,0,0","0,255,0","0,0,255","250,180,220","20,10,30"}) + void relatedColorsStayWithinRgbBoundsAcrossDarkLightGrayAndChromaticInputs(String original) { + for(int sample=0;sample<128;sample++) { + String actual=RandomRGB.similarButDistinct(original);assertColor(actual); + int[] before=components(original),after=components(actual); + int beforeLight=java.util.Arrays.stream(before).min().orElseThrow()+java.util.Arrays.stream(before).max().orElseThrow(); + int afterLight=java.util.Arrays.stream(after).min().orElseThrow()+java.util.Arrays.stream(after).max().orElseThrow(); + assertTrue(Math.abs(beforeLight-afterLight)<=28,"Related color lightness drift: "+original+" -> "+actual); + } + } + + private static int[] components(String value) { return java.util.Arrays.stream(value.split(",")).mapToInt(Integer::parseInt).toArray(); } + private static void assertColor(String value) { + int[] rgb=components(value);assertEquals(3,rgb.length); + for(int component:rgb) assertTrue(component>=0&&component<=255,value); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/template/BattleTemplateLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/template/BattleTemplateLifecycleCoverageTest.java new file mode 100644 index 00000000..6a05eccc --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/template/BattleTemplateLifecycleCoverageTest.java @@ -0,0 +1,353 @@ +package net.tfminecraft.simplefactions.war.battle.template; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.google.gson.Gson; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.loaders.BattleTemplateLoader; +import net.tfminecraft.simplefactions.war.battle.engine.core.BattleFactory; +import net.tfminecraft.simplefactions.war.battle.enums.BattleType; +import net.tfminecraft.simplefactions.war.battle.enums.DefenderRespawnMode; +import net.tfminecraft.simplefactions.war.battle.enums.LifeType; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +class BattleTemplateLifecycleCoverageTest { + @TempDir Path directory; + private Map<String, BattleTemplate> previous; + private String previousWorld; + private MockedStatic<Bukkit> bukkit; + private Logger logger; + + @BeforeEach + void setUp() { + previous = BattleTemplateLoader.getAll(); + previousWorld = Cache.worldName; + BattleTemplateLoader.resetForTests(); + Cache.worldName = "template_world"; + logger = mock(Logger.class); + Server server = mock(Server.class); + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getLogger).thenReturn(logger); + } + + @AfterEach + void restore() { + bukkit.close(); + BattleTemplateLoader.resetForTests(); + previous.values().forEach(BattleTemplateLoader::putForTests); + Cache.worldName = previousWorld; + } + + @ParameterizedTest + @ValueSource(strings = {"missing", "yaml", "entry"}) + void failedReloadKeepsEveryPreviouslyUsableTemplate(String failure) throws Exception { + var initial = directory.resolve("initial.yml"); + Files.writeString(initial, "field_default:\n type: field\n lives: 17\n"); + BattleTemplateLoader loader = new BattleTemplateLoader(); + loader.load(initial.toFile()); + BattleTemplate original = BattleTemplateLoader.getByName("field_default"); + assertNotNull(original); + var replacement = directory.resolve("replacement.yml"); + if (failure.equals("yaml")) { + Files.writeString(replacement, "broken: [unterminated\n"); + } else if (failure.equals("entry")) { + Files.writeString( + replacement, "replacement:\n type: raid\nbroken:\n type: unknown_battle_kind\n"); + } + loadAndAssertFailure(loader, replacement); + assertSame(original, BattleTemplateLoader.getByName("field_default")); + assertEquals(Map.of("field_default", original), BattleTemplateLoader.getAll()); + assertEquals(17, original.getConfig().getLives()); + verify(logger, atLeastOnce()).warning(contains("Failed to load")); + } + + @ParameterizedTest + @ValueSource(strings = {"north", ".NaN", ".inf", "-.inf"}) + void malformedCoordinatesCannotBecomeAUsableSpawn(String x) throws Exception { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.loadFromString("world: template_world\nx: " + x + "\ny: 64\nz: 5\n"); + assertNull(BattleLocation.fromSection(yaml)); + } + + @Test + void successfulReloadReplacesTheRegistryAndAnEmptyFileClearsIt() throws Exception { + Path file = directory.resolve("templates.yml"); + Files.writeString(file, "Field_Default:\n type: field\n lives: 17\n"); + BattleTemplateLoader loader = new BattleTemplateLoader(); + loader.load(file.toFile()); + BattleTemplate first = BattleTemplateLoader.getByName("Field_Default"); + assertSame(first, BattleTemplateLoader.getByName("field_default")); + assertNull(BattleTemplateLoader.getByName(null)); + assertNull(BattleTemplateLoader.getByName("missing")); + loader.load(null); + assertSame(first, BattleTemplateLoader.getByName("Field_Default")); + var snapshot = BattleTemplateLoader.getAll(); + assertThrows(UnsupportedOperationException.class, snapshot::clear); + Files.writeString(file, "replacement:\n type: raid\n"); + loader.load(file.toFile()); + assertNull(BattleTemplateLoader.getByName("Field_Default")); + assertEquals(BattleType.RAID, BattleTemplateLoader.getByName("replacement").getType()); + assertSame(first, snapshot.get("Field_Default")); + Files.writeString(file, "# intentionally no templates\n"); + loader.load(file.toFile()); + assertTrue(BattleTemplateLoader.getAll().isEmpty()); + verify(logger).info(contains("Loaded 0")); + } + + @Test + void aHeadlessParseFailureRetainsThePreviouslyLoadedDefinitions() throws Exception { + bukkit.when(Bukkit::getServer).thenReturn(null); + Path file = directory.resolve("headless.yml"); + Files.writeString(file, "ready:\n type: siege\n"); + BattleTemplateLoader loader = new BattleTemplateLoader(); + loader.load(file.toFile()); + BattleTemplate original = BattleTemplateLoader.getByName("ready"); + Files.writeString(file, "invalid: [broken\n"); + loadAndAssertFailure(loader, file); + assertSame(original, BattleTemplateLoader.getByName("ready")); + Files.writeString(file, "invalid: scalar\n"); + loadAndAssertFailure(loader, file); + assertSame(original, BattleTemplateLoader.getByName("ready")); + verifyNoInteractions(logger); + } + + @Test + void locationsResolveCurrentWorldFallbackAndPreserveCoordinatesAndRotation() throws Exception { + World world = world("template_world"); + var yaml = yaml("world: ' '\nx: 12.5\ny: 64\nz: -8\nyaw: 90.25\npitch: -12.5\n"); + BattleLocation parsed = BattleLocation.fromSection(yaml); + Location expected = new Location(world, 12.5, 64, -8, 90.25f, -12.5f); + assertEquals(expected, parsed.toBukkitLocation()); + assertEquals("template_world", parsed.getWorld()); + assertEquals(expected, BattleLocation.fromBukkitLocation(expected).toBukkitLocation()); + assertEquals( + new Location(world, 1, 2, 3), + BattleLocation.fromMap(Map.of("x", 1, "y", 2L, "z", 3.0)).toBukkitLocation()); + var explicit = + BattleLocation.fromMap( + Map.of("world", "template_world", "x", 4, "y", 5, "z", 6, "yaw", 45, "pitch", -30)); + assertEquals(new Location(world, 4, 5, 6, 45, -30), explicit.toBukkitLocation()); + var unavailable = new BattleLocation("unloaded_world", 2, 3, 4, 0, 0); + assertNull(unavailable.toBukkitLocation()); + assertEquals("unloaded_world", unavailable.getWorld()); + } + + @Test + void missingCoordinateShapesDoNotProduceAnOriginSpawn() throws Exception { + assertNull(BattleLocation.fromSection(null)); + assertNull(BattleLocation.fromMap(null)); + assertNull(BattleLocation.fromBukkitLocation(null)); + assertNull(BattleLocation.fromBukkitLocation(new Location(null, 1, 2, 3))); + assertNull(BattleLocation.fromSection(yaml("x: 2\ny: 5\n"))); + assertNull(BattleLocation.fromMap(Map.of("x", 1, "y", 2))); + assertNull(BattleLocation.fromMap(Map.of("x", "east", "y", 2, "z", 3))); + assertNull(TemplateSideConfig.fromSection(null)); + assertNull(CapturePointDefinition.fromSection(null)); + assertNull(CapturePointDefinition.fromMap(null)); + assertNull(CapturePointDefinition.fromMap(Map.of("id", 123, "x", 1, "y", 2, "z", 3))); + assertNull(CapturePointDefinition.fromMap(Map.of("id", "missing_location"))); + assertNull(CapturePointDefinition.fromSection(yaml("id: missing_location\n"))); + assertNull(CapturePointDefinition.fromSection(yaml("x: 1\ny: 2\nz: 3\n"))); + } + + @ParameterizedTest + @ValueSource(strings = {"x", "y", "z", "yaw", "pitch"}) + void nonfiniteMapValuesAreRejectedForEveryCoordinateAndRotation(String field) { + var values = new java.util.HashMap<String, Object>(); + values.putAll(Map.of("x", 1, "y", 2, "z", 3, "yaw", 4, "pitch", 5)); + for (double invalid : + new double[] {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) { + values.put(field, invalid); + assertNull(BattleLocation.fromMap(values), field + " must be finite"); + } + } + + @Test + void editableLocationAndPointDtosSurviveJsonWithoutLosingGeometry() { + World world = world("edited_world"); + BattleLocation edited = new BattleLocation(); + edited.setWorld("edited_world"); + edited.setX(10.25); + edited.setY(80); + edited.setZ(-15.5); + edited.setYaw(37.5f); + edited.setPitch(-12.25f); + CapturePointDefinition point = new CapturePointDefinition(); + point.setId("tower"); + point.setLocation(edited); + Gson gson = new Gson(); + CapturePointDefinition restored = + gson.fromJson(gson.toJson(point), CapturePointDefinition.class); + assertEquals("tower", restored.getId()); + assertEquals( + new Location(world, 10.25, 80, -15.5, 37.5f, -12.25f), + restored.getLocation().toBukkitLocation()); + assertEquals(10.25, restored.getLocation().getX()); + assertEquals(80, restored.getLocation().getY()); + assertEquals(-15.5, restored.getLocation().getZ()); + assertEquals(37.5f, restored.getLocation().getYaw()); + assertEquals(-12.25f, restored.getLocation().getPitch()); + TemplateSideConfig side = new TemplateSideConfig(edited, edited); + side.setSpawn(new BattleLocation("edited_world", 100, 64, 1, 0, 0)); + side.setJail(new BattleLocation("edited_world", 200, 32, 2, 180, 10)); + TemplateSideConfig sideRestored = gson.fromJson(gson.toJson(side), TemplateSideConfig.class); + assertEquals(new Location(world, 100, 64, 1), sideRestored.getSpawn().toBukkitLocation()); + assertEquals( + new Location(world, 200, 32, 2, 180, 10), sideRestored.getJail().toBukkitLocation()); + edited.setWorld(null); + World fallback = world("template_world"); + assertSame(fallback, edited.toBukkitLocation().getWorld()); + assertEquals("edited_world", restored.getLocation().getWorld()); + } + + @Test + void currentAndLegacySideAndCaptureLayoutsResolveTheSameRealLocations() throws Exception { + World world = world("template_world"); + TemplateSideConfig current = + TemplateSideConfig.fromSection( + yaml("spawn: {x: 1, y: 64, z: 2}\njail: {x: 3, y: 32, z: 4}\n")); + TemplateSideConfig legacy = + TemplateSideConfig.fromSection(yaml("spawn: true\nx: 1\ny: 64\nz: 2\n")); + assertEquals(new Location(world, 1, 64, 2), current.getSpawn().toBukkitLocation()); + assertEquals(current.getSpawn().toBukkitLocation(), legacy.getSpawn().toBukkitLocation()); + assertEquals(new Location(world, 3, 32, 4), current.getJail().toBukkitLocation()); + assertNull(legacy.getJail()); + CapturePointDefinition nested = + CapturePointDefinition.fromMap( + Map.of("id", "bridge", "location", Map.of("x", 8, "y", 65, "z", 9))); + CapturePointDefinition flat = + CapturePointDefinition.fromMap(Map.of("id", "bridge", "x", 8, "y", 65, "z", 9)); + CapturePointDefinition section = + CapturePointDefinition.fromSection(yaml("id: bridge\nx: 8\ny: 65\nz: 9\n")); + assertEquals("bridge", nested.getId()); + assertEquals(new Location(world, 8, 65, 9), nested.getLocation().toBukkitLocation()); + assertEquals(nested.getLocation().toBukkitLocation(), flat.getLocation().toBukkitLocation()); + assertEquals(nested.getLocation().toBukkitLocation(), section.getLocation().toBukkitLocation()); + } + + @Test + void completeYamlConfigurationAppliesSettingsAndRetainsDeclaredGeometry() throws Exception { + World world = world("template_world"); + Path file = directory.resolve("complete.yml"); + Files.writeString( + file, + """ + configured: + type: raid + friendly_fire: false + keep_inventory: false + loot_enabled: false + life_type: COLLECTIVE + lives: 43 + attacker: + spawn: {x: 1, y: 64, z: 2} + jail: {x: 3, y: 64, z: 4} + defender: + spawn: {x: 5, y: 64, z: 6} + capture_points: + - id: outer + location: {x: 8, y: 64, z: 9} + - id: inner + x: 10 + y: 64 + z: 11 + - id: missing_position + - unusable_scalar + contest_area: + min: {x: 0, y: 60, z: 0} + max: {x: 20, y: 70, z: 20} + contest_duration_seconds: 75 + naval_variant: true + naval_spawn: + spawn: {x: 30, y: 63, z: 40} + defender_respawn_mode: lives + defender_lives: 11 + capture_points_enabled: true + campaign_raid: true + raid_target: + id: harbour + location: {x: 15, y: 64, z: 15} + """); + new BattleTemplateLoader().load(file.toFile()); + BattleModeTemplate mode = BattleTemplateService.getInstance().getModeConfig("configured"); + assertEquals( + List.of("outer", "inner"), + mode.getCapturePoints().stream().map(CapturePointDefinition::getId).toList()); + assertEquals(new Location(world, 1, 64, 2), mode.getAttacker().getSpawn().toBukkitLocation()); + assertEquals(new Location(world, 5, 64, 6), mode.getDefender().getSpawn().toBukkitLocation()); + assertTrue(mode.getContestArea().contains(new Location(world, 10, 64, 10))); + assertFalse(mode.getContestArea().contains(new Location(world, 21, 64, 10))); + assertEquals(75, mode.getContestDurationSeconds()); + assertEquals( + new Location(world, 30, 63, 40), mode.getNavalSpawn().getSpawn().toBukkitLocation()); + assertEquals("harbour", mode.getRaidTarget().getId()); + var battle = BattleFactory.createBlank(BattleType.RAID, "configured_raid"); + BattleFactory.applyTemplate(battle, "configured"); + assertFalse(battle.hasFriendlyFire()); + assertFalse(battle.hasKeepInventory()); + assertFalse(battle.hasLootEnabled()); + assertTrue(battle.isCampaignRaid()); + assertTrue(battle.isCapturePointsEnabled()); + assertEquals(LifeType.COLLECTIVE, battle.getLifeType()); + assertEquals(43, battle.getLives()); + assertEquals(DefenderRespawnMode.LIVES, battle.getDefenderRespawnMode()); + assertEquals(11, battle.getDefenderLives()); + assertNotNull(battle.getSideById(BattleTemplate.ATTACKER_SIDE)); + assertNotNull(battle.getSideById(BattleTemplate.DEFENDER_SIDE)); + } + + @Test + void programmaticSectionsAndMalformedCaptureListsPreserveOnlyUsableDefinitions() + throws Exception { + YamlConfiguration configuration = yaml("capture_points: not_a_list\n"); + assertTrue(BattleModeTemplate.fromSection(configuration).getCapturePoints().isEmpty()); + var valid = yaml("id: valid\nlocation: {x: 1, y: 2, z: 3}\n"); + var invalid = yaml("id: incomplete\n"); + configuration.set("capture_points", new ArrayList<>(List.of(valid, invalid))); + BattleModeTemplate mode = BattleModeTemplate.fromSection(configuration); + assertEquals( + List.of("valid"), + mode.getCapturePoints().stream().map(CapturePointDefinition::getId).toList()); + } + + private World world(String name) { + World world = mock(World.class); + when(world.getName()).thenReturn(name); + bukkit.when(() -> Bukkit.getWorld(name)).thenReturn(world); + return world; + } + + private static YamlConfiguration yaml(String contents) throws Exception { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.loadFromString(contents); + return yaml; + } + + private static void loadAndAssertFailure(BattleTemplateLoader loader, Path file) { + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> loader.load(file.toFile())); + assertTrue(failure.getMessage().contains(file.toString())); + assertNotNull(failure.getCause()); + assertNotNull(failure.getCause().getMessage()); + assertFalse(failure.getCause().getMessage().isBlank()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/war/campaign/ObjectiveProvincePickerTest.java b/src/test/java/net/tfminecraft/simplefactions/war/campaign/ObjectiveProvincePickerTest.java index 9ccde60a..afd25cbf 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/campaign/ObjectiveProvincePickerTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/campaign/ObjectiveProvincePickerTest.java @@ -168,6 +168,7 @@ void pickObjective_fallsBackToGeometricCenter() { void pickObjective_pillageUsesSettlementCenter() { Faction attacker = mockFaction("atk"); Faction defender = mockFaction("def"); + when(defender.getProvinces()).thenReturn(List.of(11)); Settlement settlement = new Settlement("town", "Town", 11, 0, 0); SettlementHandler handler = mock(SettlementHandler.class); when(defender.getSettlementHandler()).thenReturn(handler); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/declare/WarDeclareConflictTest.java b/src/test/java/net/tfminecraft/simplefactions/war/declare/WarDeclareConflictTest.java index 7a469a6c..a8b5c7d7 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/declare/WarDeclareConflictTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/declare/WarDeclareConflictTest.java @@ -139,6 +139,7 @@ private static List<War> activeWars() throws Exception { private static Faction faction(String id) { Faction faction = mock(Faction.class); + when(faction.getDiplomacyHandler()).thenReturn(new net.tfminecraft.simplefactions.diplomacy.DiplomacyHandler(faction)); when(faction.getId()).thenReturn(id); when(faction.getRelations()).thenReturn(new HashMap<>()); when(faction.canHaveVassals()).thenReturn(true); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidatorCivilWarBlockTest.java b/src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidatorCivilWarBlockTest.java index 1fc397e6..4b59758e 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidatorCivilWarBlockTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidatorCivilWarBlockTest.java @@ -101,6 +101,7 @@ void war_stillValidAgainstLockedDefender() { private static Faction mockFaction(String id, int tierLevel) { Faction faction = mock(Faction.class); + when(faction.getDiplomacyHandler()).thenReturn(new net.tfminecraft.simplefactions.diplomacy.DiplomacyHandler(faction)); when(faction.getId()).thenReturn(id); when(faction.getName()).thenReturn(id); when(faction.getRelations()).thenReturn(new HashMap<>()); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidatorTest.java b/src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidatorTest.java index 123df9bd..6eb72c7f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidatorTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidatorTest.java @@ -958,7 +958,7 @@ void pillage_rejectsUnknownSettlement() { WarValidationResult result = validator.validate(pillageRequest(attacker, defender, "missing")); assertFalse(result.isValid()); - assertEquals("§cThat settlement does not exist.", result.getMessage()); + assertEquals("§cThat settlement does not exist or its id is ambiguous in their realm.", result.getMessage()); } @Test @@ -1100,6 +1100,7 @@ private static Title mockTitle(String id, int tierLevel) { private static Faction mockFaction(String id, int tierLevel) { Faction faction = mock(Faction.class); + when(faction.getDiplomacyHandler()).thenReturn(new net.tfminecraft.simplefactions.diplomacy.DiplomacyHandler(faction)); Tier tier = mock(Tier.class); when(faction.getId()).thenReturn(id); when(faction.getTier()).thenReturn(tier);