From 1de1e1604f9e63aab97c09b970cada24b1c40d4c Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:10:27 +0000 Subject: [PATCH 1/2] feat: usurp an overlord through a movement, and preview war goals before the code A vassal no longer declares a usurp war on its own overlord. It starts a Usurp movement in the overlord's government instead: only direct vassal leaders who could take the title may lead it, the overlord can cave in, and otherwise it becomes a civil war with the Usurp goal. Winning lets the cause leader's faction take the overlord's highest title. The declare war button now opens the goal picker straight away. Leaders can see every available goal and what it wins (reparations at today's income, pillage loot per settlement, subject effects, usurped title and subjects) before asking staff for a code. The code is asked for when they confirm, and the confirmation reopens once it is accepted. Co-Authored-By: Claude Opus 5.5 --- .../simplefactions/government/Government.java | 2 + .../government/movement/Action.java | 1 + .../government/movement/PoliticalAction.java | 5 +- .../government/movement/UsurpCause.java | 50 +++++ .../government/proposal/Proposal.java | 3 + .../loaders/PoliticalActionLoader.java | 2 + .../managers/InventoryManager.java | 2 +- .../managers/inventory/DeclareWarCreator.java | 64 +++++-- .../managers/inventory/DeclareWarView.java | 43 +++-- .../managers/inventory/RelationView.java | 10 +- .../simplefactions/objects/Faction.java | 4 + .../simplefactions/utils/LoreWriter.java | 3 + .../war/civilwar/CivilWarGoalMapper.java | 1 + .../war/core/WarDeclareHelper.java | 9 +- .../war/declare/DeclareCodePrompt.java | 20 +- .../war/declare/WarGoalPreview.java | 123 ++++++++++++ .../war/declare/WarGoalValidator.java | 9 +- .../war/resolution/PillageApplyService.java | 10 + .../war/resolution/WarOutcomeService.java | 9 +- src/main/resources/political-actions.yml | 8 + src/main/resources/war.yml | 4 +- .../coalition/CoalitionViewTest.java | 4 +- .../government/UsurpMovementTest.java | 181 ++++++++++++++++++ .../loaders/CatalogReloadCoverageTest.java | 5 +- .../InventoryManagerCoverageTest.java | 2 +- .../inventory/RelationMenusCoverageTest.java | 9 +- .../inventory/WarMenusCoverageTest.java | 40 ++-- .../utils/LoreWriterCoverageTest.java | 1 + .../war/core/WarDeclareHelperTest.java | 4 +- .../DeclareCodePromptCoverageTest.java | 56 +++--- .../war/declare/WarGoalPreviewTest.java | 132 +++++++++++++ .../war/declare/WarGoalValidatorTest.java | 10 +- .../war/resolution/WarOutcomeServiceTest.java | 18 ++ 33 files changed, 730 insertions(+), 114 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/government/movement/UsurpCause.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalPreview.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/government/UsurpMovementTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalPreviewTest.java 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..f1ad997c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/PoliticalAction.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/PoliticalAction.java @@ -40,7 +40,10 @@ public PoliticalAction(Action action) { this.icon = "v.paper"; this.name = action.getDisplay(); this.description = new ArrayList<>(); - this.pools = Arrays.asList("citizens", "guilds", "factions"); + // 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..27689eef --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/UsurpCause.java @@ -0,0 +1,50 @@ +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. */ + public static boolean canUsurp(Faction vassal, Faction overlord) { + if (vassal == null || overlord == null || overlord.getHighestTitle() == null) { + 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..d3d6fcce 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/core/WarDeclareHelper.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/core/WarDeclareHelper.java @@ -83,14 +83,11 @@ public static boolean canDeclareUsurp(Faction attacker, Faction defender) { 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..59571132 --- /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("§7Their §f" + subjects + " §7" + (subjects == 1 ? "subject becomes" : "subjects become") + " yours"); + } + 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..222af2f3 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); 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..8d695bc6 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/government/UsurpMovementTest.java @@ -0,0 +1,181 @@ +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.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))); + 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"); + } + + @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..d2c4eeba 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/core/WarDeclareHelperTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/core/WarDeclareHelperTest.java @@ -133,7 +133,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 +142,7 @@ void canDeclareUsurp_allowsDirectOverlord() { FactionManager.factions.add(attacker); FactionManager.factions.add(defender); - assertTrue(WarDeclareHelper.canDeclareUsurp(attacker, defender)); + assertFalse(WarDeclareHelper.canDeclareUsurp(attacker, defender)); } @Test 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..fdf6d409 --- /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("§7Their §f1 §7subject becomes yours")); + fixture.subject(king, fixture.saved("two", "Two")); + assertTrue(WarGoalPreview.usurp(king).contains("§7Their §f2 §7subjects become yours")); + 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..bea1c998 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 @@ -829,14 +829,16 @@ void usurp_allowsIndependent() { } @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(); From 15be51d94a663b4f6a9311ae6649d05ae76072c9 Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:34:17 +0000 Subject: [PATCH 2/2] fix: only a faction allowed vassals can usurp Usurping makes the old overlord the usurper's subject and hands over its subjects. A usurper whose laws forbid vassals took the title but left the old overlord independent and kept its subjects in place, found while testing on Dev. The movement, the war goal and its validator now require a vassalage law. The default Usurp action carries its description for servers whose political-actions.yml predates it, and the usurp preview line no longer loses a space in the client. Co-Authored-By: Claude Opus 5.5 --- .../government/movement/PoliticalAction.java | 5 ++++- .../simplefactions/government/movement/UsurpCause.java | 7 +++++-- .../simplefactions/war/core/WarDeclareHelper.java | 2 +- .../simplefactions/war/declare/WarGoalPreview.java | 2 +- .../simplefactions/war/declare/WarGoalValidator.java | 4 ++++ .../simplefactions/government/UsurpMovementTest.java | 5 +++++ .../simplefactions/war/core/WarDeclareHelperTest.java | 5 +++++ .../simplefactions/war/declare/WarGoalPreviewTest.java | 4 ++-- .../simplefactions/war/declare/WarGoalValidatorTest.java | 4 ++++ 9 files changed, 31 insertions(+), 7 deletions(-) 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 f1ad997c..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,7 +39,10 @@ public PoliticalAction(Action action) { this.action = action; this.icon = "v.paper"; this.name = action.getDisplay(); - this.description = new ArrayList<>(); + 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") diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/UsurpCause.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/UsurpCause.java index 27689eef..bf448fba 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/movement/UsurpCause.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/UsurpCause.java @@ -13,9 +13,12 @@ public final class UsurpCause { private UsurpCause() {} - /** True when {@code vassal} is a direct subject of {@code overlord} and may take its title. */ + /** + * 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) { + if (vassal == null || overlord == null || overlord.getHighestTitle() == null || !vassal.canHaveVassals()) { return false; } if (!RelationManager.isOverlord(vassal, overlord)) { 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 d3d6fcce..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,7 +77,7 @@ 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())) { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalPreview.java b/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalPreview.java index 59571132..1c4c7697 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalPreview.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalPreview.java @@ -79,7 +79,7 @@ public static List usurp(Faction defender) { } int subjects = RelationManager.getSubjects(defender).size(); if (subjects > 0) { - lines.add("§7Their §f" + subjects + " §7" + (subjects == 1 ? "subject becomes" : "subjects become") + " yours"); + lines.add("§7Subjects that become yours: §f" + subjects); } lines.add("§7They become your subject"); return lines; 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 222af2f3..f48cc92c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidator.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidator.java @@ -196,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/test/java/net/tfminecraft/simplefactions/government/UsurpMovementTest.java b/src/test/java/net/tfminecraft/simplefactions/government/UsurpMovementTest.java index 8d695bc6..039ac47c 100644 --- a/src/test/java/net/tfminecraft/simplefactions/government/UsurpMovementTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/government/UsurpMovementTest.java @@ -8,6 +8,7 @@ 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; @@ -47,6 +48,7 @@ void setup() { 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); @@ -97,6 +99,9 @@ void onlyADirectVassalOfEqualOrLowerRankCanUsurpATitledOverlord() { 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 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 d2c4eeba..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)); } @@ -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/WarGoalPreviewTest.java b/src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalPreviewTest.java index fdf6d409..758554cc 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalPreviewTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalPreviewTest.java @@ -92,9 +92,9 @@ void usurpNamesTheTitleAndTheSubjectsThatChangeHands() { 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("§7Their §f1 §7subject becomes yours")); + assertTrue(WarGoalPreview.usurp(king).contains("§7Subjects that become yours: §f1")); fixture.subject(king, fixture.saved("two", "Two")); - assertTrue(WarGoalPreview.usurp(king).contains("§7Their §f2 §7subjects become yours")); + assertTrue(WarGoalPreview.usurp(king).contains("§7Subjects that become yours: §f2")); assertEquals(List.of("§7They become your subject"), WarGoalPreview.usurp(attacker)); } 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 bea1c998..566b5f67 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidatorTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidatorTest.java @@ -825,6 +825,10 @@ 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()); }