diff --git a/src/main/java/net/tfminecraft/simplefactions/government/Government.java b/src/main/java/net/tfminecraft/simplefactions/government/Government.java index 94c18f35..3e65d984 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/Government.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/Government.java @@ -46,6 +46,7 @@ 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.UsurpCause; import net.tfminecraft.simplefactions.government.movement.Pool; import net.tfminecraft.simplefactions.government.movement.cause.Cause; import net.tfminecraft.simplefactions.government.proposal.Proposal; @@ -438,6 +439,7 @@ public boolean canProposePolitical(Player p, Action action) { break; case VASSAL_LEADER: if(!PoliticalActionLoader.getByAction(action).allowFactions()) return false; + if(action == Action.USURP && !UsurpCause.canUsurp(FactionManager.getByLeader(name), f)) return false; default: break; diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/Action.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/Action.java index 97534901..a8e3e5d6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/Action.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/Action.java @@ -4,6 +4,7 @@ public enum Action { CHANGE_LEADER("#c5e0e3Change Leader"), NATIONHOOD("#c5e0e3Demand Nationhood"), INDEPENDENCE("#c5e0e3Declare Independence"), + USURP("#c5e0e3Usurp the Title"), SNAP_ELECTIONS("#c5e0e3Call Snap Elections"), DISSOLVE("#c5e0e3Dissolve Faction"), WHITE_PEACE("#c5e0e3White Peace"), 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 97ae5c91..b338bb6e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/PoliticalAction.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/PoliticalAction.java @@ -39,8 +39,14 @@ public PoliticalAction(Action action) { this.action = action; this.icon = "v.paper"; this.name = action.getDisplay(); - this.description = new ArrayList<>(); - this.pools = Arrays.asList("citizens", "guilds", "factions"); + this.description = action == Action.USURP + ? new ArrayList<>(List.of(StringFormatter.formatHex("#c4c0a1The cause leader's faction takes the highest title"), + StringFormatter.formatHex("#c4c0a1and becomes the overlord of this faction."))) + : new ArrayList<>(); + // Only a vassal can take its overlord's title; everything else is open to all pools. + this.pools = action == Action.USURP + ? List.of("vassals") + : Arrays.asList("citizens", "guilds", "factions"); } public Action getAction() { diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/UsurpCause.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/UsurpCause.java new file mode 100644 index 00000000..bf448fba --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/UsurpCause.java @@ -0,0 +1,53 @@ +package net.tfminecraft.simplefactions.government.movement; + +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.RelationManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.government.movement.cause.Cause; +import net.tfminecraft.simplefactions.war.declare.WarGoalValidator; + +/** + * A vassal takes its overlord's highest title through a movement in the overlord's + * government, not a war declared from outside. The cause leader's faction usurps. + */ +public final class UsurpCause { + private UsurpCause() {} + + /** + * True when {@code vassal} is a direct subject of {@code overlord} and may take its title. + * The usurper must be allowed vassals, since the old overlord becomes its subject. + */ + public static boolean canUsurp(Faction vassal, Faction overlord) { + if (vassal == null || overlord == null || overlord.getHighestTitle() == null || !vassal.canHaveVassals()) { + return false; + } + if (!RelationManager.isOverlord(vassal, overlord)) { + return false; + } + return WarGoalValidator.canUsurpByRank(vassal.getTier().getTier(), overlord.getTier().getTier()); + } + + /** The faction that takes the title: the cause leader's, else the first eligible faction in the cause. */ + public static Faction usurper(Cause cause, Faction overlord) { + if (cause == null) { + return null; + } + Faction led = cause.hasLeader() ? FactionManager.getByMember(cause.getLeader()) : null; + if (canUsurp(led, overlord)) { + return led; + } + for (Faction faction : cause.getPool().getFactions()) { + if (canUsurp(faction, overlord)) { + return faction; + } + } + return null; + } + + public static void apply(Cause cause, Faction overlord) { + Faction usurper = usurper(cause, overlord); + if (usurper != null) { + FactionManager.usurp(null, usurper, overlord); + } + } +} 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 ebb41a6c..6240ec44 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java @@ -242,6 +242,9 @@ public ItemStack getAsBook(Player p) { case INDEPENDENCE: first.add(StringFormatter.formatHex("#d4c9aeMembers of the cause are granted independence")); break; + case USURP: + first.add(StringFormatter.formatHex("#d4c9aeThe cause leader's faction takes this faction's highest title")); + break; case NATIONHOOD: first.add(StringFormatter.formatHex("#d4c9aeMembers of the cause are elevated to nationhood")); break; diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/PoliticalActionLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/PoliticalActionLoader.java index 1ceb874a..8640bb78 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/PoliticalActionLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/PoliticalActionLoader.java @@ -41,6 +41,8 @@ public void load(File configFile) { PoliticalAction action = new PoliticalAction(key, config.getConfigurationSection(key)); loaded.put(action.getAction(), action); } + // Usurp is newer than most servers' copy of this file; it is vassal-only by default. + loaded.putIfAbsent(Action.USURP, new PoliticalAction(Action.USURP)); } catch (IOException | InvalidConfigurationException | RuntimeException e) { throw new IllegalStateException("Cannot load definitions from " + configFile, e); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java index 9229a232..39550383 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java @@ -1010,7 +1010,7 @@ public void confirmWarDeclareView(Player player, WarDeclareRequest request) { pendingWarDeclares.put(player, request); confirming.put(player, request.getAttacker()); Inventory i = SimpleFactions.plugin.getServer().createInventory(null, 27, "§7Confirm Action"); - i.setItem(13, declareWarView.creator.createConfirmSummaryItem(request)); + i.setItem(13, declareWarView.creator.createConfirmSummaryItem(request, DeclareWarView.needsCode(player, request))); i.setItem(11, createButton("confirm", "war_declare", request.getDefender().getId())); i.setItem(15, createButton("cancel", "war_declare", request.getDefender().getId())); player.openInventory(i); 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 94c6cea0..b9b444b1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DeclareWarCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DeclareWarCreator.java @@ -25,6 +25,7 @@ import net.tfminecraft.simplefactions.war.declare.DeJureAnnexEligibility.KeptSettlement; import net.tfminecraft.simplefactions.war.declare.OpenMarketEligibility; import net.tfminecraft.simplefactions.war.declare.PillageEligibility; +import net.tfminecraft.simplefactions.war.declare.WarGoalPreview; import net.tfminecraft.simplefactions.war.declare.WarDeclareRequest; import net.tfminecraft.simplefactions.war.enums.WarGoalType; import net.tfminecraft.simplefactions.laws.Law; @@ -34,9 +35,10 @@ public class DeclareWarCreator { - public ItemStack createTributaryGoalItem() { - return createGoalItem(WarGoalType.TRIBUTARY, "§lTributary", List.of( - "§7Make the defender your tributary.")); + public ItemStack createTributaryGoalItem(Faction attacker, Faction defender) { + List lore = new ArrayList<>(List.of("§7Make the defender your tributary.")); + lore.addAll(WarGoalPreview.subjectEffects(RelationLoader.getType("tributary"), attacker, defender)); + return createGoalItem(WarGoalType.TRIBUTARY, "§lTributary", lore); } public ItemStack createSubjugateGoalItem() { @@ -45,16 +47,24 @@ public ItemStack createSubjugateGoalItem() { "§7your subject.")); } - public ItemStack createUsurpGoalItem() { - return createGoalItem(WarGoalType.USURP, "§lUsurp", List.of( + public ItemStack createUsurpGoalItem(Faction defender) { + List lore = new ArrayList<>(List.of( "§7Take the defender's primary title", "§7and become their liege.")); + lore.addAll(WarGoalPreview.usurp(defender)); + return createGoalItem(WarGoalType.USURP, "§lUsurp", lore); } - public ItemStack createOpenMarketGoalItem() { - return createGoalItem(WarGoalType.OPEN_MARKET, "§lOpen Market", List.of( + public ItemStack createOpenMarketGoalItem(Faction defender) { + List lore = new ArrayList<>(List.of( "§7Force the defender to adopt", "§7the configured free-trade law.")); + OpenMarketEligibility.ResolvedLaw resolved = + OpenMarketEligibility.resolve(defender, Cache.openMarketApplyDefenderLaw); + if (resolved != null && resolved.law() != null && resolved.law().getName() != null) { + lore.add("§7Law: §f" + resolved.law().getName()); + } + return createGoalItem(WarGoalType.OPEN_MARKET, "§lOpen Market", lore); } public ItemStack createChangeGovernmentGoalItem() { @@ -63,10 +73,32 @@ public ItemStack createChangeGovernmentGoalItem() { "§7government and leadership combo.")); } - public ItemStack createPillageGoalItem() { - return createGoalItem(WarGoalType.PILLAGE, "§lPillage", List.of( + /** {@code bestLoot}: the richest eligible settlement's line, or null when none is in range. */ + public ItemStack createPillageGoalItem(String bestLoot) { + List lore = new ArrayList<>(List.of( "§7Raid a nearby settlement for", "§7trade income and loot.")); + lore.add(bestLoot != null ? bestLoot : "§cNo settlement in range to pillage."); + return createGoalItem(WarGoalType.PILLAGE, "§lPillage", lore); + } + + /** Shown above the goals: what every war wins or costs, and when the staff code is asked for. */ + // Keep the existing legacy text representation, formatting, and exact-string comparisons. + @SuppressWarnings("deprecation") + public ItemStack createOverviewItem(Faction attacker, Faction defender, boolean codeRequired) { + ItemStack item = new ItemStack(Material.GOLD_NUGGET); + ItemMeta meta = item.getItemMeta(); + meta.setDisplayName("§6War with " + defender.getName()); + List lore = new ArrayList<>(WarGoalPreview.reparations(attacker, defender)); + lore.add(" "); + lore.add("§7Hover over a goal to see what it wins."); + if (codeRequired) { + lore.add("§7You only need a staff war code"); + lore.add("§7when you confirm the declaration."); + } + meta.setLore(lore); + item.setItemMeta(meta); + return item; } public ItemStack createDeJureGoalItem() { @@ -100,13 +132,14 @@ private ItemStack createGoalItem(WarGoalType goal, String name, List des // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") - public ItemStack createRelationTypeItem(RelationType type, Faction attacker) { + public ItemStack createRelationTypeItem(RelationType type, Faction attacker, Faction defender) { ItemStack item = IconGetter.getIcon("war"); ItemMeta meta = item.getItemMeta(); meta.setDisplayName(type.getName()); List lore = new ArrayList<>(); lore.add("§7The defender becomes this"); lore.add("§7kind of subject."); + lore.addAll(WarGoalPreview.subjectEffects(type, attacker, defender)); if (attacker != null && RelationManager.atLimit(attacker, type)) { lore.add("§cAt limit"); } @@ -174,7 +207,8 @@ public ItemStack createSettlementItem(PillageEligibility.PillageSettlementOption ItemStack item = new ItemStack(Material.PAPER, 1); ItemMeta meta = item.getItemMeta(); meta.setDisplayName("§7" + settlement.getName()); - List lore = new ArrayList<>(); + List lore = new ArrayList<>(WarGoalPreview.pillage(settlement)); + lore.add(" "); if (option.eligible()) { lore.add("§eClick to declare"); } else if (option.blockReason() != null) { @@ -263,7 +297,7 @@ public ItemStack createGovernmentConfirmItem() { // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") - public ItemStack createConfirmSummaryItem(WarDeclareRequest request) { + public ItemStack createConfirmSummaryItem(WarDeclareRequest request, boolean asksForCode) { ItemStack item = IconGetter.getIcon("war"); ItemMeta meta = item.getItemMeta(); meta.setDisplayName(StringFormatter.formatHex("#d42300§lDeclare War?")); @@ -303,6 +337,7 @@ public ItemStack createConfirmSummaryItem(WarDeclareRequest request) { Settlement settlement = PillageEligibility.findSettlement(request.getTargetSettlementId(), request.getDefender()); if (settlement != null && settlement.getName() != null) { lore.add(StringFormatter.formatHex("#a89977Settlement: "+settlement.getName())); + lore.addAll(WarGoalPreview.pillage(settlement)); } } if (request.getGoal() == WarGoalType.USURP) { @@ -317,6 +352,11 @@ public ItemStack createConfirmSummaryItem(WarDeclareRequest request) { lore.addAll(coalitionWarnings); } lore.add(" "); + lore.addAll(WarGoalPreview.reparations(request.getAttacker(), request.getDefender())); + lore.add(" "); + if (asksForCode) { + lore.add("§eConfirming asks for your staff war code."); + } lore.add(StringFormatter.formatHex("#c74c3fThis cannot be undone easily.")); meta.setLore(lore); item.setItemMeta(meta); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DeclareWarView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DeclareWarView.java index d2454dc4..3dc9f964 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DeclareWarView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DeclareWarView.java @@ -32,11 +32,13 @@ import net.tfminecraft.simplefactions.war.core.WarDemand; import net.tfminecraft.simplefactions.war.enums.WarGoalType; import net.tfminecraft.simplefactions.war.declare.ChangeGovernmentEligibility; +import net.tfminecraft.simplefactions.war.declare.DeclareCodePrompt; import net.tfminecraft.simplefactions.war.declare.DeJureAnnexEligibility.DeJureTitleOption; import net.tfminecraft.simplefactions.war.declare.PillageEligibility; import net.tfminecraft.simplefactions.war.declare.WarDeclareCodeService; import net.tfminecraft.simplefactions.war.declare.WarDeclareRequest; import net.tfminecraft.simplefactions.war.declare.WarDemandRules; +import net.tfminecraft.simplefactions.war.declare.WarGoalPreview; import net.tfminecraft.simplefactions.war.declare.WarGoalValidator; import net.tfminecraft.simplefactions.war.declare.WarValidationResult; import net.tfminecraft.simplefactions.enums.SFGUI; @@ -75,21 +77,26 @@ public void openGoalPicker( inventory.clear(); int slot = 10; boolean withinRealm = withinRealm(demand); + if (demand == null) { + inventory.setItem(4, creator.createOverviewItem( + attacker, defender, WarDeclareCodeService.isRequired(player))); + } // A ruler punishing their own vassal cannot set them free as a tributary. if (!withinRealm) { - inventory.setItem(slot++, creator.createTributaryGoalItem()); + inventory.setItem(slot++, creator.createTributaryGoalItem(attacker, defender)); } if (attacker.canHaveVassals()) { inventory.setItem(slot++, creator.createSubjugateGoalItem()); } - inventory.setItem(slot++, creator.createOpenMarketGoalItem()); + inventory.setItem(slot++, creator.createOpenMarketGoalItem(defender)); inventory.setItem(slot++, creator.createChangeGovernmentGoalItem()); // Pillage is fought over its own settlement, so it can only be a war's first goal. if (demand == null) { - inventory.setItem(slot++, creator.createPillageGoalItem()); + inventory.setItem(slot++, creator.createPillageGoalItem( + WarGoalPreview.bestPillage(PillageEligibility.options(attacker, defender)))); } if (!withinRealm && WarDeclareHelper.canDeclareUsurp(attacker, defender)) { - inventory.setItem(slot++, creator.createUsurpGoalItem()); + inventory.setItem(slot++, creator.createUsurpGoalItem(defender)); } if (!titleOptions(attacker, defender, demand).isEmpty()) { inventory.setItem(slot++, creator.createDeJureGoalItem()); @@ -123,7 +130,7 @@ public void openRelationTypePicker( int slot = 0; for (RelationType type : subjectTypes(demand)) { if (slot >= 26) break; - inventory.setItem(slot++, creator.createRelationTypeItem(type, attacker)); + inventory.setItem(slot++, creator.createRelationTypeItem(type, attacker, defender)); } inventory.setItem(26, inv.createBackButton(SFGUI.WAR_DECLARE_RELATION_TYPE)); relabelForDemand(inventory, demand); @@ -408,9 +415,21 @@ public void handleConfirm(Player player, WarDeclareRequest request, boolean conf returnToPreviousPicker(player, request); return; } + if (needsCode(player, request)) { + // Staff bypass the code by permission, which is what makes it safe for an + // unreachable ProvinceSystem to fail closed here. + DeclareCodePrompt.begin(player, request); + return; + } executeDeclare(player, request); } + /** True when the leader still has to enter a staff code that covers this pairing. */ + public static boolean needsCode(Player player, WarDeclareRequest request) { + return WarDeclareCodeService.isRequired(player) + && !WarDeclareCodeService.covers(WarDeclareCodeService.session(player), request); + } + /** Back out of a goal's sub-picker. A code session stays open: it covers the pairing, not a goal. */ private void backOutOfSubPicker(Player player, Faction attacker, Faction defender, WarDemandDraft demand) { if (demand != null) { @@ -522,17 +541,9 @@ private void returnToDemands(Player player, WarDemandDraft demand) { } private void executeDeclare(Player player, WarDeclareRequest request) { - // The war button gates the entry, but pendingWarDeclares is a separate map, so - // the session is re-checked here against the pairing actually confirmed. - WarDeclareCodeService.Session session = null; - if (WarDeclareCodeService.isRequired(player)) { - session = WarDeclareCodeService.session(player); - if (!WarDeclareCodeService.covers(session, request)) { - WarDeclareCodeService.clearSession(player); - player.sendMessage("§cYour war code does not cover this declaration."); - return; - } - } + // handleConfirm has already checked that this session covers the confirmed pairing. + WarDeclareCodeService.Session session = + WarDeclareCodeService.isRequired(player) ? WarDeclareCodeService.session(player) : null; War war = WarManager.declareWar( request.getAttacker(), request.getDefender(), 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 592e128d..54f6e8be 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/RelationView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/RelationView.java @@ -31,8 +31,6 @@ import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.tiers.Tier; -import net.tfminecraft.simplefactions.war.declare.DeclareCodePrompt; -import net.tfminecraft.simplefactions.war.declare.WarDeclareCodeService; import net.tfminecraft.simplefactions.enums.SFGUI; public class RelationView { @@ -302,13 +300,7 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { p.sendMessage("§cCannot declare war while none of the target faction members are online!"); return; } - if (WarDeclareCodeService.isRequired(p)) { - // Staff bypass the code entirely by permission, which is what makes it - // safe for an unreachable ProvinceSystem to fail closed here. - p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); - DeclareCodePrompt.begin(p, attacker, f); - return; - } + // Leaders browse the goals freely; the staff code is asked for at confirm. p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); inv.openDeclareWarGoalPicker(p, attacker, f); } diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java index 75741432..d98f0961 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java @@ -63,6 +63,7 @@ import net.tfminecraft.simplefactions.government.movement.Action; import net.tfminecraft.simplefactions.government.movement.CoupService; import net.tfminecraft.simplefactions.government.movement.PoliticalAction; +import net.tfminecraft.simplefactions.government.movement.UsurpCause; import net.tfminecraft.simplefactions.government.movement.cause.Cause; import net.tfminecraft.simplefactions.government.proposal.Proposal; import net.tfminecraft.simplefactions.government.proposal.TaxTarget; @@ -1110,6 +1111,9 @@ public void applyPoliticalAction(Cause cause, Proposal proposal) { RelationManager.endVassalage(vassal, this, false); } break; + case USURP: + UsurpCause.apply(cause, this); + break; case NATIONHOOD: if(cause == null) return; //no cause = no members to give nationhood for(Guild guild : cause.getPool().getGuilds()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/LoreWriter.java b/src/main/java/net/tfminecraft/simplefactions/utils/LoreWriter.java index 383a8786..cb512efd 100644 --- a/src/main/java/net/tfminecraft/simplefactions/utils/LoreWriter.java +++ b/src/main/java/net/tfminecraft/simplefactions/utils/LoreWriter.java @@ -105,6 +105,9 @@ public static void applyProposalLore(Proposal proposal, List lore, Playe case INDEPENDENCE: lore.add(StringFormatter.formatHex("#d4c9aeMembers of the cause are granted independence")); break; + case USURP: + lore.add(StringFormatter.formatHex("#d4c9aeThe cause leader's faction takes this faction's highest title")); + break; case NATIONHOOD: lore.add(StringFormatter.formatHex("#d4c9aeMembers of the cause are elevated to nationhood")); break; diff --git a/src/main/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarGoalMapper.java b/src/main/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarGoalMapper.java index 384fc752..0f3b4853 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarGoalMapper.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarGoalMapper.java @@ -29,6 +29,7 @@ public static WarGoalType fromAction(Action action) { case TAX_CHANGE -> WarGoalType.CHANGE_TAX; case WHITE_PEACE, SURRENDER -> WarGoalType.FORCE_PEACE; case INDEPENDENCE -> WarGoalType.INDEPENDENCE; + case USURP -> WarGoalType.USURP; case NATIONHOOD -> WarGoalType.NATIONHOOD; default -> null; }; diff --git a/src/main/java/net/tfminecraft/simplefactions/war/core/WarDeclareHelper.java b/src/main/java/net/tfminecraft/simplefactions/war/core/WarDeclareHelper.java index c69cbe69..81f9d14d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/core/WarDeclareHelper.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/core/WarDeclareHelper.java @@ -77,20 +77,17 @@ public static boolean canDeclareUsurp(Faction attacker, Faction defender) { if (attacker == null || defender == null) { return false; } - if (defender.getHighestTitle() == null) { + if (defender.getHighestTitle() == null || !attacker.canHaveVassals()) { return false; } if (!WarGoalValidator.canUsurpByRank(attacker.getTier().getTier(), defender.getTier().getTier())) { return false; } - if (RelationManager.sameRealm(attacker, defender) - && !RelationManager.isOverlord(attacker, defender)) { + // A vassal usurps its overlord through a movement in the overlord's government. + if (RelationManager.sameRealm(attacker, defender)) { return false; } - if (InterVassalQueries.isInternalPeer(attacker, defender)) { - return false; - } - return true; + return !InterVassalQueries.isInternalPeer(attacker, defender); } static boolean canAnnexByRank(int attackerTierLevel, int titleTierLevel) { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/declare/DeclareCodePrompt.java b/src/main/java/net/tfminecraft/simplefactions/war/declare/DeclareCodePrompt.java index 45571e80..faa76e4b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/declare/DeclareCodePrompt.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/declare/DeclareCodePrompt.java @@ -31,23 +31,25 @@ public class DeclareCodePrompt implements Listener { private static final Map pending = new ConcurrentHashMap<>(); private static class Pending { + private final WarDeclareRequest request; private final String attackerId; private final String defenderId; private final AtomicBoolean submitted = new AtomicBoolean(); - private Pending(String attackerId, String defenderId) { - this.attackerId = attackerId; - this.defenderId = defenderId; + private Pending(WarDeclareRequest request) { + this.request = request; + this.attackerId = request.getAttacker().getId(); + this.defenderId = request.getDefender().getId(); } } - /** Asks the leader for the code that staff handed them. */ - public static void begin(Player player, Faction attacker, Faction defender) { - if (player == null || attacker == null || defender == null) { + /** Asks the leader for the code that staff handed them, once they confirm a declaration. */ + public static void begin(Player player, WarDeclareRequest request) { + if (player == null || request == null) { return; } player.closeInventory(); - Pending state = new Pending(attacker.getId(), defender.getId()); + Pending state = new Pending(request); pending.put(player, state); WarDeclareCodeService.clearSession(player); player.sendMessage("§eEnter your war declaration code in chat:"); @@ -146,9 +148,9 @@ private void apply( WarDeclareCodeService.openSession(player, new WarDeclareCodeService.Session( code, state.attackerId, state.defenderId)); - player.sendMessage("§aCode accepted. Choose your war goal."); + player.sendMessage("§aCode accepted. Confirm to declare the war."); player.playSound(player, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); - FactionManager.getInv().declareWarView.openGoalPicker(player, attacker, defender); + FactionManager.getInv().confirmWarDeclareView(player, state.request); } @EventHandler diff --git a/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalPreview.java b/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalPreview.java new file mode 100644 index 00000000..1c4c7697 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalPreview.java @@ -0,0 +1,123 @@ +package net.tfminecraft.simplefactions.war.declare; + +import java.util.ArrayList; +import java.util.List; + +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.diplomacy.RelationType; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.TradeBreakdown; +import net.tfminecraft.simplefactions.managers.RelationManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.FactionModifier; +import net.tfminecraft.simplefactions.settlement.Settlement; +import net.tfminecraft.simplefactions.tiers.Title; +import net.tfminecraft.simplefactions.utils.Formatter; +import net.tfminecraft.simplefactions.war.resolution.PillageApplyService; +import net.tfminecraft.simplefactions.war.resolution.WarReparationsService; + +/** + * What a war would win, shown while a leader browses the declare menus, so they can + * judge a war before asking staff for a code. Money figures use today's trade income. + */ +public final class WarGoalPreview { + private WarGoalPreview() {} + + /** Every decisive war ends in reparations from the loser, whatever the goal. */ + public static List reparations(Faction attacker, Faction defender) { + List lines = new ArrayList<>(); + String percent = percent(Cache.warReparationsIncomePercent); + int days = Cache.warReparationsDays; + double won = dailyReparations(defender); + lines.add("§aIf you win: §7" + defender.getName() + " §7and its subjects pay you"); + lines.add("§7" + percent + "% of their trade income for " + days + " days"); + lines.add("§7(about §e" + money(won) + "d §7a day, §e" + money(won * days) + "d §7in total)"); + double lost = dailyReparations(attacker); + lines.add("§cIf you lose: §7you and your subjects pay them"); + lines.add("§7the same share (about §e" + money(lost) + "d §7a day)"); + return lines; + } + + /** The reparations a defeated faction and its subjects would pay a day, at today's income. */ + public static double dailyReparations(Faction payer) { + double total = 0; + for (Faction faction : WarReparationsService.payerAndVassals(payer)) { + for (Guild guild : faction.getGuildHandler().getGuilds()) { + TradeBreakdown trade = guild.getTradeBreakdown(); + total += trade == null ? 0 : Math.max(0, trade.getIncome()); + } + } + return total * Cache.warReparationsIncomePercent / 100.0; + } + + /** The defender's relation toward the attacker after a subjugation or tributary win. */ + public static List subjectEffects(RelationType type, Faction attacker, Faction defender) { + List lines = new ArrayList<>(); + if (type == null) { + return lines; + } + if (type.hasGiveModifiers()) { + lines.add("§7You get:"); + for (FactionModifier mod : type.getGiveModifiers()) { + lines.add("§7- " + new FactionModifier(defender, mod).getString(attacker)); + } + } + if (type.hasRecieveModifiers()) { + lines.add("§7They get:"); + for (FactionModifier mod : type.getRecieveModifiers()) { + lines.add("§7- " + new FactionModifier(attacker, mod).getString(defender)); + } + } + return lines; + } + + public static List usurp(Faction defender) { + List lines = new ArrayList<>(); + Title title = defender.getHighestTitle(); + if (title != null) { + lines.add("§7Title: §f" + title.getName()); + } + int subjects = RelationManager.getSubjects(defender).size(); + if (subjects > 0) { + lines.add("§7Subjects that become yours: §f" + subjects); + } + lines.add("§7They become your subject"); + return lines; + } + + public static List pillage(Settlement settlement) { + List lines = new ArrayList<>(); + List guilds = PillageApplyService.guildsIn(settlement); + lines.add("§7Loot: §e" + money(PillageApplyService.previewLoot(guilds)) + "d"); + lines.add("§7(" + Cache.pillageLootDays + " days of trade income from " + guilds.size() + + (guilds.size() == 1 ? " guild)" : " guilds)")); + lines.add("§7Their guilds there lose " + percent(Math.abs(Cache.pillageTradeHitPercent)) + "%"); + lines.add("§7of their trade income, recovering over " + Cache.pillageTradeHitDays + " days"); + return lines; + } + + /** The richest settlement the attacker may pillage, or null when none is eligible. */ + public static String bestPillage(List options) { + double best = -1; + String name = null; + for (PillageEligibility.PillageSettlementOption option : options) { + if (!option.eligible()) { + continue; + } + double loot = PillageApplyService.previewLoot(PillageApplyService.guildsIn(option.settlement())); + if (loot > best) { + best = loot; + name = option.settlement().getName(); + } + } + return name == null ? null : "§7Best loot: §e" + money(best) + "d §7from " + name; + } + + private static String money(double amount) { + return Formatter.formatMoney(amount); + } + + private static String percent(double percent) { + return percent == Math.rint(percent) ? String.valueOf((long) percent) : String.valueOf(percent); + } +} 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 b70dd218..f48cc92c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidator.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidator.java @@ -35,6 +35,8 @@ * */ public class WarGoalValidator { + public static final String USURP_OVERLORD = + "§cTo usurp your overlord, start a Usurp movement in their government."; public WarValidationResult validate(WarDeclareRequest request) { WarValidationResult shared = validateShared(request); @@ -91,11 +93,10 @@ private WarValidationResult validateShared(WarDeclareRequest request) { if (!request.isWithinRealm() && RelationManager.sameRealm(attacker, defender) && !InterVassalQueries.isInternalPeer(attacker, defender)) { - boolean usurpOverlord = request.getGoal() == WarGoalType.USURP - && RelationManager.isOverlord(attacker, defender); - if (!usurpOverlord) { - return WarValidationResult.fail("§cYou cannot declare war on a faction in the same realm."); + if (request.getGoal() == WarGoalType.USURP && RelationManager.isOverlord(attacker, defender)) { + return WarValidationResult.fail(USURP_OVERLORD); } + return WarValidationResult.fail("§cYou cannot declare war on a faction in the same realm."); } // A truce stops new wars; a demand inside one already being fought is not one. WarValidationResult truce = demand ? WarValidationResult.ok() : validateTruce(attacker, defender); @@ -195,6 +196,10 @@ private WarValidationResult validateUsurp(WarDeclareRequest request) { if (!canUsurpByRank(attacker.getTier().getTier(), defender.getTier().getTier())) { return WarValidationResult.fail("§cYou cannot usurp a faction of lower rank."); } + // The loser becomes the usurper's subject, which a faction without vassals cannot hold. + if (!attacker.canHaveVassals()) { + return WarValidationResult.fail("§cYour laws do not allow vassals, so you cannot usurp."); + } return WarValidationResult.ok(); } 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 5d22ad31..c3c46d46 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/resolution/PillageApplyService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/resolution/PillageApplyService.java @@ -30,6 +30,16 @@ public static void apply(War war) { applyTreasury(war, hit, snapshotLoot(hit, PillageApplyService::liveTradeIncome, Cache.pillageLootDays), true); } + /** The guilds a pillage of this settlement would hit, as {@link #apply} finds them. */ + public static List guildsIn(Settlement settlement) { + return guildsInSettlement(settlement, FactionManager.getAllGuilds()); + } + + /** The loot a pillage would pay out today, before the war is fought. */ + public static double previewLoot(List guilds) { + return snapshotLoot(guilds, PillageApplyService::liveTradeIncome, Cache.pillageLootDays); + } + static List guildsInSettlement(Settlement settlement, List all) { List hit = new ArrayList<>(); if (settlement == null || all == null) { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/resolution/WarOutcomeService.java b/src/main/java/net/tfminecraft/simplefactions/war/resolution/WarOutcomeService.java index afe20957..c10b141c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/resolution/WarOutcomeService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/resolution/WarOutcomeService.java @@ -156,7 +156,14 @@ static void applyAttackerGoal(War war) { case TRIBUTARY -> applyTributary(war); case SUBJUGATE -> applySubjugate(war); case TRANSFER_SUBJECT -> applyTransferSubject(war); - case USURP -> applyUsurp(war); + // A vassal's usurp is won through its movement, which knows who leads the cause. + case USURP -> { + if (war.getMovementId() != null && !war.getMovementId().isBlank()) { + applyMovementWar(war); + } else { + applyUsurp(war); + } + } case DE_JURE_ANNEX -> applyDeJureAnnex(war); case OPEN_MARKET -> applyOpenMarket(war); case CHANGE_GOVERNMENT -> applyChangeGovernment(war); diff --git a/src/main/resources/political-actions.yml b/src/main/resources/political-actions.yml index a7172dc9..97461777 100644 --- a/src/main/resources/political-actions.yml +++ b/src/main/resources/political-actions.yml @@ -19,6 +19,14 @@ independence: pools: - guilds - vassals +usurp: + name: "#62ccd1Usurp" + description: + - "#c4c0a1The cause leader's faction takes the highest title" + - "#c4c0a1and becomes the overlord of this faction." + - "#c4c0a1Only direct vassals can lead or join it." + pools: + - vassals snap_elections: name: "#62ccd1Snap Elections" description: diff --git a/src/main/resources/war.yml b/src/main/resources/war.yml index 93337c51..60ef923a 100644 --- a/src/main/resources/war.yml +++ b/src/main/resources/war.yml @@ -3,8 +3,8 @@ # Hours under battle_schedule are Europe/Paris (CET/CEST), not UTC. war: - # Production gate: an attacking leader must type a staff-minted Discord code before - # the goal picker opens, and the code pins the goal. simplefactions.admin bypasses it. + # Production gate: an attacking leader can browse every war goal freely, but must type + # a staff-minted Discord code to confirm the declaration. simplefactions.admin bypasses it. require_declare_code: true # How long to wait for ProvinceSystem to answer a code check before giving up. The # gate fails closed, so a slow backend blocks the declare rather than allowing it. diff --git a/src/test/java/net/tfminecraft/simplefactions/diplomacy/coalition/CoalitionViewTest.java b/src/test/java/net/tfminecraft/simplefactions/diplomacy/coalition/CoalitionViewTest.java index a10ae614..47914c88 100644 --- a/src/test/java/net/tfminecraft/simplefactions/diplomacy/coalition/CoalitionViewTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/diplomacy/coalition/CoalitionViewTest.java @@ -356,9 +356,9 @@ void theDeclarationSummaryCarriesCoalitionWarnings() { WarView unused = new WarView(navigation); assertNotNull(unused); var declare = new net.tfminecraft.simplefactions.managers.inventory.DeclareWarView(navigation); - String summary = lore(declare.creator.createConfirmSummaryItem(WarDeclareRequest.of(holy, thalendor, WarGoalType.TRIBUTARY))); + String summary = lore(declare.creator.createConfirmSummaryItem(WarDeclareRequest.of(holy, thalendor, WarGoalType.TRIBUTARY), false)); assertTrue(summary.contains("Its members can be called into this war.")); - String quiet = lore(declare.creator.createConfirmSummaryItem(WarDeclareRequest.of(fig, holy, WarGoalType.TRIBUTARY))); + String quiet = lore(declare.creator.createConfirmSummaryItem(WarDeclareRequest.of(fig, holy, WarGoalType.TRIBUTARY), false)); assertFalse(quiet.contains("coalition")); verify(navigation, never()).warView(any(), any(), any(), org.mockito.ArgumentMatchers.anyBoolean()); } diff --git a/src/test/java/net/tfminecraft/simplefactions/government/UsurpMovementTest.java b/src/test/java/net/tfminecraft/simplefactions/government/UsurpMovementTest.java new file mode 100644 index 00000000..039ac47c --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/government/UsurpMovementTest.java @@ -0,0 +1,186 @@ +package net.tfminecraft.simplefactions.government; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.tfminecraft.simplefactions.database.FactionData; +import net.tfminecraft.simplefactions.government.movement.Action; +import net.tfminecraft.simplefactions.government.movement.PoliticalAction; +import net.tfminecraft.simplefactions.government.movement.Pool; +import net.tfminecraft.simplefactions.government.movement.UsurpCause; +import net.tfminecraft.simplefactions.government.movement.cause.Cause; +import net.tfminecraft.simplefactions.government.proposal.Proposal; +import net.tfminecraft.simplefactions.loaders.PoliticalActionLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.war.civilwar.CivilWarGoalMapper; +import net.tfminecraft.simplefactions.war.core.WarDeclareHelper; +import net.tfminecraft.simplefactions.war.declare.WarDeclareRequest; +import net.tfminecraft.simplefactions.war.declare.WarGoalValidator; +import net.tfminecraft.simplefactions.war.enums.WarGoalType; +import org.bukkit.Bukkit; +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; + +/** A vassal takes its overlord's title through a movement, not a declared war. */ +class UsurpMovementTest { + private FactionDomainFixture fixture; + private Map originalActions; + private Faction duke; + private Faction count; + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + originalActions = PoliticalActionLoader.map; + 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))); + fixture.lawGroup("authority", Map.of("effects.faction.rules", List.of("CAN_HAVE_VASSALS true"))); + duke = titled("duke", "Dana", "duke_title", "duchy"); + count = titled("count", "Carl", "count_title", "county"); + fixture.subject(duke, count); + } + + @AfterEach + void cleanup() { + PoliticalActionLoader.map = originalActions; + if (fixture != null) fixture.close(); + } + + private Faction titled(String id, String leader, String titleId, String tier) { + fixture.title(titleId, tier); + FactionData data = fixture.data(id, leader); + data.titles.add(titleId); + Faction faction = fixture.saved(data); + faction.updateTier(); + return faction; + } + + private Cause cause(String leader, Faction... members) { + Cause cause = mock(Cause.class); + when(cause.hasLeader()).thenReturn(leader != null); + when(cause.getLeader()).thenReturn(leader); + Pool pool = new Pool(); + for (Faction member : members) pool.addFaction(member); + when(cause.getPool()).thenReturn(pool); + return cause; + } + + @Test + void onlyADirectVassalOfEqualOrLowerRankCanUsurpATitledOverlord() { + Faction baron = titled("baron", "Bea", "baron_title", "province"); + fixture.subject(count, baron); + Faction stranger = titled("stranger", "Sam", "stranger_title", "county"); + + assertTrue(UsurpCause.canUsurp(count, duke)); + assertFalse(UsurpCause.canUsurp(baron, duke), "a nested subject must go through its own liege"); + assertFalse(UsurpCause.canUsurp(stranger, duke)); + assertFalse(UsurpCause.canUsurp(duke, count)); + assertFalse(UsurpCause.canUsurp(null, duke)); + assertFalse(UsurpCause.canUsurp(count, null)); + Faction untitled = fixture.saved("untitled", "Uma"); + fixture.subject(untitled, stranger); + assertFalse(UsurpCause.canUsurp(stranger, untitled), "an untitled overlord has nothing to take"); + Faction king = titled("king", "Kai", "king_title", "duchy"); + Faction prince = titled("prince", "Pia", "prince_title", "duchy"); + fixture.title("extra_duchy", "duchy"); + fixture.subject(prince, king); + assertTrue(UsurpCause.canUsurp(king, prince), "equal rank may usurp"); + Faction noVassals = spy(count); + when(noVassals.canHaveVassals()).thenReturn(false); + assertFalse(UsurpCause.canUsurp(noVassals, duke), "the old overlord must be able to become its subject"); + } + + @Test + void theCauseLeadersFactionUsurpsElseTheFirstEligibleMember() { + Faction other = titled("other", "Otto", "other_title", "county"); + fixture.subject(duke, other); + Faction stranger = fixture.saved("stranger", "Sam"); + + assertSame(count, UsurpCause.usurper(cause("Carl", other), duke)); + assertSame(other, UsurpCause.usurper(cause(null, stranger, other), duke)); + assertSame(other, UsurpCause.usurper(cause("Sam", other), duke)); + assertNull(UsurpCause.usurper(cause(null, stranger), duke)); + assertNull(UsurpCause.usurper(null, duke)); + } + + @Test + void applyingTheCauseUsurpsTheOverlordAndAnEmptyCauseChangesNothing() { + try (MockedStatic factions = mockStatic(FactionManager.class, CALLS_REAL_METHODS)) { + factions.when(() -> FactionManager.usurp(any(), any(), any())).thenReturn(null); + Proposal proposal = new Proposal("Carl", duke.getGovernment()); + proposal.setPoliticalActionProposal(new PoliticalAction(Action.USURP)); + duke.applyPoliticalAction(cause("Carl", count), proposal); + factions.verify(() -> FactionManager.usurp(null, count, duke)); + + duke.applyPoliticalAction(cause(null), proposal); + factions.verify(() -> FactionManager.usurp(any(), any(), any()), times(1)); + } + } + + @Test + void onlyAnEligibleVassalLeaderCanProposeAUsurp() { + Government government = duke.getGovernment(); + count.addMember("Cody"); + Faction baron = titled("baron", "Bea", "baron_title", "province"); + fixture.subject(duke, baron); + fixture.guild(duke, "merchants", "Eve"); + duke.addMember("Mia"); + Faction big = titled("big", "Bo", "big_title", "duchy"); + fixture.title("big_extra", "duchy"); + fixture.subject(duke, big); + + assertTrue(government.canProposePolitical(fixture.player("Carl"), Action.USURP)); + assertTrue(government.canProposePolitical(fixture.player("Bea"), Action.USURP)); + assertFalse(government.canProposePolitical(fixture.player("Cody"), Action.USURP), "vassal member"); + assertFalse(government.canProposePolitical(fixture.player("Eve"), Action.USURP), "guild leader"); + assertFalse(government.canProposePolitical(fixture.player("Mia"), Action.USURP), "citizen"); + assertFalse(government.canProposePolitical(fixture.player("Dana"), Action.USURP), "the ruler"); + duke.getTitles().clear(); + duke.updateTier(); + assertFalse(government.canProposePolitical(fixture.player("Carl"), Action.USURP), "nothing to take"); + } + + @Test + void usurpIsVassalOnlyAndMapsToTheUsurpCivilWarGoal() { + PoliticalAction usurp = new PoliticalAction(Action.USURP); + assertTrue(usurp.allowFactions()); + assertFalse(usurp.allowGuilds()); + assertFalse(usurp.allowCitizens()); + assertTrue(new PoliticalAction(Action.INDEPENDENCE).allowGuilds()); + assertEquals(WarGoalType.USURP, CivilWarGoalMapper.fromAction(Action.USURP)); + } + + @Test + void aPoliticalActionsFileWithoutUsurpStillOffersIt(@TempDir Path folder) throws Exception { + File file = folder.resolve("political-actions.yml").toFile(); + Files.writeString(file.toPath(), "independence:\n name: Independence\n pools: [guilds]\n"); + new PoliticalActionLoader().load(file); + assertFalse(PoliticalActionLoader.getByAction(Action.INDEPENDENCE).allowFactions()); + assertTrue(PoliticalActionLoader.getByAction(Action.USURP).allowFactions()); + assertFalse(PoliticalActionLoader.getByAction(Action.USURP).allowGuilds()); + } + + @Test + void aVassalCanNoLongerDeclareAUsurpWarOnItsOverlord() { + assertFalse(WarDeclareHelper.canDeclareUsurp(count, duke)); + var result = new WarGoalValidator().validate(WarDeclareRequest.of(count, duke, WarGoalType.USURP)); + assertFalse(result.isValid()); + assertEquals(WarGoalValidator.USURP_OVERLORD, result.getMessage()); + Faction stranger = titled("stranger", "Sam", "stranger_title", "county"); + assertTrue(WarDeclareHelper.canDeclareUsurp(stranger, duke)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/loaders/CatalogReloadCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/loaders/CatalogReloadCoverageTest.java index eb62aed8..fba8381a 100644 --- a/src/test/java/net/tfminecraft/simplefactions/loaders/CatalogReloadCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/loaders/CatalogReloadCoverageTest.java @@ -156,8 +156,9 @@ void politicalActionReloadReplacesOldActionsButRetainsTheNoneAction() throws Exc 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()); + // Usurp is supplied when a server's file predates it. + assertEquals(Set.of(Action.NONE, Action.SURRENDER, Action.USURP), PoliticalActionLoader.get().keySet()); + assertEquals(3, PoliticalActionLoader.getList().size()); assertNull(PoliticalActionLoader.getByAction(Action.CHANGE_LEADER)); Object original = PoliticalActionLoader.getByAction(Action.SURRENDER); Files.writeString(file, "SURRENDER: {}\ninvalid-action: {}\n"); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerCoverageTest.java index a40f7ab6..645779ae 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerCoverageTest.java @@ -1559,7 +1559,7 @@ void warDeclarationConfirmationKeepsItsOriginalRequestAndDecision(boolean confir 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); + when(manager.declareWarView.creator.createConfirmSummaryItem(request, false)).thenReturn(summary); manager.confirmWarDeclareView(player, request); Inventory inventory = player.getOpenInventory().getTopInventory(); assertSame(summary, inventory.getItem(13)); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/RelationMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/RelationMenusCoverageTest.java index dfffdab9..ab8fc35d 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/RelationMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/RelationMenusCoverageTest.java @@ -403,7 +403,7 @@ void attitudeSelectionUsesServiceResultAndOnlySuccessfulChangesReturnToDetails(b @ParameterizedTest @CsvSource({"leader,leader", "already,already in a war", "truce,truce", "opinion,opinion", "offline,online", "code,code", "goal,goal"}) - void warDeclarationRechecksLiveConditionsBeforePromptOrGoalSelection(String scenario, String expected) { + void warDeclarationRechecksLiveConditionsBeforeGoalSelection(String scenario, String expected) { type("war", "threshold.amount", -10); view.diplomacyView(null, player, target, true); if (scenario.equals("leader")) when(origin.getLeader()).thenReturn("NewLeader"); @@ -415,8 +415,11 @@ void warDeclarationRechecksLiveConditionsBeforePromptOrGoalSelection(String scen 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); + // A required code is only asked for at confirm, so leaders can browse the goals first. + if (scenario.equals("code") || scenario.equals("goal")) { + verify(manager).openDeclareWarGoalPicker(player, origin, target); + prompts.verifyNoInteractions(); + } else { prompts.verifyNoInteractions(); verify(manager, never()).openDeclareWarGoalPicker(any(), any(), any()); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/WarMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/WarMenusCoverageTest.java index d4c0a1aa..652d4f03 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/WarMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/WarMenusCoverageTest.java @@ -45,6 +45,7 @@ import net.tfminecraft.simplefactions.war.declare.OpenMarketEligibility; import net.tfminecraft.simplefactions.war.declare.PillageEligibility; import net.tfminecraft.simplefactions.war.declare.WarDeclareCodeService; +import net.tfminecraft.simplefactions.war.declare.DeclareCodePrompt; import net.tfminecraft.simplefactions.war.declare.WarDeclareRequest; import net.tfminecraft.simplefactions.war.declare.WarGoalValidator; import net.tfminecraft.simplefactions.war.declare.WarValidationResult; @@ -585,17 +586,17 @@ void eachWarGoalButtonKeepsItsGoalIdAndDescription() { Map> options = Map.of( WarGoalType.TRIBUTARY, - declaration.creator::createTributaryGoalItem, + () -> declaration.creator.createTributaryGoalItem(attacker, defender), WarGoalType.SUBJUGATE, declaration.creator::createSubjugateGoalItem, WarGoalType.USURP, - declaration.creator::createUsurpGoalItem, + () -> declaration.creator.createUsurpGoalItem(defender), WarGoalType.OPEN_MARKET, - declaration.creator::createOpenMarketGoalItem, + () -> declaration.creator.createOpenMarketGoalItem(defender), WarGoalType.CHANGE_GOVERNMENT, declaration.creator::createChangeGovernmentGoalItem, WarGoalType.PILLAGE, - declaration.creator::createPillageGoalItem, + () -> declaration.creator.createPillageGoalItem(null), WarGoalType.DE_JURE_ANNEX, declaration.creator::createDeJureGoalItem, WarGoalType.TRANSFER_SUBJECT, @@ -764,10 +765,12 @@ void mismatchingCodesAndCancellationNeverDeclareOrSpendTheCode() { try (MockedConstruction validators = validating(WarValidationResult.ok()); MockedStatic codes = mockStatic(WarDeclareCodeService.class)) { codes.when(() -> WarDeclareCodeService.isRequired(player)).thenReturn(true); - declaration.handleConfirm( - player, WarDeclareRequest.of(attacker, defender, WarGoalType.WAR), true); - verify(player).sendMessage("§cYour war code does not cover this declaration."); - codes.verify(() -> WarDeclareCodeService.clearSession(player)); + WarDeclareRequest request = WarDeclareRequest.of(attacker, defender, WarGoalType.WAR); + // Without a covering code, confirming asks for one instead of declaring. + try (MockedStatic prompts = mockStatic(DeclareCodePrompt.class)) { + declaration.handleConfirm(player, request, true); + prompts.verify(() -> DeclareCodePrompt.begin(player, request)); + } codes .when(() -> WarDeclareCodeService.session(player)) .thenReturn( @@ -1086,12 +1089,15 @@ void confirmationSummaryShowsTheResolvedTermsAndTarget(WarGoalType goal) { "republic", "elected", "town"); - ItemStack item = declaration.creator.createConfirmSummaryItem(request); + boolean asks = goal == WarGoalType.OPEN_MARKET; + ItemStack item = declaration.creator.createConfirmSummaryItem(request, asks); String text = lore(item); assertEquals("Declare War?", name(item)); assertTrue(text.contains("Target: defender")); assertTrue(text.contains("Goal: " + goal.getDisplayName())); assertTrue(text.contains("cannot be undone easily")); + assertTrue(text.contains("If you win:")); + assertEquals(asks, text.contains("Confirming asks for your staff war code.")); switch (goal) { case DE_JURE_ANNEX -> assertTrue(text.contains("Title: Defender Crown")); case SUBJUGATE -> @@ -1099,7 +1105,10 @@ void confirmationSummaryShowsTheResolvedTermsAndTarget(WarGoalType goal) { text.contains("Subject type: " + RelationLoader.getType("vassal").getName())); case TRANSFER_SUBJECT -> assertTrue(text.contains("Subject: attacker")); case USURP -> assertTrue(text.contains("Primary title: Defender Crown")); - case OPEN_MARKET -> assertTrue(text.contains("Law: republic")); + case OPEN_MARKET -> { + assertTrue(text.contains("Law: republic")); + assertTrue(lore(declaration.creator.createOpenMarketGoalItem(defender)).contains("Law: republic")); + } case CHANGE_GOVERNMENT -> { assertTrue(text.contains("Government: republic")); assertTrue(text.contains("Leadership: elected")); @@ -1112,13 +1121,22 @@ void confirmationSummaryShowsTheResolvedTermsAndTarget(WarGoalType goal) { } } + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void theOverviewShowsReparationsAndSaysWhenTheCodeIsAskedFor(boolean codeRequired) { + String text = lore(declaration.creator.createOverviewItem(attacker, defender, codeRequired)); + assertTrue(text.contains("If you win:")); + assertTrue(text.contains("If you lose:")); + assertEquals(codeRequired, text.contains("when you confirm the declaration.")); + } + @Test void relationTypeItemExplainsAnExistingLimitButPreservesTheSelectedType() { var type = RelationLoader.getType("vassal"); try (MockedStatic relations = mockStatic(RelationManager.class, CALLS_REAL_METHODS)) { relations.when(() -> RelationManager.atLimit(attacker, type)).thenReturn(true); - ItemStack item = declaration.creator.createRelationTypeItem(type, attacker); + ItemStack item = declaration.creator.createRelationTypeItem(type, attacker, defender); assertEquals("vassal", id(item)); assertTrue(lore(item).contains("At limit")); } diff --git a/src/test/java/net/tfminecraft/simplefactions/utils/LoreWriterCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/utils/LoreWriterCoverageTest.java index cfdc2db1..b014cfed 100644 --- a/src/test/java/net/tfminecraft/simplefactions/utils/LoreWriterCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/utils/LoreWriterCoverageTest.java @@ -130,6 +130,7 @@ void politicalDescriptionsExplainTheRequestedAction(Action action) { case DISSOLVE -> "Dissolves the faction"; case INDEPENDENCE -> "granted independence"; case NATIONHOOD -> "elevated to nationhood"; + case USURP -> "takes this faction's highest title"; case SNAP_ELECTIONS -> "voting lasts for a week"; default -> "Action:"; }; diff --git a/src/test/java/net/tfminecraft/simplefactions/war/core/WarDeclareHelperTest.java b/src/test/java/net/tfminecraft/simplefactions/war/core/WarDeclareHelperTest.java index 882fd45d..a09dad3f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/core/WarDeclareHelperTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/core/WarDeclareHelperTest.java @@ -108,6 +108,9 @@ void canDeclareUsurp_allowsIndependentWithTitleAndRank() { FactionManager.factions.add(attacker); FactionManager.factions.add(defender); + when(attacker.canHaveVassals()).thenReturn(false); + assertFalse(WarDeclareHelper.canDeclareUsurp(attacker, defender), "laws without vassals"); + when(attacker.canHaveVassals()).thenReturn(true); assertTrue(WarDeclareHelper.canDeclareUsurp(attacker, defender)); } @@ -133,7 +136,7 @@ void canDeclareUsurp_rejectsHigherRankAttacker() { } @Test - void canDeclareUsurp_allowsDirectOverlord() { + void canDeclareUsurp_refusesTheDirectOverlordWhichIsAMovement() { Faction defender = mockRankedFaction("liege", 4); withTitle(defender); Faction attacker = mockSubject("vassal", "liege"); @@ -142,7 +145,7 @@ void canDeclareUsurp_allowsDirectOverlord() { FactionManager.factions.add(attacker); FactionManager.factions.add(defender); - assertTrue(WarDeclareHelper.canDeclareUsurp(attacker, defender)); + assertFalse(WarDeclareHelper.canDeclareUsurp(attacker, defender)); } @Test @@ -186,6 +189,7 @@ private static Faction mockRankedFaction(String id, int tierLevel) { Faction faction = mockFaction(id); Tier tier = mockTier(tierLevel); when(faction.getTier()).thenReturn(tier); + when(faction.canHaveVassals()).thenReturn(true); return faction; } @@ -212,6 +216,7 @@ private static Faction mockSubject(String id, String overlordId) { relations.put(overlordId, relation); when(faction.getRelations()).thenReturn(relations); when(faction.getRelation(overlordId)).thenReturn(relation); + when(faction.canHaveVassals()).thenReturn(true); return faction; } } diff --git a/src/test/java/net/tfminecraft/simplefactions/war/declare/DeclareCodePromptCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/declare/DeclareCodePromptCoverageTest.java index ce3dc486..3ef69ece 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/declare/DeclareCodePromptCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/declare/DeclareCodePromptCoverageTest.java @@ -122,6 +122,10 @@ private static Map map(Class owner, String name) throws Excep return (Map) field(owner, name).get(null); } + private static WarDeclareRequest request(Faction attacker, Faction defender) { + return new WarDeclareRequest(attacker, defender, WarGoalType.TRIBUTARY, null, null); + } + private AsyncPlayerChatEvent submit(String code) { AsyncPlayerChatEvent event = new AsyncPlayerChatEvent(true, player, code, Set.of()); listener.onPlayerChat(event); @@ -130,9 +134,9 @@ private AsyncPlayerChatEvent submit(String code) { } @Test - void aValidCodeIsCheckedOffThreadAndOnlyOpensTheGoalPickerOnTheMainCallback() { + void aValidCodeIsCheckedOffThreadAndOnlyReopensTheConfirmationOnTheMainCallback() { fixture.provincesEnabled(true); - DeclareCodePrompt.begin(player, attacker, defender); + DeclareCodePrompt.begin(player, request(attacker, defender)); assertTrue(submit(" chosen-code ").isCancelled()); assertTrue(requests.isEmpty()); assertNull(WarDeclareCodeService.session(player)); @@ -148,21 +152,18 @@ void aValidCodeIsCheckedOffThreadAndOnlyOpensTheGoalPickerOnTheMainCallback() { assertEquals("chosen-code", WarDeclareCodeService.session(player).code); assertEquals("war_declare", requests.getFirst().get("kind").getAsString()); - // The code covers the pairing only; the leader still chooses the goal. - assertFalse(inventory.pendingWarDeclares.containsKey(player)); - verify(player).sendMessage("§aCode accepted. Choose your war goal."); - var holder = - (net.tfminecraft.simplefactions.managers.holder.DeclareWarHolder) - player.getOpenInventory().getTopInventory().getHolder(); - assertEquals(net.tfminecraft.simplefactions.enums.SFGUI.WAR_DECLARE_GOAL, holder.getStep()); - assertEquals("defender", holder.getDefenderId()); + // The leader already chose the goal; the accepted code brings them back to confirm it. + verify(player).sendMessage("§aCode accepted. Confirm to declare the war."); + WarDeclareRequest pending = inventory.pendingWarDeclares.get(player); + assertEquals(WarGoalType.TRIBUTARY, pending.getGoal()); + assertEquals(defender, pending.getDefender()); } @Test void anEarlierPromptTimeoutCannotCancelItsReplacement() { - DeclareCodePrompt.begin(player, attacker, defender); + DeclareCodePrompt.begin(player, request(attacker, defender)); Timed oldTimeout = timed.getLast(); - DeclareCodePrompt.begin(player, attacker, alternative); + DeclareCodePrompt.begin(player, request(attacker, alternative)); clearInvocations(player); oldTimeout.callback().run(); @@ -176,10 +177,10 @@ void anEarlierPromptTimeoutCannotCancelItsReplacement() { @Test void anOlderGatewayReplyCannotReplaceTheAcceptedNewerPairing() { - DeclareCodePrompt.begin(player, attacker, defender); + DeclareCodePrompt.begin(player, request(attacker, defender)); assertTrue(submit("old-code").isCancelled()); Runnable oldRequest = fixture.ui.asyncTasks.removeFirst(); - DeclareCodePrompt.begin(player, attacker, alternative); + DeclareCodePrompt.begin(player, request(attacker, alternative)); assertTrue(submit("new-code").isCancelled()); fixture.ui.asyncTasks.removeFirst().run(); fixture.ui.runTasks(); @@ -195,10 +196,10 @@ void anOlderGatewayReplyCannotReplaceTheAcceptedNewerPairing() { @Test void cancellingAReplacementAlsoInvalidatesTheOlderInFlightCode() { - DeclareCodePrompt.begin(player, attacker, defender); + DeclareCodePrompt.begin(player, request(attacker, defender)); assertTrue(submit("old-code").isCancelled()); Runnable oldRequest = fixture.ui.asyncTasks.removeFirst(); - DeclareCodePrompt.begin(player, attacker, alternative); + DeclareCodePrompt.begin(player, request(attacker, alternative)); assertTrue(submit("cancel").isCancelled()); verify(player).sendMessage("§7War declaration cancelled"); @@ -211,9 +212,8 @@ void cancellingAReplacementAlsoInvalidatesTheOlderInFlightCode() { @Test void absentInputsDoNotPromptAndOrdinaryChatRemainsPublic() { - DeclareCodePrompt.begin(null, attacker, defender); - DeclareCodePrompt.begin(player, null, defender); - DeclareCodePrompt.begin(player, attacker, null); + DeclareCodePrompt.begin(null, request(attacker, defender)); + DeclareCodePrompt.begin(player, null); assertTrue(timed.isEmpty()); assertFalse(submit("ordinary chat").isCancelled()); @@ -224,7 +224,7 @@ void absentInputsDoNotPromptAndOrdinaryChatRemainsPublic() { @ParameterizedTest @ValueSource(strings = {" ", " CaNcEl "}) void blankAndCancelConsumeOnlyThePromptWithoutContactingTheGateway(String input) { - DeclareCodePrompt.begin(player, attacker, defender); + DeclareCodePrompt.begin(player, request(attacker, defender)); assertTrue(submit(input).isCancelled()); verify(player) .sendMessage( @@ -242,7 +242,7 @@ void blankAndCancelConsumeOnlyThePromptWithoutContactingTheGateway(String input) @ParameterizedTest @ValueSource(booleans = {true, false}) void anUnsubmittedPromptExpiresAndOnlyNotifiesAnOnlinePlayer(boolean online) { - DeclareCodePrompt.begin(player, attacker, defender); + DeclareCodePrompt.begin(player, request(attacker, defender)); assertEquals(1200L, timed.getFirst().ticks()); when(player.isOnline()).thenReturn(online); @@ -255,7 +255,7 @@ void anUnsubmittedPromptExpiresAndOnlyNotifiesAnOnlinePlayer(boolean online) { @Test void aSubmittedCodeSurvivesTheInputDeadlineAndDoesNotCaptureUnrelatedChat() { - DeclareCodePrompt.begin(player, attacker, defender); + DeclareCodePrompt.begin(player, request(attacker, defender)); Timed inputDeadline = timed.getFirst(); assertTrue(submit("valid-code").isCancelled()); assertFalse(submit("still chatting").isCancelled()); @@ -276,7 +276,7 @@ void aSubmittedCodeSurvivesTheInputDeadlineAndDoesNotCaptureUnrelatedChat() { @ValueSource(booleans = {true, false}) void aGatewayTimeoutConsumesTheAttemptAndRejectsItsLateReply(boolean online) { Cache.warDeclareCodeTimeoutSeconds = 0; - DeclareCodePrompt.begin(player, attacker, defender); + DeclareCodePrompt.begin(player, request(attacker, defender)); assertTrue(submit("slow-code").isCancelled()); Timed serviceDeadline = timed.getLast(); assertEquals(20L, serviceDeadline.ticks()); @@ -295,7 +295,7 @@ void aGatewayTimeoutConsumesTheAttemptAndRejectsItsLateReply(boolean online) { @Test void anOfflinePlayerDoesNotReceiveOrOpenAGatewayResult() { - DeclareCodePrompt.begin(player, attacker, defender); + DeclareCodePrompt.begin(player, request(attacker, defender)); assertTrue(submit("valid-code").isCancelled()); fixture.ui.asyncTasks.removeFirst().run(); when(player.isOnline()).thenReturn(false); @@ -311,7 +311,7 @@ void anOfflinePlayerDoesNotReceiveOrOpenAGatewayResult() { @Test void rejectedCodesReportTheBackendReasonWithoutGrantingADeclarationSession() { response = GatewayClient.Result.fail("This code has expired"); - DeclareCodePrompt.begin(player, attacker, defender); + DeclareCodePrompt.begin(player, request(attacker, defender)); assertTrue(submit("expired-code").isCancelled()); fixture.ui.asyncTasks.removeFirst().run(); fixture.ui.runTasks(); @@ -325,7 +325,7 @@ void rejectedCodesReportTheBackendReasonWithoutGrantingADeclarationSession() { @ParameterizedTest @ValueSource(booleans = {true, false}) void deletingEitherFactionDuringValidationDoesNotOpenAStaleConfirmation(boolean deleteAttacker) { - DeclareCodePrompt.begin(player, attacker, defender); + DeclareCodePrompt.begin(player, request(attacker, defender)); assertTrue(submit("valid-code").isCancelled()); FactionManager.factions.remove(deleteAttacker ? attacker : defender); fixture.ui.asyncTasks.removeFirst().run(); @@ -338,7 +338,7 @@ void deletingEitherFactionDuringValidationDoesNotOpenAStaleConfirmation(boolean @Test void leavingInvalidatesInFlightValidationEvenBeforeTheOnlineFlagChanges() { - DeclareCodePrompt.begin(player, attacker, defender); + DeclareCodePrompt.begin(player, request(attacker, defender)); assertTrue(submit("valid-code").isCancelled()); WarDeclareCodeService.openSession( player, @@ -361,7 +361,7 @@ void startingANewPromptClearsPreviouslyAcceptedCodes() { new WarDeclareCodeService.Session( "previous", attacker.getId(), defender.getId())); - DeclareCodePrompt.begin(player, attacker, alternative); + DeclareCodePrompt.begin(player, request(attacker, alternative)); assertNull(WarDeclareCodeService.session(player)); assertTrue(submit("cancel").isCancelled()); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalPreviewTest.java b/src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalPreviewTest.java new file mode 100644 index 00000000..758554cc --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalPreviewTest.java @@ -0,0 +1,132 @@ +package net.tfminecraft.simplefactions.war.declare; + +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.database.FactionData; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.TradeBreakdown; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.settlement.Settlement; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class WarGoalPreviewTest { + private FactionDomainFixture fixture; + private double previousPercent; + private int previousDays; + private int previousLootDays; + private Faction attacker; + private Faction defender; + + @BeforeEach + void setup() { + fixture = new FactionDomainFixture(); + previousPercent = Cache.warReparationsIncomePercent; + previousDays = Cache.warReparationsDays; + previousLootDays = Cache.pillageLootDays; + Cache.warReparationsIncomePercent = 25; + Cache.warReparationsDays = 10; + Cache.pillageLootDays = 10; + attacker = fixture.saved("attacker", "Alice"); + defender = fixture.saved("defender", "Bob"); + } + + @AfterEach + void cleanup() { + Cache.warReparationsIncomePercent = previousPercent; + Cache.warReparationsDays = previousDays; + Cache.pillageLootDays = previousLootDays; + if (fixture != null) fixture.close(); + } + + private static void income(Guild guild, double amount) { + TradeBreakdown trade = new TradeBreakdown(); + trade.setIncome(amount); + guild.setTradeBreakdown(trade); + } + + @Test + void reparationsCountTheLoserAndItsSubjectsAtTodaysTradeIncome() { + Faction vassal = fixture.saved("vassal", "Cara"); + fixture.subject(defender, vassal); + income(defender.getOrCreateMainGuild(), 300); + income(vassal.getOrCreateMainGuild(), 100); + income(fixture.guild(defender, "debtors", "Dee"), -50); + income(attacker.getOrCreateMainGuild(), 40); + + assertEquals(100.0, WarGoalPreview.dailyReparations(defender), 1e-9); + assertEquals(10.0, WarGoalPreview.dailyReparations(attacker), 1e-9); + String text = String.join("\n", WarGoalPreview.reparations(attacker, defender)); + assertTrue(text.contains("25% of their trade income for 10 days"), text); + assertTrue(text.contains("about §e100.00d §7a day, §e1000.00d §7in total"), text); + assertTrue(text.contains("the same share (about §e10.00d §7a day)"), text); + Cache.warReparationsIncomePercent = 12.5; + assertTrue(String.join("\n", WarGoalPreview.reparations(attacker, defender)).contains("12.5%")); + } + + @Test + void subjectEffectsShowWhatEachSideGetsAndNothingForAPlainType() { + var tributary = + fixture.relationType( + "tributary", + Map.of("give-modifiers", List.of("levy(25.0)"), "recieve-modifiers", List.of("prestige(5.0)"))); + String text = String.join("\n", WarGoalPreview.subjectEffects(tributary, attacker, defender)); + assertTrue(text.startsWith("§7You get:"), text); + assertTrue(text.contains("§7They get:"), text); + assertEquals(4, WarGoalPreview.subjectEffects(tributary, attacker, defender).size()); + assertTrue(WarGoalPreview.subjectEffects(fixture.relationType("plain", Map.of("name", "Plain")), attacker, defender).isEmpty()); + assertTrue(WarGoalPreview.subjectEffects(null, attacker, defender).isEmpty()); + } + + @Test + void usurpNamesTheTitleAndTheSubjectsThatChangeHands() { + fixture.title("crown", "duchy"); + FactionData data = fixture.data("king", "Kai"); + data.titles.add("crown"); + Faction king = fixture.saved(data); + assertEquals(List.of("§7Title: §fcrown", "§7They become your subject"), WarGoalPreview.usurp(king)); + fixture.subject(king, fixture.saved("one", "One")); + assertTrue(WarGoalPreview.usurp(king).contains("§7Subjects that become yours: §f1")); + fixture.subject(king, fixture.saved("two", "Two")); + assertTrue(WarGoalPreview.usurp(king).contains("§7Subjects that become yours: §f2")); + assertEquals(List.of("§7They become your subject"), WarGoalPreview.usurp(attacker)); + } + + @Test + void pillageShowsLootFromTheGuildsInTheSettlementAndPicksTheRichestEligibleOne() { + Guild rich = fixture.guild(defender, "rich", "Rae"); + rich.setCapital(5, false); + when(fixture.provinces.getIncome(rich, false)).thenReturn(30.0); + Guild poor = fixture.guild(defender, "poor", "Pat"); + poor.setCapital(6, false); + when(fixture.provinces.getIncome(poor, false)).thenReturn(7.0); + Settlement richTown = settlement("Richtown", 5); + Settlement poorTown = settlement("Poortown", 6); + + String text = String.join("\n", WarGoalPreview.pillage(richTown)); + assertTrue(text.contains("Loot: §e300.00d"), text); + assertTrue(text.contains("from 1 guild)"), text); + assertTrue(text.contains("lose 100%"), text); + assertTrue(String.join("\n", WarGoalPreview.pillage(settlement("Empty", 9))).contains("from 0 guilds)")); + + var options = + List.of( + new PillageEligibility.PillageSettlementOption(poorTown, true, null), + new PillageEligibility.PillageSettlementOption(richTown, false, "§cToo far")); + assertEquals("§7Best loot: §e70.00d §7from Poortown", WarGoalPreview.bestPillage(options)); + assertNull(WarGoalPreview.bestPillage(List.of(options.get(1)))); + } + + private static Settlement settlement(String name, int province) { + Settlement settlement = mock(Settlement.class); + when(settlement.getName()).thenReturn(name); + when(settlement.contains(province)).thenReturn(true); + return settlement; + } +} 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 5fa9fbcd..566b5f67 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidatorTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidatorTest.java @@ -465,7 +465,7 @@ void secondDemand_isNotRefusedByATruce() { } @Test - void usurp_rejectsRebellingAgainstTheOverlordDuringATruce() { + void usurp_sendsTheOverlordCaseToAMovementEvenDuringATruce() { Faction king = mockFactionWithTitle("king", 5); when(king.getName()).thenReturn("Kingdom"); Faction duke = mockSubject("duke", "king"); @@ -475,7 +475,7 @@ void usurp_rejectsRebellingAgainstTheOverlordDuringATruce() { WarValidationResult result = validator.validate(WarDeclareRequest.of(duke, king, WarGoalType.USURP)); assertFalse(result.isValid()); - assertTrue(result.getMessage().startsWith("§cYou have a truce with Kingdom §cfor another "), result.getMessage()); + assertEquals(WarGoalValidator.USURP_OVERLORD, result.getMessage()); } @Test @@ -825,18 +825,24 @@ void usurp_allowsIndependent() { FactionManager.factions.add(attacker); FactionManager.factions.add(defender); + when(attacker.canHaveVassals()).thenReturn(false); + assertEquals("§cYour laws do not allow vassals, so you cannot usurp.", + validator.validate(WarDeclareRequest.of(attacker, defender, WarGoalType.USURP)).getMessage()); + when(attacker.canHaveVassals()).thenReturn(true); assertTrue(validator.validate(WarDeclareRequest.of(attacker, defender, WarGoalType.USURP)).isValid()); } @Test - void usurp_allowsDirectOverlord() { + void usurp_sendsAVassalToAMovementAgainstItsDirectOverlord() { Faction defender = mockFactionWithTitle("liege", 4); Faction attacker = mockSubject("vassal", "liege"); when(attacker.getTier().getTier()).thenReturn(3); FactionManager.factions.add(attacker); FactionManager.factions.add(defender); - assertTrue(validator.validate(WarDeclareRequest.of(attacker, defender, WarGoalType.USURP)).isValid()); + WarValidationResult result = validator.validate(WarDeclareRequest.of(attacker, defender, WarGoalType.USURP)); + assertFalse(result.isValid()); + assertEquals(WarGoalValidator.USURP_OVERLORD, result.getMessage()); } @Test diff --git a/src/test/java/net/tfminecraft/simplefactions/war/resolution/WarOutcomeServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/resolution/WarOutcomeServiceTest.java index b705dd62..c6a2abea 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/resolution/WarOutcomeServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/resolution/WarOutcomeServiceTest.java @@ -553,6 +553,24 @@ void attackerVictory_movementOrigin_appliesGateWithWarSource() { assertTrue(fx.payerObligations.isEmpty()); } + @Test + void attackerVictory_usurpMovement_appliesTheMovementInsteadOfTheWarLeadersUsurp() { + Fixture fx = fixture(); + Movement movement = mock(Movement.class); + fx.war.setGoal(WarGoalType.USURP); + try (MockedStatic factions = mockStatic(FactionManager.class); + MockedStatic gate = mockStatic(MovementOutcomeService.class)) { + factions.when(() -> FactionManager.getMovementById("mov-1")).thenReturn(movement); + fx.war.setMovementId("mov-1"); + WarOutcomeService.apply(fx.war, WarEndReason.ATTACKER_VICTORY); + gate.verify(() -> MovementOutcomeService.apply(movement, MovementOutcomeSource.WAR)); + factions.verify(() -> FactionManager.usurp(any(), any(), any()), never()); + fx.war.setMovementId(" "); + WarOutcomeService.apply(fx.war, WarEndReason.ATTACKER_VICTORY); + factions.verify(() -> FactionManager.usurp(isNull(), eq(fx.attacker), eq(fx.defender))); + } + } + @Test void attackerVictory_movementOrigin_missingMovement_doesNotApply() { Fixture fx = fixture();