From 6461b2d4cfd9e7672d20261c5a1235e1dadc5183 Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Wed, 11 Nov 2026 08:57:51 +0000 Subject: [PATCH] Track war losses as wounds and settle them when the war ends Battles no longer shrink regiments as they end. Each campaign battle side records what every force brought (faction regiments, subjects' levies including militia, a subject's whole militia on its own province, and mercenaries on the roster) and its deaths. The deaths become wounds: shared between forces by lives brought, militia first inside a force, then law and paid slots in proportion. Law slots are full after every battle; wounded paid slots cannot fight, cost no upkeep and are lost when the war ends. Mercenary companies heal a percentage every few hours and never lose slots. Each faction has one army over all its wars, and recruiting during a war reinforces it. Each war keeps a ledger of its battles, so staff can fix losses with /war admin losses, /war admin army and /war admin wounds, and set a campaign battle's starting lives with /battle lives before it starts. The war commitment snapshot is gone. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 + .../net/tfminecraft/simplefactions/Cache.java | 2 + .../simplefactions/army/Military.java | 7 +- .../simplefactions/army/Regiment.java | 9 +- .../database/BattleSideData.java | 2 + .../database/CommitmentData.java | 9 - .../database/MercenaryCompanyData.java | 4 + .../simplefactions/database/WarData.java | 3 +- .../economy/settlement/FactionUpkeepPlan.java | 17 +- .../espionage/EspionageService.java | 6 +- .../simplefactions/loaders/ConfigLoader.java | 3 + .../managers/InventoryManager.java | 5 + .../managers/RelationManager.java | 1 - .../simplefactions/managers/WarManager.java | 17 +- .../managers/inventory/MilitaryCreator.java | 7 +- .../mercenary/company/MercenaryCompany.java | 20 + .../contract/MercenaryEngagements.java | 12 + .../contract/MercenaryLoyaltyWatcher.java | 3 +- .../CampaignBattleOutcomeService.java | 3 +- .../war/battle/engine/core/BattleSide.java | 25 + .../engine/core/BattleSideSetupService.java | 2 +- .../engine/core/BattleStaffService.java | 57 +- .../military/BattleCasualtyService.java | 447 ++----------- .../war/battle/military/BattleForces.java | 311 +++++++++ .../battle/military/BattleLivesService.java | 132 ++-- .../battle/military/BattlePoolService.java | 149 ----- .../war/battle/persistence/BattleMapper.java | 4 + .../war/battle/ui/BattleCommandManager.java | 4 +- .../war/battle/ui/BattleInventoryManager.java | 10 +- .../war/battle/ui/BattleLivesEditor.java | 12 +- .../war/battle/ui/BattleStaffCommands.java | 5 +- .../war/battle/ui/BattleTabCompletion.java | 2 +- .../CampaignCapabilityService.java | 6 +- .../runtime/BattleAutoresolveService.java | 15 +- .../campaign/ui/CampaignBattleIconLore.java | 3 +- .../commitment/LevySnapshotCalculator.java | 177 ----- .../war/commitment/WarCommitmentService.java | 297 --------- .../war/core/CallToArmsService.java | 3 - .../simplefactions/war/core/War.java | 10 + .../war/core/WarCommandManager.java | 17 +- .../war/core/WarCommitment.java | 18 - .../war/core/WarDebugFormatter.java | 24 +- .../simplefactions/war/core/WarDevMode.java | 6 +- .../simplefactions/war/core/WarMapper.java | 48 +- .../war/core/WarTabCompletion.java | 65 ++ .../war/wounds/BattleForce.java | 47 ++ .../war/wounds/CompanyWounds.java | 88 +++ .../simplefactions/war/wounds/ForceKind.java | 13 + .../simplefactions/war/wounds/ForcePart.java | 16 + .../war/wounds/WarArmyAdmin.java | 191 ++++++ .../simplefactions/war/wounds/WarLedger.java | 217 ++++++ .../simplefactions/war/wounds/WarWounds.java | 327 +++++++++ .../simplefactions/war/wounds/Wound.java | 11 + .../war/wounds/WoundAllocator.java | 101 +++ src/main/resources/war.yml | 10 +- .../RealmRelationsBoundaryCoverageTest.java | 81 +-- .../settlement/FactionUpkeepWoundsTest.java | 44 ++ .../InventoryManagerCoverageTest.java | 26 + .../managers/WarManagerCoverageTest.java | 76 +-- .../managers/WarManagerStartCivilWarTest.java | 3 - .../inventory/ArmyMenusCoverageTest.java | 12 + .../MercenaryCompanyPersistenceTest.java | 26 + .../contract/MercenaryLivesTest.java | 55 +- .../contract/MercenaryRosterTest.java | 21 +- .../war/actions/WarActionApplierTest.java | 13 - .../CampaignBattleJoinServiceTest.java | 50 +- .../CampaignBattleOutcomeServiceTest.java | 2 +- .../CampaignBattleRosterServiceTest.java | 80 ++- .../campaign/WarbandCampaignSignupTest.java | 28 +- .../CampaignWarbandSignupServiceTest.java | 40 +- .../BattleEngineListenersCoverageTest.java | 11 +- .../engine/core/BattleJoinServiceTest.java | 10 +- .../engine/core/BattleStaffServiceTest.java | 34 + ...aignBattleRuntimeBoundaryCoverageTest.java | 4 +- .../BattleCasualtyLifecycleCoverageTest.java | 619 ------------------ .../military/BattleCasualtyServiceTest.java | 578 ++++------------ .../war/battle/military/BattleForcesTest.java | 471 +++++++++++++ .../military/BattleLivesServiceTest.java | 135 +++- .../BattleMilitaryBoundaryCoverageTest.java | 78 +-- .../military/BattlePoolServiceTest.java | 360 ---------- .../battle/ui/BattleCommandsCoverageTest.java | 4 +- .../ui/BattleInventoryCoverageTest.java | 4 +- .../war/battle/ui/BattleStaffUiTest.java | 8 +- .../BattleRosterBoundaryCoverageTest.java | 8 +- .../admin/WarScheduleAdminServiceTest.java | 24 +- .../CampaignCapabilityServiceTest.java | 14 +- .../BattleAutoresolveCoverageTest.java | 43 +- .../runtime/BattleAutoresolveServiceTest.java | 19 +- .../runtime/BattleScheduleServiceTest.java | 24 +- .../BattleScheduleTickServiceTest.java | 24 +- .../CivilWarLifecycleCoverageTest.java | 15 - .../LevySnapshotCalculatorTest.java | 190 ------ .../war/core/CallToArmsServiceTest.java | 16 - .../war/core/WarCommandsCoverageTest.java | 75 ++- .../war/core/WarCommitmentTest.java | 416 ------------ .../war/core/WarDataRoundTripTest.java | 50 +- .../war/core/WarDebugFormatterTest.java | 42 +- .../war/core/WarDevModeTest.java | 10 +- .../war/core/WarPersistenceCoverageTest.java | 32 +- .../war/history/WarHistoryEventsTest.java | 13 - .../PillageMissingBankRegressionTest.java | 12 +- .../war/wounds/CompanyWoundsTest.java | 105 +++ .../simplefactions/war/wounds/TestForces.java | 48 ++ .../war/wounds/WarArmyAdminTest.java | 168 +++++ .../war/wounds/WarLedgerTest.java | 91 +++ .../war/wounds/WarWoundsTest.java | 270 ++++++++ .../war/wounds/WoundAllocatorTest.java | 84 +++ .../war/wounds/WoundFixture.java | 104 +++ 108 files changed, 3915 insertions(+), 3859 deletions(-) delete mode 100644 src/main/java/net/tfminecraft/simplefactions/database/CommitmentData.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattleForces.java delete mode 100644 src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattlePoolService.java delete mode 100644 src/main/java/net/tfminecraft/simplefactions/war/commitment/LevySnapshotCalculator.java delete mode 100644 src/main/java/net/tfminecraft/simplefactions/war/commitment/WarCommitmentService.java delete mode 100644 src/main/java/net/tfminecraft/simplefactions/war/core/WarCommitment.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/war/wounds/BattleForce.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/war/wounds/CompanyWounds.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/war/wounds/ForceKind.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/war/wounds/ForcePart.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/war/wounds/WarArmyAdmin.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/war/wounds/WarLedger.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/war/wounds/WarWounds.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/war/wounds/Wound.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/war/wounds/WoundAllocator.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepWoundsTest.java delete mode 100644 src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleCasualtyLifecycleCoverageTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleForcesTest.java delete mode 100644 src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattlePoolServiceTest.java delete mode 100644 src/test/java/net/tfminecraft/simplefactions/war/commitment/LevySnapshotCalculatorTest.java delete mode 100644 src/test/java/net/tfminecraft/simplefactions/war/core/WarCommitmentTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/war/wounds/CompanyWoundsTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/war/wounds/TestForces.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/war/wounds/WarArmyAdminTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/war/wounds/WarLedgerTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/war/wounds/WarWoundsTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/war/wounds/WoundAllocatorTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/war/wounds/WoundFixture.java diff --git a/README.md b/README.md index e54b27bb..1d6edeb7 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ Players shape a world of provinces and titles, organise settlements and guilds, A field battle with capture points ends when a side runs out of lives, retreats, or wins by control. Each second, the side holding more points than any other gains `per_second` × its share of the points, and holding every point multiplies that by `all_points_multiplier`. A point counts as held at 50% capture. The first side to `target` wins. The settings are under `battle.control` in `config.yml` (defaults: 1000, 0.5 and 4, so holding 2 of 3 points takes about 50 minutes and holding all of them about 8). Sieges keep their contest timer. +Campaign battles wear armies down through wounds. When a field battle or siege starts, every force on a side puts lives into the side's shared pool: each fighting faction's regiments allowed in that battle, each non-fighting subject's levy (its levy share of every regiment, militia included; all of its militia when the battle is on its own province), and each hired mercenary company's members on the roster. When it ends, the side's deaths become wounds: split between forces by the lives each brought, militia first inside a force, then law and paid slots in proportion. No force loses more than it brought. Law-granted slots are full again after every battle. Wounded paid slots cannot fight and cost no upkeep, and they are lost when the war ends. Mercenary companies never lose slots; their wounds heal by `mercenary_heal_percent` of the company every `mercenary_heal_hours`. A faction has one army for all its wars, and recruiting during a war reinforces it. The settings are under `war.battle_military` in `war.yml`. + +Each war keeps a ledger of its battles, so staff can correct losses at any time before it ends. `/war admin losses ` lists the battles and what the war's end will take, and `/war admin losses attacker|defender set|add|remove [reason]` corrects a battle's deaths. `/war admin army ` shows each regiment's slots, wounds per war and what can fight. `/war admin wounds add|remove [reason]` and `/war admin wounds company [set ]` change wounds directly. Before a campaign battle starts, `/battle lives set|add|remove ` sets the lives that side starts with, and `auto` goes back to what its forces give. + Use `/faction findvehicles personal` to locate your current character's vehicles. Faction leaders can also use `/faction findvehicles pool` or `/faction findvehicles `. Tab completion suggests the available diff --git a/src/main/java/net/tfminecraft/simplefactions/Cache.java b/src/main/java/net/tfminecraft/simplefactions/Cache.java index 5dee11c3..ee99f5a9 100644 --- a/src/main/java/net/tfminecraft/simplefactions/Cache.java +++ b/src/main/java/net/tfminecraft/simplefactions/Cache.java @@ -134,6 +134,8 @@ public static boolean requireProvinces(org.bukkit.command.CommandSender sender) public static int warBattleLivesPerRegiment; public static int warBattleMinSideLives; public static int warBattleDeathsPerRegimentLoss; + public static double warMercenaryHealPercent = 25; + public static double warMercenaryHealHours = 6; public static BattleLootMode battleLootMode; public static List battleLootCommands = new ArrayList<>(); public static String battleLootItemPath; diff --git a/src/main/java/net/tfminecraft/simplefactions/army/Military.java b/src/main/java/net/tfminecraft/simplefactions/army/Military.java index 449bd705..b22a1968 100644 --- a/src/main/java/net/tfminecraft/simplefactions/army/Military.java +++ b/src/main/java/net/tfminecraft/simplefactions/army/Military.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.army; +import net.tfminecraft.simplefactions.war.wounds.WarWounds; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -57,7 +58,7 @@ public double getTotalUpkeep() { public double getRawTotalUpkeep() { double d = 0; for(Regiment r : regiments) { - d += r.getTotalUpkeep(); + d += r.getTotalUpkeep(WarWounds.woundedSlots(f, r)); } return format.formatDouble(d); } @@ -246,7 +247,7 @@ public int getManpower(boolean offense) { manpower += e.getAmount(); } } else { - manpower += r.getCurrentSlots(); + manpower += WarWounds.availableSlots(f, r); } } return manpower; @@ -258,7 +259,7 @@ public int getManpowerNoLevy(boolean offense) { if(r.isEquipment()) continue; if(offense && !r.isOffensive()) continue; if(r.isLevy()) continue; - manpower += r.getCurrentSlots(); + manpower += WarWounds.availableSlots(f, r); } return manpower; } diff --git a/src/main/java/net/tfminecraft/simplefactions/army/Regiment.java b/src/main/java/net/tfminecraft/simplefactions/army/Regiment.java index 169a6577..8403574b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/army/Regiment.java +++ b/src/main/java/net/tfminecraft/simplefactions/army/Regiment.java @@ -196,11 +196,10 @@ public void sizeDecrease() { currentSlots--; } - public double getTotalUpkeep() { - double d = 0; - int paidSlots = currentSlots-freeSlots; + /** Upkeep for the paid slots that are not wounded; wounded slots cost nothing until they are lost. */ + public double getTotalUpkeep(int woundedSlots) { + int paidSlots = currentSlots - freeSlots - Math.max(0, woundedSlots); if(paidSlots < 1) return 0; - d += paidSlots*upkeep; - return format.formatDouble(d); + return format.formatDouble(paidSlots*upkeep); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/database/BattleSideData.java b/src/main/java/net/tfminecraft/simplefactions/database/BattleSideData.java index 240b373f..361ff25d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/BattleSideData.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/BattleSideData.java @@ -14,6 +14,8 @@ public class BattleSideData { public int maxLives; // Absent in saves from before the start was recorded. public Integer startLives; + public List forces; + public Integer livesOverride; public String lifeType; public List warbandIds = new ArrayList<>(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/database/CommitmentData.java b/src/main/java/net/tfminecraft/simplefactions/database/CommitmentData.java deleted file mode 100644 index 4ae84a42..00000000 --- a/src/main/java/net/tfminecraft/simplefactions/database/CommitmentData.java +++ /dev/null @@ -1,9 +0,0 @@ -package net.tfminecraft.simplefactions.database; - -public class CommitmentData { - public String factionId; - public String sourceFactionId; - public String regimentId; - public int count; - public String committedAt; -} diff --git a/src/main/java/net/tfminecraft/simplefactions/database/MercenaryCompanyData.java b/src/main/java/net/tfminecraft/simplefactions/database/MercenaryCompanyData.java index e7d73e87..5e4d4767 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/MercenaryCompanyData.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/MercenaryCompanyData.java @@ -25,4 +25,8 @@ public class MercenaryCompanyData { public Map peacetimeWageOverrides = new LinkedHashMap<>(); /** Wages accrued but not yet settled, keyed by player name. */ public Map pendingWages = new LinkedHashMap<>(); + /** Wounded lives from battles; absent when the company is unwounded. */ + public Integer woundLives; + /** When the stored wounds started healing. */ + public String woundsHealFrom; } diff --git a/src/main/java/net/tfminecraft/simplefactions/database/WarData.java b/src/main/java/net/tfminecraft/simplefactions/database/WarData.java index f5c640c0..4c1e43bc 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/WarData.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/WarData.java @@ -106,7 +106,8 @@ public class WarData { public String endReason; public SideData attackers; public SideData defenders; - public List commitments; + /** The war's battle ledger; absent in saves from before wounds. */ + public net.tfminecraft.simplefactions.war.wounds.WarLedger.Data ledger; public WarData() { campaignProvinces = new ArrayList<>(); diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepPlan.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepPlan.java index 2eb777ee..615eec57 100644 --- a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepPlan.java +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepPlan.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.economy.settlement; +import net.tfminecraft.simplefactions.war.wounds.WarWounds; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; @@ -41,19 +42,23 @@ public static FactionUpkeepPlan calculate(Faction faction, double available) { boolean bank = SettlementGuilds.capital(faction).getBank() != null; double wealth = bank ? available : 0; double factor = 1 + faction.getModifier(FactionModifiers.MILITARY_UPKEEP).getAmount() / 100; - double army = armyCost(regiments, factor); + double army = armyCost(faction, regiments, factor); + // Unpaid upkeep only disbands slots that cost something: wounded slots are free and stay + // until the war ends, so their wounds never move onto new recruits. if (!bank && army > 0) { for (Regiment regiment : regiments) { - if (!regiment.isLevy()) regiment.setCurrentSlots(Math.min(regiment.getCurrentSlots(), regiment.getFreeSlots())); + if (!regiment.isLevy()) regiment.setCurrentSlots(Math.min(regiment.getCurrentSlots(), + regiment.getFreeSlots() + WarWounds.woundedSlots(faction, regiment))); } army = 0; } int guard = 0; while (army > 0 && wealth < army && guard++ < 100000) { - Regiment shrink = regiments.stream().filter(r -> !r.isLevy() && r.getCurrentSlots() > r.getFreeSlots()).findFirst().orElse(null); + Regiment shrink = regiments.stream().filter(r -> !r.isLevy() + && r.getCurrentSlots() > r.getFreeSlots() + WarWounds.woundedSlots(faction, r)).findFirst().orElse(null); if (shrink == null) break; shrink.sizeDecrease(); - army = armyCost(regiments, factor); + army = armyCost(faction, regiments, factor); } long charge = bank && army > 0 ? Money.cents(army) : 0; wealth -= Money.denars(charge); @@ -98,9 +103,9 @@ private static FactionUpkeepPlan paused(Faction faction) { faction.getInstallationHandler().serializeConstruction(), 0, 0, 0); } - private static double armyCost(List regiments, double factor) { + private static double armyCost(Faction faction, List regiments, double factor) { double raw = 0; - for (Regiment regiment : regiments) raw += regiment.getTotalUpkeep(); + for (Regiment regiment : regiments) raw += regiment.getTotalUpkeep(WarWounds.woundedSlots(faction, regiment)); return Formatter.formatDouble(Formatter.formatDouble(raw) * factor); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java index fe4eb46f..24c8e540 100644 --- a/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java +++ b/src/main/java/net/tfminecraft/simplefactions/espionage/EspionageService.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.espionage; +import net.tfminecraft.simplefactions.war.wounds.WarWounds; import net.tfminecraft.simplefactions.identity.Members; import java.time.LocalDate; @@ -682,7 +683,7 @@ private static void ledgerAndForces(Map values, Faction faction, values.put("Daily net income", main.getLedger().getNetIncome()); values.put("Professional army", (double) faction.getMilitary().getRegiments().stream() .filter(regiment -> regiment.isProfessional() && !regiment.isEquipment()) - .mapToInt(regiment -> regiment.getCurrentSlots()).sum()); + .mapToInt(regiment -> WarWounds.availableSlots(faction, regiment)).sum()); values.put("Levies", (double) (faction.getMilitary().getManpower(false) - faction.getMilitary().getManpowerNoLevy(false))); values.put("Mercenaries", (double) faction.getMilitary().getMercenaryManpower()); values.put("Army", values.get("Professional army") + values.get("Levies") + values.get("Mercenaries")); @@ -706,7 +707,8 @@ private static void ledgerAndForces(Map values, Faction faction, } for (var regiment : faction.getMilitary().getRegiments()) values.put("Regiment:" + regiment.getId() + (regiment.isLevy() ? ":Levies" : ":Soldiers"), (double) (regiment.isLevy() - ? regiment.getEntries().stream().mapToInt(net.tfminecraft.simplefactions.army.LevyEntry::getAmount).sum() : regiment.getCurrentSlots())); + ? regiment.getEntries().stream().mapToInt(net.tfminecraft.simplefactions.army.LevyEntry::getAmount).sum() + : WarWounds.availableSlots(faction, regiment))); for (var installation : faction.getInstallationHandler().getAll()) values.put("Installation:" + installation.getId() + ":Level", (double) installation.getLevel()); if (faction.getGovernment() != null) { values.put("Stability", faction.getGovernment().getStability()); diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java index 0c11bc7b..2a570f6e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java @@ -298,6 +298,9 @@ public void loadWar(File warFile) { Cache.warBattleMinSideLives = config.getInt("war.battle_military.min_side_lives", 1); int deathsPerRegiment = config.getInt("war.battle_military.deaths_per_regiment_loss", 5); Cache.warBattleDeathsPerRegimentLoss = deathsPerRegiment > 0 ? deathsPerRegiment : 5; + Cache.warMercenaryHealPercent = Math.max(0, config.getDouble("war.battle_military.mercenary_heal_percent", 25)); + double healHours = config.getDouble("war.battle_military.mercenary_heal_hours", 6); + Cache.warMercenaryHealHours = healHours > 0 ? healHours : 6; Cache.battleLootMode = BattleLootMode.fromJson(config.getString("war.battle_loot.mode", "ITEM")); if (Cache.battleLootMode == null) { Cache.battleLootMode = BattleLootMode.COMMAND; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java index d40dc3cf..50301a58 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.managers; +import net.tfminecraft.simplefactions.war.wounds.WarWounds; import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.espionage.EspionageAccess; @@ -1565,6 +1566,10 @@ public void clickButton(InventoryClickEvent e) { if(f == null || !Members.is(p, f.getLeader())) return; Regiment r = f.getMilitary().getRegiment(data); if(r == null) return; + if(WarWounds.availablePaidSlots(f, r) <= 0 && WarWounds.woundedSlots(f, r) > 0) { + p.sendMessage("§cWounded slots cannot be disbanded. They are lost when the war ends."); + return; + } r.sizeDecrease(); p.sendMessage("§cDecreased size of "+r.getName()); p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java index 54a80fe2..52e3d19b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/RelationManager.java @@ -52,7 +52,6 @@ public static void reset(Faction origin, Faction target, boolean hostile) { public static boolean endVassalage(Faction origin, Faction target, boolean hostile) { if(isOverlord(origin, target) || isOverlord(target, origin)) { - net.tfminecraft.simplefactions.war.commitment.WarCommitmentService.onVassalageEnded(origin, target); reset(origin, target, hostile); net.tfminecraft.simplefactions.espionage.EspionageService.forgetReports(origin, target); MercenaryLoyaltyWatcher.onRelationChanged(origin, target); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/WarManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/WarManager.java index d68c5954..c6b0534d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/WarManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/WarManager.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.managers; +import net.tfminecraft.simplefactions.war.wounds.WarWounds; import net.tfminecraft.simplefactions.identity.Members; import java.time.Instant; @@ -25,7 +26,6 @@ import net.tfminecraft.simplefactions.war.notice.WarNotices; import net.tfminecraft.simplefactions.war.core.Side; import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.core.WarCommitment; import net.tfminecraft.simplefactions.war.core.WarDeclareHelper; import net.tfminecraft.simplefactions.war.campaign.runtime.BattleSideMembers; import net.tfminecraft.simplefactions.war.campaign.runtime.pick.BattleInstallationPickService; @@ -34,7 +34,6 @@ import net.tfminecraft.simplefactions.war.campaign.WarCampaignService; import net.tfminecraft.simplefactions.war.campaign.buildup.BuildupService; import net.tfminecraft.simplefactions.war.campaign.runtime.CampaignClock; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; import net.tfminecraft.simplefactions.war.enums.WarEndReason; import net.tfminecraft.simplefactions.war.enums.WarGoalType; import net.tfminecraft.simplefactions.war.enums.WarType; @@ -161,7 +160,6 @@ public static War declareWar( lastDeclareError = navy.getMessage(); return null; } - WarCommitmentService.commitAllParticipants(war); addWar(war); logWarDeclared(war, attacker, defender); CoalitionWars.announce(war); @@ -259,7 +257,6 @@ public static War startCivilWar( // Register the snapshot before refreshing, so the military recognizes the active rebellion. wars.add(war); refreshTempRebelSlots(war); - WarCommitmentService.commitAllParticipants(war); announceAndPersist(war); logWarDeclared(war, attacker, defender); return war; @@ -322,14 +319,6 @@ private static ProvinceManager resolveProvinceManager() { return plugin.getProvinceManager(); } - public static List getCommitmentsForWar(int warId) { - return WarCommitmentService.getCommitmentsForWar(warId); - } - - static void clearCommitments(int warId) { - WarCommitmentService.clearCommitments(warId); - } - // Keep the existing legacy text representation, formatting, and exact-string comparisons. public static War addWar(War w) { wars.add(w); @@ -452,6 +441,9 @@ public static void endWar(War w, WarEndReason reason) { TruceService.onWarEnded(w, reason); FactionManager.getMap().enqueueOccupationFromWar(w); WartimeInstallationService.revert(w); + // Every wounded paid slot of this war is lost now, once, while civil war rebels still hold + // the regiments they fought with. + WarWounds.settle(w); CivilWarUntangleService.restore(w, reason); WarOutcomeService.apply(w, reason); WarCombatTeardownService.teardownCombatForWar(w); @@ -460,7 +452,6 @@ public static void endWar(War w, WarEndReason reason) { WarHistoryEvents.ended(w, reason); BattleInstallationPickService.clearForNewBattleDay(w); CampaignRaidService.clearForNewBattleDay(w); - clearCommitments(w.getId()); for (War war : wars) { if (war.getId() == w.getId()) { wars.remove(war); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryCreator.java index b5591e4e..9f2c18c1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/MilitaryCreator.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.war.wounds.WarWounds; import net.tfminecraft.simplefactions.war.freeze.PreparationFreeze; import java.util.ArrayList; import java.util.List; @@ -85,11 +86,15 @@ public ItemStack createRegimentIcon(Faction f, Regiment r) { } else { lore.add("§7Current Slots: §e"+r.getCurrentSlots()); } + int wounded = WarWounds.woundedSlots(f, r); + if(wounded > 0) { + lore.add("§cWounded: "+wounded+" §7(cannot fight, lost when the war ends)"); + } if(r.sentToOverlord() > 0) { lore.add(StringFormatter.formatHex("#877e7c("+r.sentToOverlord()+" sent as levies)")); } double mod = 1.0 + f.getModifier(FactionModifiers.MILITARY_UPKEEP).getAmount()/100.0; - lore.add("§7Current Upkeep: §e"+Formatter.formatMoney(r.getTotalUpkeep()*mod)+"d §7("+Formatter.formatMoney(r.getUpkeep()*mod)+"d per slot)"); + lore.add("§7Current Upkeep: §e"+Formatter.formatMoney(r.getTotalUpkeep(wounded)*mod)+"d §7("+Formatter.formatMoney(r.getUpkeep()*mod)+"d per slot)"); } else { int total = 0; for(LevyEntry e : r.getEntries()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java index 6d2116f9..36912a34 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.mercenary.company; +import net.tfminecraft.simplefactions.war.wounds.CompanyWounds; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; @@ -62,6 +63,8 @@ public class MercenaryCompany { * before the daily tick that pays it out. */ private final Map pendingWages = new LinkedHashMap<>(); + /** Wounds from battles, shared by every contract; they heal over time. */ + private CompanyWounds wounds = new CompanyWounds(); public MercenaryCompany(Guild guild, String name, Regiment regiment, int formationSeconds) { this.guild = guild; @@ -126,6 +129,10 @@ public MercenaryCompany(Guild guild, MercenaryCompanyData data, Regiment regimen accrueWage(e.getKey(), e.getValue() == null ? 0 : e.getValue()); } } + if (data.woundLives != null && data.woundLives > 0) { + wounds = new CompanyWounds(data.woundLives, + data.woundsHealFrom == null ? java.time.Instant.EPOCH : java.time.Instant.parse(data.woundsHealFrom)); + } if (data.slotQueue != null && regiment != null) { for (String entry : data.slotQueue) { if (entry == null) continue; @@ -254,6 +261,15 @@ public int getFilledSlots() { return Math.min(enlisted.size(), getSlots()); } + public CompanyWounds getWounds() { + return wounds; + } + + /** Slots not wounded right now; only these bring lives to a battle. */ + public int getHealthySlots(java.time.Instant now) { + return wounds.healthySlots(now, getSlots()); + } + public boolean hasFreeSlot() { return enlisted.size() < getSlots(); } @@ -723,6 +739,10 @@ public MercenaryCompanyData serialize() { data.activeWageOverrides = new LinkedHashMap<>(wages.getActiveOverrides()); data.peacetimeWageOverrides = new LinkedHashMap<>(wages.getPeacetimeOverrides()); data.pendingWages = new LinkedHashMap<>(pendingWages); + if (wounds.storedLives() > 0) { + data.woundLives = wounds.storedLives(); + data.woundsHealFrom = wounds.healFrom().toString(); + } return data; } } diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryEngagements.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryEngagements.java index 947f46e3..5f4083f8 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryEngagements.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryEngagements.java @@ -124,6 +124,18 @@ public static int coveringMembers( return found; } + /** How many of the company's enlisted players are on this battle side's roster, whatever the contracts. */ + public static int membersOnRoster(MercenaryCompany company, BattleSide side) { + int found = 0; + for (String name : company.getEnlisted()) { + UUID id = uuids == null ? null : uuids.uuidOf(name); + if (id != null && isOnRoster(side, id)) { + found++; + } + } + return found; + } + private static boolean isOnRoster(BattleSide side, UUID id) { for (Warband band : side.getBands()) { if (band != null && band.hasMember(id)) return true; diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryLoyaltyWatcher.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryLoyaltyWatcher.java index 450a947d..75b19e08 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryLoyaltyWatcher.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryLoyaltyWatcher.java @@ -15,8 +15,7 @@ * counts secondaries that have already joined, so a contract that was legal at * signing turns illegal the moment an ally answers a call to arms, a vassalage * lands, or an election changes who sits in a government. There are no Bukkit - * events for any of the three, so the choke points call in here directly, the way - * {@code RelationManager.endVassalage} already calls {@code WarCommitmentService}. + * events for any of the three, so the choke points call in here directly. */ public final class MercenaryLoyaltyWatcher { private MercenaryLoyaltyWatcher() { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleOutcomeService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleOutcomeService.java index 0c2d9497..17855ffd 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleOutcomeService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleOutcomeService.java @@ -175,7 +175,8 @@ public static CampaignBattleApplyResult applyCampaignBattleOutcome( ? BattleCasualtyService.applyBattleCasualties( war, battle, - sideCasualties != null ? sideCasualties : Map.of()) + sideCasualties != null ? sideCasualties : Map.of(), + resolution) : BattleCasualtyTally.empty(); if (winnerRole != null) { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleSide.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleSide.java index 74643c1d..1ade2efe 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleSide.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleSide.java @@ -12,6 +12,7 @@ import net.tfminecraft.simplefactions.war.battle.enums.LifeType; import net.tfminecraft.simplefactions.war.battle.warband.Warband; +import net.tfminecraft.simplefactions.war.wounds.BattleForce; public class BattleSide { private String id; @@ -22,6 +23,10 @@ public class BattleSide { private int lives; private int maxLives; private Integer startLives; + /** What each force brought, recorded when a campaign battle starts; null before that. */ + private List forces; + /** Starting lives staff set before the battle, used instead of the computed ones. */ + private Integer livesOverride; private List respawnPoints = new ArrayList(); private Location jail; @@ -55,6 +60,26 @@ public int getMaxLives() { return maxLives; } + public boolean hasForces() { + return forces != null; + } + + public List getForces() { + return forces == null ? List.of() : forces; + } + + public void setForces(List forces) { + this.forces = forces == null ? null : List.copyOf(forces); + } + + public Integer getLivesOverride() { + return livesOverride; + } + + public void setLivesOverride(Integer livesOverride) { + this.livesOverride = livesOverride; + } + /** Lives when the battle started, or null for a battle that has not started since this was added. */ public Integer getStartLives() { return startLives; diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleSideSetupService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleSideSetupService.java index 9df25ad9..e589a979 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleSideSetupService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleSideSetupService.java @@ -42,7 +42,7 @@ public static void setJail(Battle battle, BattleSide side, Location location) { public static void setSideLives(Battle battle, BattleSide side, int lives) { if (battle != null && battle.getWarId() != null) { - throw new IllegalStateException("Campaign battle lives are computed from war commitment"); + throw new IllegalStateException("Campaign battle lives come from the forces of each side"); } if (side == null) { throw new IllegalArgumentException("Side is required"); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleStaffService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleStaffService.java index 3d3429d8..6152ec9c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleStaffService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleStaffService.java @@ -18,6 +18,7 @@ import net.tfminecraft.simplefactions.war.battle.enums.BattleEndReason; import net.tfminecraft.simplefactions.war.battle.enums.BattleType; import net.tfminecraft.simplefactions.war.battle.military.BattleCasualtyLedger; +import net.tfminecraft.simplefactions.war.battle.military.BattleLivesService; import net.tfminecraft.simplefactions.war.battle.persistence.BattlePersistenceService; import net.tfminecraft.simplefactions.war.battle.ui.BattleStaffNotices; import net.tfminecraft.simplefactions.war.battle.ui.BattleWatchService; @@ -152,10 +153,15 @@ public static boolean startsOnRelease(Battle battle) { && war.getScheduledBattleAt() != null && !war.getScheduledBattleAt().isAfter(CampaignClock.now()); } - /** Adds, removes or sets a side's lives during the battle. */ + /** + * Adds, removes or sets a side's lives. During the battle this changes the lives left. Before + * it starts it sets the lives the side starts with, instead of the ones its forces would give; + * {@code auto} goes back to those. Lives only limit respawns: deaths beyond what the forces + * brought wound nobody. + */ public static Result adjustLives(Battle battle, String sideId, String operation, int amount, String by) { - if (battle == null || !battle.hasStarted()) { - return Result.error("Lives can only be corrected while the battle runs. Use setlives before it starts."); + if (battle == null) { + return Result.error("No such battle."); } BattleSide side = battle.getSideById(sideId); if (side == null) { @@ -164,13 +170,12 @@ public static Result adjustLives(Battle battle, String sideId, String operation, if (amount < 0) { return Result.error("The amount must be 0 or more."); } + String op = operation.toLowerCase(Locale.ROOT); + if (!battle.hasStarted()) { + return adjustStartLives(battle, side, op, amount, by); + } int before = side.getLives(); - long after = switch (operation.toLowerCase(Locale.ROOT)) { - case "add" -> (long) before + amount; - case "remove" -> (long) before - amount; - case "set" -> amount; - default -> Long.MIN_VALUE; - }; + long after = newLives(op, before, amount); if (after == Long.MIN_VALUE) { return Result.error("Use add, remove or set."); } @@ -185,6 +190,40 @@ public static Result adjustLives(Battle battle, String sideId, String operation, + " from " + before + " to " + lives + "."); } + private static Result adjustStartLives(Battle battle, BattleSide side, String op, int amount, String by) { + War war = battle.getWarId() == null ? null : WarManager.getById(battle.getWarId()); + if (war == null) { + return Result.error("That battle is not running. Set its lives before it starts with /battle setlives."); + } + // The current starting lives: a staff value already set, or what the forces give. + int before = BattleLivesService.previewCampaignSideLives(war, battle, side.getId()).sideLives(); + if (op.equals("auto")) { + side.setLivesOverride(null); + BattlePersistenceService.persistBattle(battle); + int lives = BattleLivesService.previewCampaignSideLives(war, battle, side.getId()).sideLives(); + return done(battle, by, "START_LIVES", "put " + side.getId() + " back on the lives its forces give in " + + battle.getDisplayName() + " (" + lives + " now)."); + } + long after = newLives(op, before, amount); + if (after == Long.MIN_VALUE) { + return Result.error("Use add, remove, set or auto."); + } + int lives = (int) Math.max(0, Math.min(Integer.MAX_VALUE, after)); + side.setLivesOverride(lives); + BattlePersistenceService.persistBattle(battle); + return done(battle, by, "START_LIVES", "set " + side.getId() + " to start " + battle.getDisplayName() + + " with " + lives + " lives (was " + before + "). Use auto to go back to what its forces give."); + } + + private static long newLives(String op, int before, int amount) { + return switch (op) { + case "add" -> (long) before + amount; + case "remove" -> (long) before - amount; + case "set" -> amount; + default -> Long.MIN_VALUE; + }; + } + /** Emergency switch for a campaign battle: every vehicle can be used by either side. */ public static Result setAllVehiclesUsable(Battle battle, boolean on, String by) { if (battle == null || battle.getWarId() == null) { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattleCasualtyService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattleCasualtyService.java index c763ebeb..4ce8c075 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattleCasualtyService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattleCasualtyService.java @@ -1,426 +1,99 @@ package net.tfminecraft.simplefactions.war.battle.military; -import net.tfminecraft.simplefactions.identity.Members; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashSet; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Set; -import org.bukkit.Bukkit; import org.bukkit.entity.Player; -import net.tfminecraft.simplefactions.Cache; -import net.tfminecraft.simplefactions.army.Regiment; -import net.tfminecraft.simplefactions.database.Database; -import net.tfminecraft.simplefactions.managers.FactionManager; -import net.tfminecraft.simplefactions.managers.WarManager; -import net.tfminecraft.simplefactions.objects.Faction; -import net.tfminecraft.simplefactions.war.core.Side; -import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.core.WarCommitment; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.war.battle.campaign.CampaignBattleSides; import net.tfminecraft.simplefactions.war.battle.engine.core.Battle; +import net.tfminecraft.simplefactions.war.battle.engine.core.BattleSide; import net.tfminecraft.simplefactions.war.battle.enums.BattleType; import net.tfminecraft.simplefactions.war.battle.template.BattleTemplate; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; import net.tfminecraft.simplefactions.war.campaign.runtime.BattleSideMembers; - +import net.tfminecraft.simplefactions.war.core.Side; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.history.BattleResolution; +import net.tfminecraft.simplefactions.war.wounds.BattleForce; +import net.tfminecraft.simplefactions.war.wounds.WarLedger; +import net.tfminecraft.simplefactions.war.wounds.WarWounds; + +/** + * Puts a resolved field battle or siege in its war's ledger: what each force brought (recorded + * when the battle started, or read now for an autoresolve) and each side's deaths. The wounds + * follow from that; nothing is taken from a faction until the war ends. + */ public final class BattleCasualtyService { private BattleCasualtyService() {} - /** Applies the losses and returns, per faction and regiment, what was actually taken. */ - public static BattleCasualtyTally applyBattleCasualties(War war, Battle battle, Map sideCasualties) { - BattleCasualtyTally tally = BattleCasualtyTally.empty(); - if (!shouldApply(war, battle, sideCasualties)) { - return tally; - } - - Integer battleProvinceId = battle.getProvinceId() != null - ? battle.getProvinceId() - : war.getScheduledBattleProvinceId(); - if (battleProvinceId == null) { - return tally; - } - - int attackerRegimentLosses = regimentLossesForDeaths( - sideCasualties.getOrDefault(BattleTemplate.ATTACKER_SIDE, 0)); - int defenderRegimentLosses = regimentLossesForDeaths( - sideCasualties.getOrDefault(BattleTemplate.DEFENDER_SIDE, 0)); - if (attackerRegimentLosses <= 0 && defenderRegimentLosses <= 0) { - return tally; - } - - Set affectedFactions = new HashSet<>(); - Side attackerSide = CampaignBattleSides.warSideFor(war, battle, BattleTemplate.ATTACKER_SIDE); - Side defenderSide = CampaignBattleSides.warSideFor(war, battle, BattleTemplate.DEFENDER_SIDE); - int attackerLosses = applySide( - war, - battleProvinceId, - attackerSide, - attackerRegimentLosses, - new Debits(affectedFactions, tally, war, attackerSide)); - int defenderLosses = applySide( - war, - battleProvinceId, - defenderSide, - defenderRegimentLosses, - new Debits(affectedFactions, tally, war, defenderSide)); - - persistCommitments(war); - for (Faction faction : affectedFactions) { - if (faction != null) { - new Database().saveFaction(faction); + /** Records the battle and returns, per faction regiment, the slots it wounded. */ + public static BattleCasualtyTally applyBattleCasualties(War war, Battle battle, Map sideCasualties, + BattleResolution resolution) { + if (!shouldApply(war, battle)) { + return BattleCasualtyTally.empty(); + } + Integer provinceId = battle.getProvinceId() != null ? battle.getProvinceId() : war.getScheduledBattleProvinceId(); + WarLedger.SideRecord attacker = new WarLedger.SideRecord(0, List.of()); + WarLedger.SideRecord defender = new WarLedger.SideRecord(0, List.of()); + for (String battleSideId : List.of(BattleTemplate.ATTACKER_SIDE, BattleTemplate.DEFENDER_SIDE)) { + Side warSide = CampaignBattleSides.warSideFor(war, battle, battleSideId); + BattleSide battleSide = battle.getSideById(battleSideId); + List forces = battleSide != null && battleSide.hasForces() ? battleSide.getForces() + : BattleForces.build(war, provinceId, warSide, battle, battleSide); + int deaths = sideCasualties == null ? 0 : sideCasualties.getOrDefault(battleSideId, 0); + WarLedger.SideRecord record = new WarLedger.SideRecord(deaths, forces); + if (warSide == war.getAttackers()) { + attacker = record; + } else { + defender = record; } } - broadcastLosses(war, attackerLosses, defenderLosses); + BattleCasualtyTally tally = WarWounds.recordBattle(war, battle.getId(), resolution.toJson(), provinceId, + attacker, defender); + broadcast(war, tally); return tally; } - public static int regimentLossesForDeaths(int deaths) { - if (deaths <= 0) { - return 0; - } - return deaths / deathsPerRegimentLoss(); + /** Deaths that wound a side one regiment slot (5 when unset). */ + public static int deathsPerRegimentLoss() { + return WarWounds.deathsPerSlot(); } - /** Deaths that cost a side one regiment when a campaign battle ends (5 when unset). */ - public static int deathsPerRegimentLoss() { - int deathsPerRegiment = Cache.warBattleDeathsPerRegimentLoss; - return deathsPerRegiment <= 0 ? 5 : deathsPerRegiment; + public static int regimentLossesForDeaths(int deaths) { + return deaths <= 0 ? 0 : deaths / deathsPerRegimentLoss(); } - public static boolean shouldApply(War war, Battle battle, Map sideCasualties) { + public static boolean shouldApply(War war, Battle battle) { if (war == null || !war.isActive() || battle == null || battle.getWarId() == null) { return false; } BattleType type = battle.getBattleType(); - if (type == BattleType.RAID) { - return false; - } - if (type != BattleType.FIELD && type != BattleType.SIEGE) { - return false; - } - if (sideCasualties == null || sideCasualties.isEmpty()) { - return false; - } - for (int casualties : sideCasualties.values()) { - if (casualties > 0) { - return true; - } - } - return false; - } - - public static void persistCommitments(War war) { - if (war != null) { - WarManager.persist(war); - } + return type == BattleType.FIELD || type == BattleType.SIEGE; } - private static int applySide( - War war, - int battleProvinceId, - Side side, - int casualties, - Debits debits) { - if (casualties <= 0 || side == null) { - return 0; - } - - PoolMode mode = BattlePoolService.resolvePoolMode(war, battleProvinceId, side); - Map> eligible = BattlePoolService.eligibleRegiments( - war, - battleProvinceId, - side, - mode); - if (eligible.isEmpty()) { - return 0; - } - - int remaining = casualties; - - for (Map.Entry> factionEntry : eligible.entrySet()) { - String factionId = factionEntry.getKey(); - Map regimentCounts = factionEntry.getValue(); - Integer militiaCount = regimentCounts.get(BattlePoolService.MILITIA_REGIMENT_ID); - if (militiaCount == null) { - continue; - } - int take = Math.min(remaining, militiaCount); - debitOwnRegiment(war, factionId, BattlePoolService.MILITIA_REGIMENT_ID, take, debits); - remaining -= take; - if (remaining <= 0) { - return casualties; - } - } - - List targets = new ArrayList<>(); - for (Map.Entry> factionEntry : eligible.entrySet()) { - String factionId = factionEntry.getKey(); - for (Map.Entry regimentEntry : factionEntry.getValue().entrySet()) { - if (BattlePoolService.MILITIA_REGIMENT_ID.equalsIgnoreCase(regimentEntry.getKey())) { - continue; - } - int weight = regimentEntry.getValue(); - targets.add(new RegimentTarget(factionId, regimentEntry.getKey(), weight)); - } - } - - Map allocation = allocateProportionally(remaining, targets); - for (Map.Entry entry : allocation.entrySet()) { - RegimentTarget target = entry.getKey(); - int amount = entry.getValue(); - if (amount <= 0) { - continue; - } - remaining -= amount; - if (WarCommitment.LEVY_REGIMENT_ID.equalsIgnoreCase(target.regimentId())) { - debitLevyForHolder(war, target.factionId(), amount, debits); + private static void broadcast(War war, BattleCasualtyTally tally) { + int attacker = 0; + int defender = 0; + for (BattleCasualtyTally.Loss loss : tally.losses()) { + if (WarLedger.ATTACKER.equals(loss.side())) { + attacker += loss.count(); } else { - debitOwnRegiment(war, target.factionId(), target.regimentId(), amount, debits); + defender += loss.count(); } } - return casualties - remaining; - } - - private static void debitOwnRegiment( - War war, - String factionId, - String regimentId, - int amount, - Debits debits) { - WarCommitmentService.debitCount(war.getId(), factionId, null, regimentId, amount); - Faction faction = FactionManager.getByString(factionId); - if (faction == null || faction.getMilitary() == null) { + if (attacker <= 0 && defender <= 0) { return; } - Regiment regiment = faction.getMilitary().getRegiment(regimentId); - if (regiment == null) { - return; - } - for (int i = 0; i < amount; i++) { - regiment.sizeDecrease(); - } - debits.affected().add(faction); - debits.tally().add(faction, debits.side(), regiment, amount); - } - - private static void debitLevyForHolder( - War war, - String holderFactionId, - int amount, - Debits debits) { - List levyRows = new ArrayList<>(); - for (WarCommitment commitment : WarCommitmentService.getCommitmentsForWar(war.getId())) { - if (!commitment.isLevyRow()) { - continue; - } - if (!commitment.factionId().equalsIgnoreCase(holderFactionId)) { - continue; - } - if (commitment.count() <= 0) { - continue; - } - levyRows.add(new LevyRowTarget( - commitment.factionId(), - commitment.sourceFactionId(), - commitment.count())); - } - List rowTargets = new ArrayList<>(); - for (LevyRowTarget row : levyRows) { - rowTargets.add(new RegimentTarget(row.holderId(), row.sourceId(), row.weight())); - } - Map rowAllocation = allocateProportionally(amount, rowTargets); - for (Map.Entry entry : rowAllocation.entrySet()) { - RegimentTarget target = entry.getKey(); - int rowDebit = entry.getValue(); - if (rowDebit <= 0) { - continue; - } - debitLevyRow(war, target.factionId(), target.regimentId(), rowDebit, debits); - } - } - - private static void debitLevyRow( - War war, - String holderFactionId, - String sourceFactionId, - int amount, - Debits debits) { - WarCommitmentService.debitCount( - war.getId(), - holderFactionId, - sourceFactionId, - WarCommitment.LEVY_REGIMENT_ID, - amount); - debitSourceForLevy(war, sourceFactionId, amount, debits); - } - - private static void debitSourceForLevy( - War war, - String sourceFactionId, - int amount, - Debits debits) { - List sourceTargets = new ArrayList<>(); - for (WarCommitment commitment : WarCommitmentService.getCommitmentsForWar(war.getId())) { - if (commitment.isLevyRow()) { - continue; - } - if (commitment.factionId() == null || !commitment.factionId().equalsIgnoreCase(sourceFactionId)) { - continue; - } - if (commitment.count() <= 0) { - continue; - } - if (isEquipmentRegiment(sourceFactionId, commitment.regimentId())) { - continue; - } - sourceTargets.add(new RegimentTarget(sourceFactionId, commitment.regimentId(), commitment.count())); - } - Faction source = FactionManager.getByString(sourceFactionId); - if (sourceTargets.isEmpty() && source != null && source.getMilitary() != null) { - // A subject lending levies need not be a direct fighter with its own snapshot rows. - for (Regiment regiment : source.getMilitary().getRegiments()) { - if (regiment.isLevy() || regiment.isOffensive() || regiment.isEquipment()) continue; - int sent = Math.min(regiment.sentToOverlord(), regiment.getCurrentSlots()); - if (sent > 0) sourceTargets.add(new RegimentTarget(sourceFactionId, regiment.getId(), sent)); - } - } - if (sourceTargets.isEmpty()) return; - - Map allocation = allocateProportionally(amount, sourceTargets); - amount = allocation.values().stream().mapToInt(Integer::intValue).sum(); - for (Map.Entry entry : allocation.entrySet()) { - RegimentTarget target = entry.getKey(); - int debit = entry.getValue(); - if (debit <= 0) { - continue; - } - debitOwnRegiment(war, target.factionId(), target.regimentId(), debit, debits); - } - - if (source == null || source.getMilitary() == null) { - return; - } - for (Regiment regiment : source.getMilitary().getRegiments()) { - if (regiment.isLevy() || regiment.isOffensive() || regiment.isEquipment()) { - continue; - } - int sent = regiment.sentToOverlord(); - if (sent <= 0) { - continue; - } - int reduce = Math.min(amount, sent); - regiment.setSentToOverlord(sent - reduce); - amount -= reduce; - debits.affected().add(source); - if (amount <= 0) { - break; - } - } - } - - private static Map allocateProportionally(int total, List targets) { - if (total <= 0 || targets == null || targets.isEmpty()) { - return Map.of(); - } - long weightSum = 0; - for (RegimentTarget target : targets) { - weightSum += target.weight(); - } - - total = (int) Math.min((long) total, weightSum); - Map allocation = new LinkedHashMap<>(); - List remainders = new ArrayList<>(); - int assigned = 0; - for (RegimentTarget target : targets) { - double exact = (double) total * target.weight() / weightSum; - int floor = (int) Math.floor(exact); - allocation.put(target, floor); - assigned += floor; - remainders.add(new RemainderEntry(target, exact - floor)); - } - remainders.sort(Comparator.comparingDouble(RemainderEntry::remainder).reversed()); - int leftover = total - assigned; - for (int i = 0; i < leftover && i < remainders.size(); i++) { - RegimentTarget target = remainders.get(i).target(); - allocation.put(target, allocation.getOrDefault(target, 0) + 1); - } - return allocation; - } - - private static void broadcastLosses(War war, int attackerLosses, int defenderLosses) { - if (attackerLosses <= 0 && defenderLosses <= 0) { - return; - } - String message = "§7Campaign battle losses: attacker " - + attackerLosses - + ", defender " - + defenderLosses - + " regiments."; - for (String memberName : BattleSideMembers.collectEligibleMemberNames(war.getAttackers())) { - Player player = Members.player(memberName); - if (player != null && player.isOnline()) { - player.sendMessage(message); - } - } - for (String memberName : BattleSideMembers.collectEligibleMemberNames(war.getDefenders())) { - Player player = Members.player(memberName); - if (player != null && player.isOnline()) { - player.sendMessage(message); - } - } - } - - record RegimentTarget(String factionId, String regimentId, int weight) {} - - /** Who was hit while applying one side's losses, and which war side that is. */ - private record Debits(Set affected, BattleCasualtyTally tally, String side) { - Debits(Set affected, BattleCasualtyTally tally, War war, Side warSide) { - this(affected, tally, warSide == war.getAttackers() ? "attacker" : "defender"); - } - } - - private record LevyRowTarget(String holderId, String sourceId, int weight) {} - - private record RemainderEntry(RegimentTarget target, double remainder) {} - - private static boolean isEquipmentRegiment(String factionId, String regimentId) { - Faction faction = FactionManager.getByString(factionId); - if (faction == null || faction.getMilitary() == null || regimentId == null) { - return false; - } - Regiment regiment = faction.getMilitary().getRegiment(regimentId); - return regiment != null && regiment.isEquipment(); - } - - static int getCommitmentCount(int warId, String factionId, String sourceFactionId, String regimentId) { - for (WarCommitment commitment : WarCommitmentService.getCommitmentsForWar(warId)) { - if (!matchesCommitment(commitment, factionId, sourceFactionId, regimentId)) { - continue; + String message = "§7Campaign battle wounds: attacker " + attacker + ", defender " + defender + + " regiments. Wounded regiments cannot fight and are lost when the war ends."; + for (Side side : List.of(war.getAttackers(), war.getDefenders())) { + for (String memberName : BattleSideMembers.collectEligibleMemberNames(side)) { + Player player = Members.player(memberName); + if (player != null && player.isOnline()) { + player.sendMessage(message); + } } - return commitment.count(); - } - return 0; - } - - private static boolean matchesCommitment( - WarCommitment commitment, - String factionId, - String sourceFactionId, - String regimentId) { - if (commitment.factionId() == null || !commitment.factionId().equalsIgnoreCase(factionId)) { - return false; - } - if (!commitment.regimentId().equalsIgnoreCase(regimentId)) { - return false; - } - if (sourceFactionId == null) { - return commitment.sourceFactionId() == null; } - return sourceFactionId.equalsIgnoreCase(commitment.sourceFactionId()); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattleForces.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattleForces.java new file mode 100644 index 00000000..94a705b7 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattleForces.java @@ -0,0 +1,311 @@ +package net.tfminecraft.simplefactions.war.battle.military; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.enums.FactionModifiers; +import net.tfminecraft.simplefactions.inactivity.InactivityService; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.RelationManager; +import net.tfminecraft.simplefactions.managers.TitleManager; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.mercenary.contract.MercenaryEngagements; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.war.battle.engine.core.Battle; +import net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager; +import net.tfminecraft.simplefactions.war.battle.engine.core.BattleSide; +import net.tfminecraft.simplefactions.war.campaign.progression.CampaignCapabilityService; +import net.tfminecraft.simplefactions.war.campaign.progression.CampaignSideService; +import net.tfminecraft.simplefactions.war.campaign.progression.CampaignSideService.CampaignSideRole; +import net.tfminecraft.simplefactions.war.campaign.runtime.BattleSideMembers; +import net.tfminecraft.simplefactions.war.campaign.runtime.CampaignClock; +import net.tfminecraft.simplefactions.war.civilwar.wartime.CivilWarBorderLock; +import net.tfminecraft.simplefactions.war.core.Side; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.wounds.BattleForce; +import net.tfminecraft.simplefactions.war.wounds.ForceKind; +import net.tfminecraft.simplefactions.war.wounds.ForcePart; +import net.tfminecraft.simplefactions.war.wounds.WarWounds; +import net.tfminecraft.simplefactions.war.wounds.WoundAllocator; + +/** + * What every force on one war side brings to one battle, read live from the factions and + * companies when asked: + *
    + *
  • each fighting faction: its regiments allowed in this battle (offensive regiments to attack, + * all of them to defend, militia only to defend its own province);
  • + *
  • each non-fighting subject: a levy, the levy share of all its regiments including militia, + * lent to its nearest fighting overlord for any battle; when the battle is in the subject's own + * province, its whole militia defends instead;
  • + *
  • each hired company: the members on the roster, up to the contract and its healthy slots.
  • + *
+ * Wounded paid slots bring nothing. Law slots always come in full. + */ +public final class BattleForces { + public static final String MILITIA_REGIMENT_ID = "militia"; + + private BattleForces() {} + + public static PoolMode resolvePoolMode(War war, Side side) { + if (war == null || side == null) { + return PoolMode.DEFENSIVE; + } + CampaignSideRole offensive = CampaignCapabilityService.battleOffensiveCoalition(war); + CampaignSideRole sideCoalition = CampaignSideService.coalitionOf(war, side); + if (offensive == null || sideCoalition == null) { + return PoolMode.DEFENSIVE; + } + return sideCoalition == offensive ? PoolMode.OFFENSIVE : PoolMode.DEFENSIVE; + } + + /** Forces for a battle side in the war's current offensive; mercenaries only with a roster. */ + public static List build(War war, Integer provinceId, Side side, Battle battle, BattleSide roster) { + return build(war, provinceId, side, resolvePoolMode(war, side), battle, roster); + } + + public static List build( + War war, + Integer provinceId, + Side side, + PoolMode mode, + Battle battle, + BattleSide roster) { + List forces = new ArrayList<>(); + if (war == null || side == null || mode == null) { + return forces; + } + int livesPerSlot = Math.max(0, Cache.warBattleLivesPerRegiment); + List fighters = BattleSideMembers.collectParticipatingFactions(side); + Set fighterIds = new LinkedHashSet<>(); + for (Faction fighter : fighters) { + fighterIds.add(key(fighter.getId())); + } + boolean civilWar = CivilWarBorderLock.isCivilWar(war); + for (Faction fighter : fighters) { + List parts = new ArrayList<>(); + for (Regiment regiment : soldierRegiments(fighter)) { + if (isEligible(civilWar, regiment, fighter, provinceId, mode)) { + addPart(parts, part(regiment, WarWounds.availablePaidSlots(fighter, regiment))); + } + } + addForce(forces, ForceKind.REGIMENTS, fighter.getId(), livesPerSlot, parts); + } + Set sources = new LinkedHashSet<>(); + for (Faction fighter : fighters) { + collectNonFighterDescendants(fighter, fighterIds, sources); + } + for (Faction source : sources) { + Faction holder = findNearestFighterHolder(source, fighterIds); + if (holder == null) { + continue; + } + boolean home = defendsHome(civilWar, source, provinceId, mode); + if (home) { + Regiment militia = source.getMilitary().getRegiment(MILITIA_REGIMENT_ID); + List militiaParts = new ArrayList<>(); + if (militia != null) { + addPart(militiaParts, part(militia, WarWounds.availablePaidSlots(source, militia))); + } + addForce(forces, ForceKind.MILITIA, source.getId(), livesPerSlot, militiaParts); + } + addForce(forces, ForceKind.LEVY, source.getId(), livesPerSlot, levyParts(source, holder, home)); + } + if (roster != null) { + addMercenaries(forces, war, side, battle, roster, livesPerSlot); + } + return forces; + } + + /** Slots the side's own forces bring (no mercenaries), for whether it can attack or defend. */ + public static int regimentSlots(War war, Integer provinceId, Side side, PoolMode mode) { + return BattleForce.regimentSlots(build(war, provinceId, side, mode, null, null)); + } + + public static boolean isMilitiaEligible(Faction faction, Integer provinceId) { + if (faction == null || faction.getId() == null || provinceId == null) { + return false; + } + Faction owner = TitleManager.getByProvince(provinceId); + return owner != null && owner.getId().equalsIgnoreCase(faction.getId()); + } + + private static boolean isEligible(boolean civilWar, Regiment regiment, Faction faction, Integer provinceId, PoolMode mode) { + if (civilWar) { + return true; + } + if (MILITIA_REGIMENT_ID.equalsIgnoreCase(regiment.getId())) { + return mode == PoolMode.DEFENSIVE && isMilitiaEligible(faction, provinceId); + } + // Defence brings every regiment type; an attack only the offensive ones. + return mode == PoolMode.DEFENSIVE || regiment.isOffensive(); + } + + private static boolean defendsHome(boolean civilWar, Faction source, Integer provinceId, PoolMode mode) { + return (civilWar || mode == PoolMode.DEFENSIVE) && isMilitiaEligible(source, provinceId); + } + + /** + * The levy: its levy % of every available regiment (capped at its active members), then each + * overlord between it and the holder takes its own % of that again. Militia is included unless + * it is already defending its home. + */ + private static List levyParts(Faction source, Faction holder, boolean withoutMilitia) { + int memberCap = source.getMembers() == null ? 0 : InactivityService.activeOnly(source.getMembers()).size(); + double levyPercent = source.getModifier(FactionModifiers.LEVY).getAmount(); + Map counts = new LinkedHashMap<>(); + Map available = new HashMap<>(); + int total = 0; + for (Regiment regiment : soldierRegiments(source)) { + if (withoutMilitia && MILITIA_REGIMENT_ID.equalsIgnoreCase(regiment.getId())) { + continue; + } + int law = lawSlots(regiment); + int paid = WarWounds.availablePaidSlots(source, regiment); + int count = (int) Math.round((law + paid) * (levyPercent / 100.0)); + if (total + count >= memberCap) { + count = memberCap - total; + } + if (count <= 0) { + continue; + } + total += count; + counts.put(regiment, count); + available.put(regiment, new int[] { law, paid }); + } + Faction current = source; + while (!counts.isEmpty() && !sameId(current, holder)) { + Faction overlord = FactionManager.getByString(RelationManager.getOverlord(current)); + if (sameId(overlord, holder)) { + break; + } + double percent = overlord.getModifier(FactionModifiers.LEVY).getAmount(); + counts.replaceAll((regiment, count) -> (int) Math.round(count * (percent / 100.0))); + counts.values().removeIf(count -> count <= 0); + current = overlord; + } + List parts = new ArrayList<>(); + for (Map.Entry entry : counts.entrySet()) { + int[] slots = available.get(entry.getKey()); + long[] split = WoundAllocator.share(entry.getValue(), new long[] { slots[0], slots[1] }); + addPart(parts, new ForcePart(entry.getKey().getId(), + MILITIA_REGIMENT_ID.equalsIgnoreCase(entry.getKey().getId()), (int) split[0], (int) split[1])); + } + return parts; + } + + private static void addMercenaries(List forces, War war, Side side, Battle battle, BattleSide roster, + int livesPerSlot) { + Instant now = CampaignClock.now(); + // What each company can still bring here: its members on the roster, so one company hired by + // two allies is not counted twice, and its healthy slots not fighting another battle. + Map left = new LinkedHashMap<>(); + for (MercenaryEngagements.Engagement engagement : MercenaryEngagements.on(war, side)) { + MercenaryCompany company = engagement.company(); + int free = left.containsKey(company) ? left.get(company) + : Math.min(MercenaryEngagements.membersOnRoster(company, roster), + company.getHealthySlots(now) - slotsInOtherBattles(company, battle)); + int slots = Math.max(0, Math.min(MercenaryEngagements.coveringMembers(engagement, roster), free)); + left.put(company, free - slots); + if (slots > 0) { + forces.add(new BattleForce(ForceKind.MERCENARY, company.getGuild().getId(), livesPerSlot, + List.of(new ForcePart(company.getRegiment().getId(), false, 0, slots)))); + } + } + } + + /** A company's healthy slots already fighting in another running battle cannot fight here too. */ + private static int slotsInOtherBattles(MercenaryCompany company, Battle battle) { + String guildId = company.getGuild().getId(); + int slots = 0; + for (Battle other : BattleManager.get()) { + if (other == battle || !other.hasStarted()) { + continue; + } + for (BattleSide side : other.getSides()) { + for (BattleForce force : side.getForces()) { + if (force.kind() == ForceKind.MERCENARY && force.ownerId().equalsIgnoreCase(guildId)) { + slots += force.slots(); + } + } + } + } + return slots; + } + + private static ForcePart part(Regiment regiment, int availablePaid) { + return new ForcePart(regiment.getId(), MILITIA_REGIMENT_ID.equalsIgnoreCase(regiment.getId()), + lawSlots(regiment), availablePaid); + } + + private static int lawSlots(Regiment regiment) { + return Math.min(Math.max(0, regiment.getFreeSlots()), regiment.getCurrentSlots()); + } + + private static void addPart(List parts, ForcePart part) { + if (part.slots() > 0) { + parts.add(part); + } + } + + private static void addForce(List forces, ForceKind kind, String ownerId, int livesPerSlot, + List parts) { + if (!parts.isEmpty()) { + forces.add(new BattleForce(kind, ownerId, livesPerSlot, parts)); + } + } + + /** Regiments that are soldiers: not the levy row, equipment or a mercenary company. */ + private static List soldierRegiments(Faction faction) { + List regiments = new ArrayList<>(); + if (faction.getMilitary() == null) { + return regiments; + } + for (Regiment regiment : faction.getMilitary().getRegiments()) { + if (!regiment.isLevy() && !regiment.isEquipment() && !regiment.isMercenary()) { + regiments.add(regiment); + } + } + return regiments; + } + + static Faction findNearestFighterHolder(Faction source, Set fighterIds) { + Faction current = source; + while (current != null) { + if (fighterIds.contains(key(current.getId()))) { + return current; + } + String overlordId = RelationManager.getOverlord(current); + if (overlordId == null || overlordId.isBlank()) { + return null; + } + current = FactionManager.getByString(overlordId); + } + return null; + } + + private static void collectNonFighterDescendants(Faction root, Set fighterIds, Set out) { + for (Faction subject : RelationManager.getSubjects(root)) { + if (!fighterIds.contains(key(subject.getId()))) { + out.add(subject); + } + collectNonFighterDescendants(subject, fighterIds, out); + } + } + + private static boolean sameId(Faction a, Faction b) { + return a != null && b != null && a.getId().equalsIgnoreCase(b.getId()); + } + + private static String key(String id) { + return id == null ? "" : id.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattleLivesService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattleLivesService.java index 941b6349..81f4a1e0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattleLivesService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattleLivesService.java @@ -1,27 +1,32 @@ package net.tfminecraft.simplefactions.war.battle.military; import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.UUID; -import java.util.logging.Logger; import net.tfminecraft.simplefactions.Cache; import net.tfminecraft.simplefactions.managers.WarManager; -import net.tfminecraft.simplefactions.war.core.Side; -import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.battle.campaign.CampaignBattleSides; import net.tfminecraft.simplefactions.war.battle.engine.core.Battle; import net.tfminecraft.simplefactions.war.battle.engine.core.BattleSide; import net.tfminecraft.simplefactions.war.battle.enums.BattleType; import net.tfminecraft.simplefactions.war.battle.enums.LifeType; -import net.tfminecraft.simplefactions.war.battle.campaign.CampaignBattleSides; import net.tfminecraft.simplefactions.war.battle.template.BattleTemplate; import net.tfminecraft.simplefactions.war.battle.warband.Warband; +import net.tfminecraft.simplefactions.war.core.Side; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.wounds.BattleForce; +import net.tfminecraft.simplefactions.war.wounds.ForceKind; +/** + * A campaign battle side's lives: the lives its forces bring minus the soldiers on its roster, + * at least {@code min_side_lives}, unless staff set the starting lives themselves. The forces + * are recorded on the side when the battle starts, so its deaths can be shared out later. + */ public final class BattleLivesService { - private static final Logger LOGGER = Logger.getLogger(BattleLivesService.class.getName()); - public record SideLivesPreview( - int committedRegiments, + int slots, int poolLives, int rosterFighters, int sideLives, @@ -38,54 +43,53 @@ public static void applyCampaignLives(Battle battle) { if (war == null || !war.isActive()) { return; } - Integer provinceId = resolveProvinceId(war, battle); - if (provinceId == null) { - return; - } - battle.setLifeType(LifeType.COLLECTIVE); - applySideLives( - battle, - BattleTemplate.ATTACKER_SIDE, - war, - CampaignBattleSides.warSideFor(war, battle, BattleTemplate.ATTACKER_SIDE), - provinceId); - applySideLives( - battle, - BattleTemplate.DEFENDER_SIDE, - war, - CampaignBattleSides.warSideFor(war, battle, BattleTemplate.DEFENDER_SIDE), - provinceId); + for (String battleSideId : List.of(BattleTemplate.ATTACKER_SIDE, BattleTemplate.DEFENDER_SIDE)) { + BattleSide battleSide = battle.getSideById(battleSideId); + Side warSide = CampaignBattleSides.warSideFor(war, battle, battleSideId); + if (battleSide != null && warSide != null) { + List forces = BattleForces.build(war, provinceId(war, battle), warSide, battle, battleSide); + battleSide.setForces(forces); + battleSide.setLives(livesFor(battleSide, forces)); + } + } } public static SideLivesPreview previewCampaignSideLives(War war, Battle battle, String battleSideId) { if (war == null || battle == null || battleSideId == null) { return new SideLivesPreview(0, 0, 0, 0, 0); } - Integer provinceId = resolveProvinceId(war, battle); - Side warSide = resolveWarSide(war, battle, battleSideId); + Side warSide = CampaignBattleSides.warSideFor(war, battle, battleSideId); BattleSide battleSide = battle.getSideById(battleSideId); if (warSide == null || battleSide == null) { return new SideLivesPreview(0, 0, 0, 0, 0); } - int committedRegiments = 0; - if (provinceId != null) { - committedRegiments = BattlePoolService.totalCommittedRegiments(war, provinceId, warSide); + List forces = BattleForces.build(war, provinceId(war, battle), warSide, battle, battleSide); + int slots = 0; + int mercenarySlots = 0; + for (BattleForce force : forces) { + slots += force.slots(); + if (force.kind() == ForceKind.MERCENARY) { + mercenarySlots += force.slots(); + } + } + return new SideLivesPreview(slots, BattleForce.lives(forces), countRosterFighters(battleSide), + livesFor(battleSide, forces), mercenarySlots); + } + + private static int livesFor(BattleSide side, List forces) { + if (side.getLivesOverride() != null) { + return side.getLivesOverride(); } - int mercenarySlots = mercenarySlots(war, warSide, battleSide); - committedRegiments = (int) Math.min(Integer.MAX_VALUE, (long) committedRegiments + mercenarySlots); - int rosterFighters = countRosterFighters(battleSide); - int poolLives = (int) Math.min(Integer.MAX_VALUE, (long) Cache.warBattleLivesPerRegiment * committedRegiments); - int sideLives = computeSideLives(committedRegiments, rosterFighters); - return new SideLivesPreview(committedRegiments, poolLives, rosterFighters, sideLives, mercenarySlots); + return computeSideLives(BattleForce.lives(forces), countRosterFighters(side)); } - public static int computeSideLives(int committedRegiments, int rosterFighters) { - if (committedRegiments <= 0) { + /** Pool lives minus the roster, at least {@code min_side_lives}; 0 when the side brought nothing. */ + public static int computeSideLives(int poolLives, int rosterFighters) { + if (poolLives <= 0) { return 0; } - long raw = (long) Cache.warBattleLivesPerRegiment * committedRegiments - rosterFighters; - return (int) Math.min(Integer.MAX_VALUE, Math.max(Cache.warBattleMinSideLives, raw)); + return (int) Math.max(Cache.warBattleMinSideLives, (long) poolLives - rosterFighters); } public static int countRosterFighters(BattleSide side) { @@ -94,10 +98,9 @@ public static int countRosterFighters(BattleSide side) { } Set uniqueMembers = new HashSet<>(); for (Warband warband : side.getBands()) { - if (warband == null) { - continue; + if (warband != null) { + uniqueMembers.addAll(warband.getMemberIds()); } - uniqueMembers.addAll(warband.getMemberIds()); } return uniqueMembers.size(); } @@ -110,48 +113,7 @@ private static boolean shouldApply(Battle battle) { return type == BattleType.FIELD || type == BattleType.SIEGE; } - private static void applySideLives( - Battle battle, - String battleSideId, - War war, - Side warSide, - int provinceId) { - BattleSide battleSide = battle.getSideById(battleSideId); - if (battleSide == null || warSide == null) { - return; - } - int committedRegiments = BattlePoolService.totalCommittedRegiments(war, provinceId, warSide); - int mercenarySlots = mercenarySlots(war, warSide, battleSide); - committedRegiments = (int) Math.min(Integer.MAX_VALUE, (long) committedRegiments + mercenarySlots); - if (committedRegiments <= 0) { - LOGGER.info( - "Campaign battle " + battle.getId() + " side " + battleSideId - + " has zero committed regiments; applying min side lives floor"); - } - int rosterFighters = countRosterFighters(battleSide); - int sideLives = computeSideLives(committedRegiments, rosterFighters); - battleSide.setLives(sideLives); - } - - private static int mercenarySlots(War war, Side warSide, BattleSide battleSide) { - long total = 0; - for (net.tfminecraft.simplefactions.mercenary.contract.MercenaryEngagements.Engagement engagement - : net.tfminecraft.simplefactions.mercenary.contract.MercenaryEngagements.on(war, warSide)) { - total += net.tfminecraft.simplefactions.mercenary.contract.MercenaryEngagements - .coveringMembers(engagement, battleSide); - } - return (int) Math.min(Integer.MAX_VALUE, total); - } - - private static Integer resolveProvinceId(War war, Battle battle) { - Integer provinceId = battle.getProvinceId(); - if (provinceId == null && war != null) { - provinceId = war.getScheduledBattleProvinceId(); - } - return provinceId; - } - - static Side resolveWarSide(War war, Battle battle, String battleSideId) { - return CampaignBattleSides.warSideFor(war, battle, battleSideId); + private static Integer provinceId(War war, Battle battle) { + return battle.getProvinceId() != null ? battle.getProvinceId() : war.getScheduledBattleProvinceId(); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattlePoolService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattlePoolService.java deleted file mode 100644 index 872f7e9c..00000000 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/military/BattlePoolService.java +++ /dev/null @@ -1,149 +0,0 @@ -package net.tfminecraft.simplefactions.war.battle.military; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import net.tfminecraft.simplefactions.army.Regiment; -import net.tfminecraft.simplefactions.managers.TitleManager; -import net.tfminecraft.simplefactions.objects.Faction; -import net.tfminecraft.simplefactions.war.civilwar.wartime.CivilWarBorderLock; -import net.tfminecraft.simplefactions.war.core.Side; -import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.core.WarCommitment; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; -import net.tfminecraft.simplefactions.war.campaign.progression.CampaignCapabilityService; -import net.tfminecraft.simplefactions.war.campaign.progression.CampaignSideService.CampaignSideRole; -import net.tfminecraft.simplefactions.war.campaign.progression.CampaignSideService; -import net.tfminecraft.simplefactions.war.campaign.runtime.BattleSideMembers; - -public final class BattlePoolService { - public static final String MILITIA_REGIMENT_ID = "militia"; - - private BattlePoolService() {} - - public static PoolMode resolvePoolMode(War war, int battleProvinceId, Side side) { - if (war == null || side == null) { - return PoolMode.DEFENSIVE; - } - CampaignSideRole offensive = CampaignCapabilityService.battleOffensiveCoalition(war); - CampaignSideRole sideCoalition = CampaignSideService.coalitionOf(war, side); - if (offensive == null || sideCoalition == null) { - return PoolMode.DEFENSIVE; - } - return sideCoalition == offensive ? PoolMode.OFFENSIVE : PoolMode.DEFENSIVE; - } - - public static boolean isMilitiaEligible(Faction faction, int battleProvinceId) { - if (faction == null || faction.getId() == null) { - return false; - } - Faction owner = TitleManager.getByProvince(battleProvinceId); - return owner != null && owner.getId().equalsIgnoreCase(faction.getId()); - } - - public static Map> eligibleRegiments( - War war, - int battleProvinceId, - Side side, - PoolMode mode) { - if (war == null || side == null || mode == null) { - return Map.of(); - } - List fighters = BattleSideMembers.collectParticipatingFactions(side); - Map> eligible = new LinkedHashMap<>(); - - for (Faction fighter : fighters) { - Map regimentCounts = collectOwnRegiments(war, fighter, battleProvinceId, mode); - addLevyRows(war, fighter.getId(), regimentCounts); - if (!regimentCounts.isEmpty()) { - eligible.put(fighter.getId(), regimentCounts); - } - } - return Map.copyOf(eligible); - } - - public static int totalCommittedRegiments(War war, int battleProvinceId, Side side) { - PoolMode mode = resolvePoolMode(war, battleProvinceId, side); - return totalCommittedRegiments(war, battleProvinceId, side, mode); - } - - public static int totalCommittedRegiments( - War war, - int battleProvinceId, - Side side, - PoolMode mode) { - long total = 0; - for (Map regimentCounts : eligibleRegiments(war, battleProvinceId, side, mode).values()) { - for (int count : regimentCounts.values()) { - total += count; - } - } - return (int) Math.min(Integer.MAX_VALUE, total); - } - - private static Map collectOwnRegiments( - War war, - Faction faction, - int battleProvinceId, - PoolMode mode) { - Map regimentCounts = new LinkedHashMap<>(); - for (Regiment regiment : faction.getMilitary().getRegiments()) { - if (regiment.isLevy() || regiment.isEquipment()) { - continue; - } - if (!isRegimentEligible(war, regiment, faction, battleProvinceId, mode)) { - continue; - } - int count = regiment.getCurrentSlots(); - if (count <= 0) { - continue; - } - regimentCounts.put(regiment.getId(), count); - } - return regimentCounts; - } - - private static boolean isRegimentEligible( - War war, - Regiment regiment, - Faction faction, - int battleProvinceId, - PoolMode mode) { - if (CivilWarBorderLock.isCivilWar(war)) { - return true; - } - if (MILITIA_REGIMENT_ID.equalsIgnoreCase(regiment.getId())) { - return mode == PoolMode.DEFENSIVE && isMilitiaEligible(faction, battleProvinceId); - } - if (mode == PoolMode.OFFENSIVE) { - return regiment.isOffensive(); - } - // Defence commits every regiment type. Militia stays limited to the faction's own province. - return true; - } - - private static void addLevyRows( - War war, - String holderFactionId, - Map regimentCounts) { - // Levies reinforce both the offensive and the defensive pool. - long levyTotal = 0; - for (WarCommitment commitment : WarCommitmentService.getCommitmentsForWar(war.getId())) { - if (!commitment.isLevyRow()) { - continue; - } - if (!commitment.factionId().equalsIgnoreCase(holderFactionId)) { - continue; - } - if (commitment.count() <= 0) { - continue; - } - levyTotal += commitment.count(); - } - if (levyTotal > 0) { - regimentCounts.put(WarCommitment.LEVY_REGIMENT_ID, (int) Math.min(Integer.MAX_VALUE, levyTotal)); - } - } - -} diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/persistence/BattleMapper.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/persistence/BattleMapper.java index a8791228..6fe29eb9 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/persistence/BattleMapper.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/persistence/BattleMapper.java @@ -187,6 +187,8 @@ private static BattleSideData toSideData(BattleSide side) { data.lives = side.getLives(); data.maxLives = side.getMaxLives(); data.startLives = side.getStartLives(); + data.forces = side.hasForces() ? side.getForces() : null; + data.livesOverride = side.getLivesOverride(); data.lifeType = side.getLt() != null ? side.getLt().name() : LifeType.COLLECTIVE.name(); for (Warband warband : side.getBands()) { if (warband != null) { @@ -215,6 +217,8 @@ private static BattleSide fromSideData(BattleSideData data, int defaultLives) { int maxLives = data.maxLives > 0 ? data.maxLives : lives; side.restoreLives(lives, maxLives); side.setStartLives(data.startLives); + side.setForces(data.forces); + side.setLivesOverride(data.livesOverride); return side; } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleCommandManager.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleCommandManager.java index 74a2c0c3..b1c372eb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleCommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleCommandManager.java @@ -427,8 +427,8 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String } Battle b = BattleManager.getByString(args[1]); if (b.getWarId() != null) { - p.sendMessage("§cCampaign battle lives are computed from war commitment and roster size."); - p.sendMessage("§7Adjust regiments in the war pool or roster signups instead."); + p.sendMessage("§cCampaign battle lives come from each side's forces and roster."); + p.sendMessage("§7Set one side's starting lives with §e/battle lives " + b.getId() + " set §7."); return true; } Integer lives = parseInteger(p, args[2]); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleInventoryManager.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleInventoryManager.java index 860e39cc..62d65bf6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleInventoryManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleInventoryManager.java @@ -642,7 +642,7 @@ public ItemStack createWarbandItem(Warband w) { SideLivesPreview preview = BattleLivesService.previewCampaignSideLives( ctx.war(), ctx.battle(), ctx.sideId()); lore.add("§7Side lives: §e" + preview.sideLives()); - if (preview.committedRegiments() > 0) { + if (preview.slots() > 0) { lore.add("§8Pool §e" + preview.poolLives() + " §8- §e" + preview.rosterFighters() + " §8soldiers"); } @@ -936,7 +936,7 @@ private static void appendCampaignSideLivesLine( Battle battle, String sideId) { SideLivesPreview preview = BattleLivesService.previewCampaignSideLives(war, battle, sideId); - if (preview.committedRegiments() <= 0) { + if (preview.slots() <= 0) { lore.add("§7" + label + ": §e0"); return; } @@ -946,12 +946,12 @@ private static void appendCampaignSideLivesLine( public static List formatSideLivesPreviewLore(SideLivesPreview preview) { List lore = new ArrayList<>(); - if (preview.committedRegiments() <= 0) { - lore.add("§7Lives: §e0 §8(no committed regiments)"); + if (preview.slots() <= 0) { + lore.add("§7Lives: §e0 §8(no regiments can fight)"); return lore; } lore.add("§7Lives: §e" + preview.sideLives()); - lore.add("§8Regiments §e" + preview.committedRegiments() + lore.add("§8Slots §e" + preview.slots() + " §8| Pool §e" + preview.poolLives() + " §8- §e" + preview.rosterFighters() + " §8soldiers"); return lore; diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleLivesEditor.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleLivesEditor.java index 8f42f8cf..590b41d6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleLivesEditor.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleLivesEditor.java @@ -76,6 +76,11 @@ public static boolean click(Player player, Battle battle, int slot, ClickType cl return true; } if (slot == CONFIRM_SLOT) { + if (!battle.hasStarted()) { + DRAFTS.remove(player.getUniqueId()); + player.sendMessage("§cThe battle is not running, so nothing was changed."); + return true; + } if (!draft.armed) { draft.armed = true; render(inventory, battle, draft); @@ -189,9 +194,10 @@ public static String regimentWarning(Battle battle) { for (BattleSide side : battle.getSides()) { deaths.add(side.getId() + ": " + casualties.getOrDefault(side.getId().toLowerCase(Locale.ROOT), 0) + " so far"); } - return "Lives only limit respawns. Regiments are lost through deaths: every " - + BattleCasualtyService.deathsPerRegimentLoss() + " deaths costs a side 1 regiment when the battle ends (" - + String.join(", ", deaths) + "). More lives means more possible deaths and losses." + zero; + return "Lives only limit respawns. Deaths wound the forces that fought, never more than the lives they" + + " brought, so added lives cost nobody: every " + BattleCasualtyService.deathsPerRegimentLoss() + + " wounded lives wound 1 paid slot until the war ends (" + String.join(", ", deaths) + ")." + + " Fix a finished battle's deaths with /war admin losses." + zero; } static List wrap(String text, int width) { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleStaffCommands.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleStaffCommands.java index f6785c97..1cdbbd02 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleStaffCommands.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleStaffCommands.java @@ -128,6 +128,9 @@ private static String vehicles(Battle battle, String[] args, String by) { } private static String lives(Battle battle, String[] args, String by) { + if (args.length == 4 && args[3].equalsIgnoreCase("auto")) { + return result(BattleStaffService.adjustLives(battle, args[2], "auto", 0, by)); + } if (args.length != 5) { return usage("lives"); } @@ -149,7 +152,7 @@ static String usage(String sub) { case "end" -> "end [nowinner|]"; case "hold" -> "hold on|off"; case "vehicles" -> "vehicles on|off"; - case "lives" -> "lives add|remove|set "; + case "lives" -> "lives add|remove|set , or auto before a campaign battle starts"; case "watch" -> "watch |off"; default -> sub + " "; }; diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleTabCompletion.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleTabCompletion.java index 1690c80e..c8b45a24 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleTabCompletion.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/ui/BattleTabCompletion.java @@ -270,7 +270,7 @@ static List staffCompletions(CommandSender sender, String command, Strin } if (args.length == 3 && (sub.equals("hold") || sub.equals("vehicles"))) return List.of("on", "off"); if (args.length == 3 && sub.equals("lives")) return sides; - if (args.length == 4 && sub.equals("lives")) return List.of("add", "remove", "set"); + if (args.length == 4 && sub.equals("lives")) return List.of("add", "remove", "set", "auto"); return List.of(); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/progression/CampaignCapabilityService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/progression/CampaignCapabilityService.java index 178d2784..0676d0c6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/progression/CampaignCapabilityService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/progression/CampaignCapabilityService.java @@ -11,7 +11,7 @@ import net.tfminecraft.simplefactions.Cache; import net.tfminecraft.simplefactions.war.core.Side; import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.battle.military.BattlePoolService; +import net.tfminecraft.simplefactions.war.battle.military.BattleForces; import net.tfminecraft.simplefactions.war.battle.military.PoolMode; import net.tfminecraft.simplefactions.war.enums.CampaignPhase; import net.tfminecraft.simplefactions.war.enums.ObjectiveHolder; @@ -59,7 +59,7 @@ public static int offensiveRegiments(War war, int provinceId, CampaignSideRole c if (side == null) { return 0; } - return BattlePoolService.totalCommittedRegiments(war, provinceId, side, PoolMode.OFFENSIVE); + return BattleForces.regimentSlots(war, provinceId, side, PoolMode.OFFENSIVE); } public static int defensiveRegiments(War war, int provinceId, CampaignSideRole coalition) { @@ -67,7 +67,7 @@ public static int defensiveRegiments(War war, int provinceId, CampaignSideRole c if (side == null) { return 0; } - return BattlePoolService.totalCommittedRegiments(war, provinceId, side, PoolMode.DEFENSIVE); + return BattleForces.regimentSlots(war, provinceId, side, PoolMode.DEFENSIVE); } public static boolean canDefend(War war, int provinceId, CampaignSideRole coalition) { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveService.java index 0e6aff25..0eee242f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveService.java @@ -24,7 +24,8 @@ import net.tfminecraft.simplefactions.war.battle.enums.BattleType; import net.tfminecraft.simplefactions.war.battle.military.BattleCasualtyService; import net.tfminecraft.simplefactions.war.battle.persistence.BattlePersistenceService; -import net.tfminecraft.simplefactions.war.battle.military.BattlePoolService; +import net.tfminecraft.simplefactions.war.battle.military.BattleForces; +import net.tfminecraft.simplefactions.war.wounds.BattleForce; import net.tfminecraft.simplefactions.war.battle.template.BattleTemplate; import net.tfminecraft.simplefactions.war.campaign.progression.AttackerNavalContestService; import net.tfminecraft.simplefactions.war.campaign.progression.BelligerentRole; @@ -172,10 +173,10 @@ public static boolean resolve(War war, Random random) { CampaignSideRole defensive = offensive.opposing(); Side offensiveSide = CampaignSideService.toSide(war, offensive); Side defensiveSide = CampaignSideService.toSide(war, defensive); - int livesPerRegiment = Math.max(0, Cache.warBattleLivesPerRegiment); - // Mercenary slots only count members on a battle roster. There is no roster here, so they are omitted. - double offensiveLives = (double) livesPerRegiment * committedRegiments(war, provinceId, offensiveSide); - double defensiveLives = (double) livesPerRegiment * committedRegiments(war, provinceId, defensiveSide); + // The same forces a fought battle would record. Mercenaries only count members on a roster; + // there is none here, so they bring nothing. + double offensiveLives = BattleForce.lives(BattleForces.build(war, provinceId, offensiveSide, null, null)); + double defensiveLives = BattleForce.lives(BattleForces.build(war, provinceId, defensiveSide, null, null)); Prediction prediction = predict( offensiveLives, defensiveLives, @@ -269,10 +270,6 @@ private static double luckFactor(Random random, double luck) { return (1.0 - luck) + roll * (2.0 * luck); } - private static int committedRegiments(War war, int provinceId, Side side) { - return Math.max(0, BattlePoolService.totalCommittedRegiments(war, provinceId, side)); - } - /** Unregistered battle so casualties key to the offensive coalition. It is not started. */ private static Battle casualtyCarrier(War war, int provinceId, CampaignSideRole offensive) { ScheduledCampaignBattle slot = CampaignScheduleService.slotAtActiveIndex(war).orElse(null); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/ui/CampaignBattleIconLore.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/ui/CampaignBattleIconLore.java index 72958d03..3c770448 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/ui/CampaignBattleIconLore.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/ui/CampaignBattleIconLore.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.war.campaign.ui; +import net.tfminecraft.simplefactions.war.wounds.WarWounds; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -125,7 +126,7 @@ private static int countMilitary(Faction faction) { if (regiment == null || regiment.isLevy() || regiment.isEquipment()) { continue; } - total += Math.max(0, regiment.getCurrentSlots()); + total += Math.max(0, WarWounds.availableSlots(faction, regiment)); } return total; } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/commitment/LevySnapshotCalculator.java b/src/main/java/net/tfminecraft/simplefactions/war/commitment/LevySnapshotCalculator.java deleted file mode 100644 index 425fbf38..00000000 --- a/src/main/java/net/tfminecraft/simplefactions/war/commitment/LevySnapshotCalculator.java +++ /dev/null @@ -1,177 +0,0 @@ -package net.tfminecraft.simplefactions.war.commitment; - -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import net.tfminecraft.simplefactions.army.Regiment; -import net.tfminecraft.simplefactions.managers.FactionManager; -import net.tfminecraft.simplefactions.managers.RelationManager; -import net.tfminecraft.simplefactions.objects.Faction; -import net.tfminecraft.simplefactions.war.core.Side; -import net.tfminecraft.simplefactions.war.campaign.runtime.BattleSideMembers; -import net.tfminecraft.simplefactions.enums.FactionModifiers; - -public final class LevySnapshotCalculator { - private LevySnapshotCalculator() {} - - public record LevyRow(String holderId, String sourceId, int count) {} - - public static Faction findNearestFighterHolder(Faction source, Set fighterIds) { - if (source == null || fighterIds == null || fighterIds.isEmpty()) { - return null; - } - Faction current = source; - while (current != null) { - String currentId = current.getId(); - if (currentId != null && containsId(fighterIds, currentId)) { - return current; - } - String overlordId = RelationManager.getOverlord(current); - if (overlordId == null || overlordId.isBlank()) { - return null; - } - current = FactionManager.getByString(overlordId); - } - return null; - } - - public static int directLevyContribution(Faction source) { - if (source == null) { - return 0; - } - int total = 0; - int memberCap = source.getMembers() != null ? net.tfminecraft.simplefactions.inactivity.InactivityService.activeOnly(source.getMembers()).size() : 0; - double levyPercent = source.getModifier(FactionModifiers.LEVY).getAmount(); - for (Regiment regiment : source.getMilitary().getRegiments()) { - if (regiment.isLevy() || regiment.isEquipment()) { - continue; - } - int count = regiment.getCurrentSlots(); - count = (int) Math.round(count * (levyPercent / 100.0)); - if (total + count >= memberCap) { - count = memberCap - total; - } - if (count <= 0) { - continue; - } - total += count; - } - return total; - } - - public static int levyContribution(Faction source, Faction holder) { - if (source == null || holder == null) { - return 0; - } - int count = directLevyContribution(source); - Faction current = source; - while (current != null && !sameId(current, holder)) { - String overlordId = RelationManager.getOverlord(current); - if (overlordId == null || overlordId.isBlank()) { - return 0; - } - Faction overlord = FactionManager.getByString(overlordId); - if (sameId(overlord, holder)) { - break; - } - double levyPercent = overlord.getModifier(FactionModifiers.LEVY).getAmount(); - count = (int) Math.round(count * (levyPercent / 100.0)); - if (count <= 0) { - return 0; - } - current = overlord; - } - return count; - } - - public static Map collectLevyRows(Side side) { - if (side == null) { - return Map.of(); - } - Set fighterIds = toIdSet(BattleSideMembers.collectParticipatingFactions(side)); - Set candidateSources = new HashSet<>(); - for (Faction fighter : BattleSideMembers.collectParticipatingFactions(side)) { - collectNonFighterDescendants(fighter, fighterIds, candidateSources); - } - return buildRows(candidateSources, fighterIds); - } - - public static Map collectLevyRowsForFighter(Faction fighter, Set sideFighterIds) { - if (fighter == null || sideFighterIds == null || sideFighterIds.isEmpty()) { - return Map.of(); - } - Set candidateSources = new HashSet<>(); - collectNonFighterDescendants(fighter, sideFighterIds, candidateSources); - return buildRows(candidateSources, sideFighterIds); - } - - private static Map buildRows(Set candidateSources, Set fighterIds) { - Map rows = new LinkedHashMap<>(); - for (Faction source : candidateSources) { - Faction holder = findNearestFighterHolder(source, fighterIds); - if (holder == null) { - continue; - } - int count = levyContribution(source, holder); - if (count <= 0) { - continue; - } - String key = levyKey(holder.getId(), source.getId()); - rows.put(key, new LevyRow(holder.getId(), source.getId(), count)); - } - return rows; - } - - private static void collectNonFighterDescendants( - Faction root, - Set fighterIds, - Set out) { - for (Faction subject : RelationManager.getSubjects(root)) { - if (!containsId(fighterIds, subject.getId())) { - out.add(subject); - } - collectNonFighterDescendants(subject, fighterIds, out); - } - } - - public static Set collectSubjectSubtreeIds(Faction root) { - Set ids = new HashSet<>(); - if (root == null || root.getId() == null) { - return ids; - } - ids.add(root.getId().toLowerCase(java.util.Locale.ROOT)); - for (Faction subject : RelationManager.getSubjects(root)) { - ids.addAll(collectSubjectSubtreeIds(subject)); - } - return ids; - } - - public static String levyKey(String holderId, String sourceId) { - return holderId.toLowerCase(java.util.Locale.ROOT) + "|" + sourceId.toLowerCase(java.util.Locale.ROOT); - } - - private static Set toIdSet(List factions) { - Set ids = new HashSet<>(); - for (Faction faction : factions) { - if (faction != null && faction.getId() != null) { - ids.add(faction.getId().toLowerCase(java.util.Locale.ROOT)); - } - } - return ids; - } - - private static boolean containsId(Set ids, String factionId) { - return factionId != null && ids.contains(factionId.toLowerCase(java.util.Locale.ROOT)); - } - - private static boolean sameId(Faction a, Faction b) { - return a != null - && b != null - && a.getId() != null - && a.getId().equalsIgnoreCase(b.getId()); - } -} diff --git a/src/main/java/net/tfminecraft/simplefactions/war/commitment/WarCommitmentService.java b/src/main/java/net/tfminecraft/simplefactions/war/commitment/WarCommitmentService.java deleted file mode 100644 index d8f7e22b..00000000 --- a/src/main/java/net/tfminecraft/simplefactions/war/commitment/WarCommitmentService.java +++ /dev/null @@ -1,297 +0,0 @@ -package net.tfminecraft.simplefactions.war.commitment; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.function.Predicate; - -import net.tfminecraft.simplefactions.army.Regiment; -import net.tfminecraft.simplefactions.managers.WarManager; -import net.tfminecraft.simplefactions.objects.Faction; -import net.tfminecraft.simplefactions.war.core.Side; -import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.core.WarCommitment; -import net.tfminecraft.simplefactions.war.commitment.LevySnapshotCalculator.LevyRow; -import net.tfminecraft.simplefactions.war.campaign.runtime.BattleSideMembers; - -public final class WarCommitmentService { - private static final Map> commitmentsByWar = new HashMap<>(); - - private WarCommitmentService() {} - - public static List commitFaction(War war, Faction faction) { - if (war == null || faction == null || faction.getId() == null) { - return List.of(); - } - List existing = getOwnRegimentCommitments(war.getId(), faction.getId()); - if (!existing.isEmpty()) { - return List.copyOf(existing); - } - - Instant committedAt = Instant.now(); - List created = new ArrayList<>(); - for (Regiment regiment : faction.getMilitary().getRegiments()) { - if (regiment.isLevy() || regiment.isEquipment()) { - continue; - } - created.add(new WarCommitment( - war.getId(), - faction.getId(), - null, - regiment.getId(), - regiment.getCurrentSlots(), - committedAt)); - } - appendCommitments(war.getId(), created); - return List.copyOf(created); - } - - public static List snapshotLevyForSide(War war, Side side) { - if (war == null || side == null) { - return List.of(); - } - return insertLevyRows(war, LevySnapshotCalculator.collectLevyRows(side)); - } - - public static List snapshotLevyForFighter(War war, Faction fighter) { - if (war == null || fighter == null) { - return List.of(); - } - Side side = war.getSide(fighter); - if (side == null) { - return List.of(); - } - Set sideFighterIds = new HashSet<>(); - for (Faction participating : BattleSideMembers.collectParticipatingFactions(side)) { - if (participating != null && participating.getId() != null) { - sideFighterIds.add(participating.getId().toLowerCase(java.util.Locale.ROOT)); - } - } - return insertLevyRows(war, LevySnapshotCalculator.collectLevyRowsForFighter(fighter, sideFighterIds)); - } - - public static void commitAllParticipants(War war) { - if (war == null) { - return; - } - for (Faction fighter : BattleSideMembers.collectParticipatingFactions(war.getAttackers())) { - commitFaction(war, fighter); - } - for (Faction fighter : BattleSideMembers.collectParticipatingFactions(war.getDefenders())) { - commitFaction(war, fighter); - } - snapshotLevyForSide(war, war.getAttackers()); - snapshotLevyForSide(war, war.getDefenders()); - } - - public static void onVassalageEnded(Faction origin, Faction target) { - if (origin == null || target == null) { - return; - } - if (net.tfminecraft.simplefactions.managers.RelationManager.isOverlord(origin, target)) { - removeLevySubtree(origin); - return; - } - if (net.tfminecraft.simplefactions.managers.RelationManager.isOverlord(target, origin)) { - removeLevySubtree(target); - } - } - - public static void removeLevySubtree(Faction brokenSubject) { - if (brokenSubject == null) { - return; - } - Set subtreeIds = LevySnapshotCalculator.collectSubjectSubtreeIds(brokenSubject); - String brokenSubjectId = brokenSubject.getId(); - for (War war : WarManager.getActive()) { - removeLevyRows(war.getId(), commitment -> { - if (brokenSubjectId != null - && brokenSubjectId.equalsIgnoreCase(commitment.factionId()) - && commitment.isLevyRow()) { - return true; - } - String sourceId = commitment.sourceFactionId(); - return sourceId != null && subtreeIds.contains(sourceId.toLowerCase(java.util.Locale.ROOT)); - }); - } - } - - public static List getCommitmentsForWar(int warId) { - List commitments = commitmentsByWar.get(warId); - if (commitments == null || commitments.isEmpty()) { - return List.of(); - } - return Collections.unmodifiableList(commitments); - } - - public static void clearCommitments(int warId) { - commitmentsByWar.remove(warId); - } - - public static void restoreCommitments(int warId, List commitments) { - if (warId < 0) { - return; - } - if (commitments == null || commitments.isEmpty()) { - commitmentsByWar.remove(warId); - return; - } - commitmentsByWar.put(warId, new ArrayList<>(commitments)); - } - - public static int debitCount( - int warId, - String factionId, - String sourceFactionId, - String regimentId, - int amount) { - if (amount <= 0 || factionId == null || regimentId == null) { - return 0; - } - List commitments = commitmentsByWar.get(warId); - if (commitments == null || commitments.isEmpty()) { - return 0; - } - for (int i = 0; i < commitments.size(); i++) { - WarCommitment commitment = commitments.get(i); - if (!matchesCommitment(commitment, factionId, sourceFactionId, regimentId)) { - continue; - } - int debited = Math.min(amount, commitment.count()); - if (debited <= 0) { - return 0; - } - int remaining = commitment.count() - debited; - if (remaining <= 0) { - commitments.remove(i); - } else { - commitments.set(i, new WarCommitment( - commitment.warId(), - commitment.factionId(), - commitment.sourceFactionId(), - commitment.regimentId(), - remaining, - commitment.committedAt())); - } - if (commitments.isEmpty()) { - commitmentsByWar.remove(warId); - } - return debited; - } - return 0; - } - - private static boolean matchesCommitment( - WarCommitment commitment, - String factionId, - String sourceFactionId, - String regimentId) { - if (commitment.factionId() == null || !commitment.factionId().equalsIgnoreCase(factionId)) { - return false; - } - if (!commitment.regimentId().equalsIgnoreCase(regimentId)) { - return false; - } - if (sourceFactionId == null) { - return commitment.sourceFactionId() == null; - } - return sourceFactionId.equalsIgnoreCase(commitment.sourceFactionId()); - } - - /** - * Raw commitment sum without pool/militia/live-slot rules. Use {@link net.tfminecraft.simplefactions.war.battle.military.BattlePoolService} for battle pools (61.03+). - */ - public static int totalCommittedRegiments( - int warId, - Set factionIds, - Predicate regimentFilter) { - if (factionIds == null || factionIds.isEmpty()) { - return 0; - } - Set normalizedIds = new HashSet<>(); - for (String factionId : factionIds) { - if (factionId != null) { - normalizedIds.add(factionId.toLowerCase(java.util.Locale.ROOT)); - } - } - int total = 0; - for (WarCommitment commitment : getCommitmentsForWar(warId)) { - if (commitment.factionId() == null || !normalizedIds.contains(commitment.factionId().toLowerCase(java.util.Locale.ROOT))) { - continue; - } - if (regimentFilter != null && !regimentFilter.test(commitment)) { - continue; - } - total += commitment.count(); - } - return total; - } - - private static List insertLevyRows(War war, Map rows) { - if (rows == null || rows.isEmpty()) { - return List.of(); - } - Instant committedAt = Instant.now(); - List created = new ArrayList<>(); - for (LevyRow row : rows.values()) { - if (hasLevyRow(war.getId(), row.holderId(), row.sourceId())) { - continue; - } - created.add(new WarCommitment( - war.getId(), - row.holderId(), - row.sourceId(), - WarCommitment.LEVY_REGIMENT_ID, - row.count(), - committedAt)); - } - appendCommitments(war.getId(), created); - return List.copyOf(created); - } - - private static boolean hasLevyRow(int warId, String holderId, String sourceId) { - for (WarCommitment commitment : getCommitmentsForWar(warId)) { - if (!commitment.isLevyRow()) { - continue; - } - if (commitment.factionId().equalsIgnoreCase(holderId) - && commitment.sourceFactionId().equalsIgnoreCase(sourceId)) { - return true; - } - } - return false; - } - - private static List getOwnRegimentCommitments(int warId, String factionId) { - List matches = new ArrayList<>(); - for (WarCommitment commitment : getCommitmentsForWar(warId)) { - if (commitment.factionId().equalsIgnoreCase(factionId) && !commitment.isLevyRow()) { - matches.add(commitment); - } - } - return matches; - } - - private static void appendCommitments(int warId, List commitments) { - if (commitments == null || commitments.isEmpty()) { - return; - } - commitmentsByWar.computeIfAbsent(warId, ignored -> new ArrayList<>()).addAll(commitments); - } - - private static void removeLevyRows(int warId, Predicate shouldRemove) { - List commitments = commitmentsByWar.get(warId); - if (commitments == null || commitments.isEmpty()) { - return; - } - commitments.removeIf(shouldRemove); - if (commitments.isEmpty()) { - commitmentsByWar.remove(warId); - } - } -} diff --git a/src/main/java/net/tfminecraft/simplefactions/war/core/CallToArmsService.java b/src/main/java/net/tfminecraft/simplefactions/war/core/CallToArmsService.java index d7cfbb7f..5f1b06bc 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/core/CallToArmsService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/core/CallToArmsService.java @@ -25,7 +25,6 @@ import net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager; import net.tfminecraft.simplefactions.war.campaign.runtime.BattleSideMembers; import net.tfminecraft.simplefactions.war.campaign.runtime.CampaignClock; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; import net.tfminecraft.simplefactions.war.history.WarHistoryEvents; import net.tfminecraft.simplefactions.war.notice.WarNotices; @@ -215,8 +214,6 @@ public static Answer accept(War war, Faction receiver) { } WarHistoryEvents.joined(war, receiver, CampaignClock.now()); WarHistoryEvents.callToArmsClosed(war, receiver, true); - WarCommitmentService.commitFaction(war, receiver); - WarCommitmentService.snapshotLevyForFighter(war, receiver); // A secondary only counts as a participant once it has joined, so contracts are re-checked here. MercenaryLoyaltyWatcher.onWarJoined(receiver); WarNotices.send(origin.getLeader(), receiver.getName() + " §aaccepted your call to arms: " diff --git a/src/main/java/net/tfminecraft/simplefactions/war/core/War.java b/src/main/java/net/tfminecraft/simplefactions/war/core/War.java index 6f43568b..d69e493c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/core/War.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/core/War.java @@ -79,6 +79,8 @@ public class War { private boolean forcedWhitePeaceByAttacker; private boolean forcedWhitePeaceByDefender; private int campaignBattlesFought; + /** What the war's battles cost; see {@link net.tfminecraft.simplefactions.war.wounds.WarWounds}. */ + private net.tfminecraft.simplefactions.war.wounds.WarLedger ledger = new net.tfminecraft.simplefactions.war.wounds.WarLedger(); private List campaignBattleSchedule = new ArrayList<>(); private int campaignScheduleIndex; private List campaignCounterSchedule = new ArrayList<>(); @@ -512,6 +514,14 @@ public void setForcedWhitePeaceByDefender(boolean forcedWhitePeaceByDefender) { this.forcedWhitePeaceByDefender = forcedWhitePeaceByDefender; } + public net.tfminecraft.simplefactions.war.wounds.WarLedger getLedger() { + return ledger; + } + + public void setLedger(net.tfminecraft.simplefactions.war.wounds.WarLedger ledger) { + this.ledger = ledger == null ? new net.tfminecraft.simplefactions.war.wounds.WarLedger() : ledger; + } + public int getCampaignBattlesFought() { return campaignBattlesFought; } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/core/WarCommandManager.java b/src/main/java/net/tfminecraft/simplefactions/war/core/WarCommandManager.java index be39c743..8362368f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/core/WarCommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/core/WarCommandManager.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.war.core; +import net.tfminecraft.simplefactions.war.wounds.WarArmyAdmin; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -108,7 +109,7 @@ private boolean handleAdmin(Player player, String[] rawArgs) { return true; } if (args.length < 2) { - player.sendMessage("§cUsage: /war admin declare|end|win|status|path|time|schedule|buildup|devmode|raid|reparations|truce|history|factions|forceinstallation ..."); + player.sendMessage("§cUsage: /war admin declare|end|win|status|path|time|schedule|buildup|devmode|raid|reparations|truce|history|factions|forceinstallation|losses|army|wounds ..."); return true; } String subcommand = args[1].toLowerCase(java.util.Locale.ROOT); @@ -134,8 +135,20 @@ private boolean handleAdmin(Player player, String[] rawArgs) { player.sendMessage(WarForceInstallation.run(args, player.getName())); yield true; } + case "losses" -> { + WarArmyAdmin.losses(args, player.getName()).forEach(player::sendMessage); + yield true; + } + case "army" -> { + WarArmyAdmin.army(args).forEach(player::sendMessage); + yield true; + } + case "wounds" -> { + WarArmyAdmin.wounds(args, player.getName()).forEach(player::sendMessage); + yield true; + } default -> { - player.sendMessage("§cUnknown admin subcommand. Use: declare, end, win, status, path, time, schedule, devmode, raid, reparations, truce, history, factions, forceinstallation"); + player.sendMessage("§cUnknown admin subcommand. Use: declare, end, win, status, path, time, schedule, devmode, raid, reparations, truce, history, factions, forceinstallation, losses, army, wounds"); yield true; } }; diff --git a/src/main/java/net/tfminecraft/simplefactions/war/core/WarCommitment.java b/src/main/java/net/tfminecraft/simplefactions/war/core/WarCommitment.java deleted file mode 100644 index 7bb721ad..00000000 --- a/src/main/java/net/tfminecraft/simplefactions/war/core/WarCommitment.java +++ /dev/null @@ -1,18 +0,0 @@ -package net.tfminecraft.simplefactions.war.core; - -import java.time.Instant; - -public record WarCommitment( - int warId, - String factionId, - String sourceFactionId, - String regimentId, - int count, - Instant committedAt) { - - public static final String LEVY_REGIMENT_ID = "levy"; - - public boolean isLevyRow() { - return LEVY_REGIMENT_ID.equalsIgnoreCase(regimentId) && sourceFactionId != null; - } -} diff --git a/src/main/java/net/tfminecraft/simplefactions/war/core/WarDebugFormatter.java b/src/main/java/net/tfminecraft/simplefactions/war/core/WarDebugFormatter.java index 688e1959..6fa9545d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/core/WarDebugFormatter.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/core/WarDebugFormatter.java @@ -66,31 +66,11 @@ public static List formatStatusLines(War war) { summary.put("defenderChoiceResolved", war.isDefenderChoiceResolved()); summary.put("forceQuorumNextClose", war.isForceQuorumNextClose() ? true : null); summary.put("startedAt", war.getStartedAt() != null ? war.getStartedAt().toString() : null); - List> commitmentRows = serializeCommitmentRows( - WarManager.getCommitmentsForWar(war.getId())); - summary.put("commitmentRows", commitmentRows); - summary.put("commitments", commitmentRows.size()); + summary.put("ledgerBattles", war.getLedger().battles().size()); + summary.put("factionWoundLives", war.getLedger().factionWoundLives()); return List.of("§7" + GSON.toJson(summary)); } - private static List> serializeCommitmentRows(List commitments) { - List> rows = new ArrayList<>(); - if (commitments == null || commitments.isEmpty()) { - return rows; - } - for (WarCommitment commitment : commitments) { - Map row = new LinkedHashMap<>(); - row.put("factionId", commitment.factionId()); - if (commitment.sourceFactionId() != null) { - row.put("sourceFactionId", commitment.sourceFactionId()); - } - row.put("regimentId", commitment.regimentId()); - row.put("count", commitment.count()); - rows.add(row); - } - return rows; - } - private static List> serializeCampaignSchedule( War war, CampaignScheduleService.ScheduleLeg leg) { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/core/WarDevMode.java b/src/main/java/net/tfminecraft/simplefactions/war/core/WarDevMode.java index bf69cb11..514dbfd4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/core/WarDevMode.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/core/WarDevMode.java @@ -14,7 +14,8 @@ import net.tfminecraft.simplefactions.war.battle.campaign.CampaignBattleSides; import net.tfminecraft.simplefactions.war.battle.engine.core.Battle; import net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager; -import net.tfminecraft.simplefactions.war.battle.military.BattlePoolService; +import net.tfminecraft.simplefactions.war.battle.military.BattleForces; +import net.tfminecraft.simplefactions.war.wounds.BattleForce; import net.tfminecraft.simplefactions.war.battle.persistence.BattlePersistenceService; import net.tfminecraft.simplefactions.war.battle.template.BattleTemplate; import net.tfminecraft.simplefactions.war.battle.warband.Warband; @@ -186,8 +187,7 @@ private static void seedCampaignDummyMembers(Warband warband, War war, Battle ba if (side == null) { return; } - int committedRegiments = BattlePoolService.totalCommittedRegiments(war, provinceId, side); - int poolLives = Cache.warBattleLivesPerRegiment * committedRegiments; + int poolLives = BattleForce.lives(BattleForces.build(war, provinceId, side, null, null)); int count = Math.min( Cache.warDevmodePhantomCount, Math.max(0, poolLives - warband.getMemberCount())); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/core/WarMapper.java b/src/main/java/net/tfminecraft/simplefactions/war/core/WarMapper.java index 5f9a0e3c..a8877b9b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/core/WarMapper.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/core/WarMapper.java @@ -17,7 +17,6 @@ import net.tfminecraft.simplefactions.Cache; import net.tfminecraft.simplefactions.database.CivilWarMemberMoveData; import net.tfminecraft.simplefactions.database.CivilWarVassalEndData; -import net.tfminecraft.simplefactions.database.CommitmentData; import net.tfminecraft.simplefactions.database.CampaignRaidData; import net.tfminecraft.simplefactions.database.ParticipantData; import net.tfminecraft.simplefactions.database.ScheduledCampaignBattleData; @@ -27,7 +26,6 @@ import net.tfminecraft.simplefactions.database.WarDemandData; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.war.campaign.WarCampaignService; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.war.enums.BattleSchedulePhase; import net.tfminecraft.simplefactions.war.enums.CampaignPhase; @@ -215,7 +213,7 @@ public static WarData toData(War war) { } data.attackers = serializeSide(war.getAttackers()); data.defenders = serializeSide(war.getDefenders()); - data.commitments = serializeCommitments(WarCommitmentService.getCommitmentsForWar(war.getId())); + data.ledger = war.getLedger() == null ? null : war.getLedger().toData(); return data; } @@ -355,7 +353,7 @@ public static War fromData(WarData data) { war.getDefenders().getMainParticipants().clear(); loadParticipants(data.attackers, war.getAttackers()); loadParticipants(data.defenders, war.getDefenders()); - WarCommitmentService.restoreCommitments(war.getId(), deserializeCommitments(data.commitments, war.getId())); + war.setLedger(net.tfminecraft.simplefactions.war.wounds.WarLedger.fromData(data.ledger)); return war; } @@ -512,48 +510,6 @@ private static void loadParticipants(SideData data, Side side) { } } - private static List serializeCommitments(List commitments) { - if (commitments == null || commitments.isEmpty()) { - return null; - } - List serialized = new ArrayList<>(); - for (WarCommitment commitment : commitments) { - CommitmentData data = new CommitmentData(); - data.factionId = commitment.factionId(); - data.sourceFactionId = commitment.sourceFactionId(); - data.regimentId = commitment.regimentId(); - data.count = commitment.count(); - if (commitment.committedAt() != null) { - data.committedAt = commitment.committedAt().toString(); - } - serialized.add(data); - } - return serialized; - } - - private static List deserializeCommitments(List commitments, int warId) { - if (commitments == null || commitments.isEmpty()) { - return List.of(); - } - List deserialized = new ArrayList<>(); - for (CommitmentData data : commitments) { - if (data == null || data.factionId == null || data.regimentId == null || data.count <= 0) { - continue; - } - Instant committedAt = data.committedAt != null && !data.committedAt.isBlank() - ? Instant.parse(data.committedAt) - : Instant.EPOCH; - deserialized.add(new WarCommitment( - warId, - data.factionId, - data.sourceFactionId, - data.regimentId, - data.count, - committedAt)); - } - return deserialized; - } - private static void applyCampaignRuntimeFields(War war, WarData data) { CampaignSideRole coalition = CampaignSideRole.fromJson(data.initiativeHolderCoalition); if (coalition == null) { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/core/WarTabCompletion.java b/src/main/java/net/tfminecraft/simplefactions/war/core/WarTabCompletion.java index 9b664d23..422dcdfb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/core/WarTabCompletion.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/core/WarTabCompletion.java @@ -58,8 +58,14 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe addIfPrefix(completions, args[1], "history"); addIfPrefix(completions, args[1], "factions"); addIfPrefix(completions, args[1], "forceinstallation"); + addIfPrefix(completions, args[1], "losses"); + addIfPrefix(completions, args[1], "army"); + addIfPrefix(completions, args[1], "wounds"); return completions; } + if (args[1].equalsIgnoreCase("losses") || args[1].equalsIgnoreCase("army") || args[1].equalsIgnoreCase("wounds")) { + return armyCompletions(args); + } if (args[1].equalsIgnoreCase("forceinstallation")) { return forceInstallationCompletions(args); } @@ -320,6 +326,65 @@ private static List forceInstallationCompletions(String[] args) { return completions; } + /** + * {@code losses set|add|remove}, {@code army } and + * {@code wounds add|remove} or {@code wounds company set}. + */ + private static List armyCompletions(String[] args) { + String sub = args[1].toLowerCase(java.util.Locale.ROOT); + String typed = args[args.length - 1]; + List completions = new ArrayList<>(); + if (sub.equals("army")) { + return args.length == 3 ? factionIds(typed) : completions; + } + if (args.length == 3) { + completions.addAll(activeWarIds(typed)); + if (sub.equals("wounds")) { + addIfPrefix(completions, typed, "company"); + } + return completions; + } + boolean company = args[2].equalsIgnoreCase("company"); + War war = company ? null : WarCommandHelper.parseWarId(args[2]).map(WarManager::getById).orElse(null); + List options = switch (sub + ":" + args.length) { + case "losses:4" -> war == null ? List.of() : war.getLedger().battles().stream() + .map(entry -> String.valueOf(entry.number())).toList(); + case "losses:5" -> List.of("attacker", "defender"); + case "losses:6" -> List.of("set", "add", "remove"); + case "wounds:4" -> company ? companyGuildIds() : factionIds(typed); + case "wounds:5" -> company ? List.of("set") : regimentIds(args[3]); + case "wounds:6" -> company ? List.of() : List.of("add", "remove"); + default -> List.of(); + }; + for (String option : options) { + addIfPrefix(completions, typed, option); + } + return completions; + } + + private static List companyGuildIds() { + List ids = new ArrayList<>(); + for (net.tfminecraft.simplefactions.guild.Guild guild : FactionManager.getAllGuilds()) { + if (guild.getCompany() != null) { + ids.add(guild.getId()); + } + } + return ids; + } + + private static List regimentIds(String factionId) { + Faction faction = FactionManager.getByString(factionId); + List ids = new ArrayList<>(); + if (faction != null && faction.getMilitary() != null) { + for (var regiment : faction.getMilitary().getRegiments()) { + if (!regiment.isLevy() && !regiment.isEquipment()) { + ids.add(regiment.getId()); + } + } + } + return ids; + } + private static List factionIds(String prefix) { List completions = new ArrayList<>(); String lower = prefix == null ? "" : prefix.toLowerCase(java.util.Locale.ROOT); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/wounds/BattleForce.java b/src/main/java/net/tfminecraft/simplefactions/war/wounds/BattleForce.java new file mode 100644 index 00000000..a73a17b8 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/war/wounds/BattleForce.java @@ -0,0 +1,47 @@ +package net.tfminecraft.simplefactions.war.wounds; + +import java.util.List; + +/** + * What one owner brought to one side of a battle. {@code ownerId} is a faction id, or the + * guild id of a mercenary company. Each slot is worth {@code livesPerSlot} lives in the + * side's shared pool. + */ +public record BattleForce(ForceKind kind, String ownerId, int livesPerSlot, List parts) { + public BattleForce { + livesPerSlot = Math.max(0, livesPerSlot); + parts = parts == null ? List.of() : List.copyOf(parts); + } + + public int slots() { + long slots = 0; + for (ForcePart part : parts) { + slots += part.slots(); + } + return (int) Math.min(Integer.MAX_VALUE, slots); + } + + public int lives() { + return (int) Math.min(Integer.MAX_VALUE, (long) slots() * livesPerSlot); + } + + /** Lives over every force, kept within an int. */ + public static int lives(List forces) { + long lives = 0; + for (BattleForce force : forces) { + lives += force.lives(); + } + return (int) Math.min(Integer.MAX_VALUE, lives); + } + + /** Slots over every force except mercenaries, the side's own regiments for capability checks. */ + public static int regimentSlots(List forces) { + long slots = 0; + for (BattleForce force : forces) { + if (force.kind() != ForceKind.MERCENARY) { + slots += force.slots(); + } + } + return (int) Math.min(Integer.MAX_VALUE, slots); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/war/wounds/CompanyWounds.java b/src/main/java/net/tfminecraft/simplefactions/war/wounds/CompanyWounds.java new file mode 100644 index 00000000..7f6b121f --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/war/wounds/CompanyWounds.java @@ -0,0 +1,88 @@ +package net.tfminecraft.simplefactions.war.wounds; + +import java.time.Duration; +import java.time.Instant; + +import net.tfminecraft.simplefactions.Cache; + +/** + * A mercenary company's wounds, shared by all its contracts. They are counted in lives and heal + * by {@code mercenary_heal_percent} of the company's slots every {@code mercenary_heal_hours}, + * counted from its latest new wound and worked out when read, so nothing needs a timer. A company never + * loses slots to wounds. + */ +public final class CompanyWounds { + private int lives; + private Instant healFrom; + + public CompanyWounds() { + } + + public CompanyWounds(int lives, Instant healFrom) { + this.lives = Math.max(0, lives); + this.healFrom = this.lives > 0 ? healFrom : null; + } + + /** Wounded lives now, after the healing due since the last read. */ + public int lives(Instant now, int slots) { + heal(now, slots); + return lives; + } + + /** Slots too wounded to fight, never more than the company has. */ + public int woundedSlots(Instant now, int slots) { + return Math.min(Math.max(0, slots), lives(now, slots) / deathsPerSlot()); + } + + public int healthySlots(Instant now, int slots) { + return Math.max(0, slots - woundedSlots(now, slots)); + } + + /** Adds (or with a negative amount removes) wounded lives, kept between 0 and the company's size. */ + public void add(int delta, Instant now, int slots) { + heal(now, slots); + set(lives + (long) delta, now, slots); + } + + public void set(long newLives, Instant now, int slots) { + long max = (long) Math.max(0, slots) * deathsPerSlot(); + int before = lives; + lives = (int) Math.max(0, Math.min(max, newLives)); + if (lives == 0) { + healFrom = null; + } else if (lives > before || healFrom == null) { + // Fresh wounds restart the healing period, so they cannot heal the moment they are taken. + healFrom = now; + } + } + + /** When healing started counting, for saving; null while unwounded. */ + public Instant healFrom() { + return healFrom; + } + + /** Lives as last stored, without healing; for saving. */ + public int storedLives() { + return lives; + } + + private void heal(Instant now, int slots) { + if (lives <= 0 || healFrom == null || now == null || Cache.warMercenaryHealPercent <= 0) { + return; + } + long periodMillis = Math.max(1L, Math.round(Cache.warMercenaryHealHours * 3_600_000d)); + long periods = Duration.between(healFrom, now).toMillis() / periodMillis; + if (periods <= 0) { + return; + } + long perPeriod = Math.max(1L, (long) Math.ceil( + Math.max(0, slots) * deathsPerSlot() * Cache.warMercenaryHealPercent / 100d)); + long healed = Math.min(lives, periods * perPeriod); + lives -= (int) healed; + healFrom = lives == 0 ? null : healFrom.plusMillis(periods * periodMillis); + } + + private static int deathsPerSlot() { + return Cache.warBattleDeathsPerRegimentLoss > 0 ? Cache.warBattleDeathsPerRegimentLoss : 5; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/war/wounds/ForceKind.java b/src/main/java/net/tfminecraft/simplefactions/war/wounds/ForceKind.java new file mode 100644 index 00000000..c7447a52 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/war/wounds/ForceKind.java @@ -0,0 +1,13 @@ +package net.tfminecraft.simplefactions.war.wounds; + +/** Where a force's soldiers come from, which decides who carries its wounds and how they heal. */ +public enum ForceKind { + /** A fighting faction's own regiments. */ + REGIMENTS, + /** A subject's soldiers lent to its nearest fighting overlord. */ + LEVY, + /** A non-fighting subject's whole militia, defending its own province. */ + MILITIA, + /** A mercenary company on contract. Its wounds heal over time and it never loses slots. */ + MERCENARY +} diff --git a/src/main/java/net/tfminecraft/simplefactions/war/wounds/ForcePart.java b/src/main/java/net/tfminecraft/simplefactions/war/wounds/ForcePart.java new file mode 100644 index 00000000..3bfc9b01 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/war/wounds/ForcePart.java @@ -0,0 +1,16 @@ +package net.tfminecraft.simplefactions.war.wounds; + +/** + * One regiment's share of a force. Law slots are granted by laws and are full again after every + * battle; paid slots carry their wounds until the war ends. + */ +public record ForcePart(String regimentId, boolean militia, int lawSlots, int paidSlots) { + public ForcePart { + lawSlots = Math.max(0, lawSlots); + paidSlots = Math.max(0, paidSlots); + } + + public int slots() { + return (int) Math.min(Integer.MAX_VALUE, (long) lawSlots + paidSlots); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/war/wounds/WarArmyAdmin.java b/src/main/java/net/tfminecraft/simplefactions/war/wounds/WarArmyAdmin.java new file mode 100644 index 00000000..1eb381c0 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/war/wounds/WarArmyAdmin.java @@ -0,0 +1,191 @@ +package net.tfminecraft.simplefactions.war.wounds; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.war.campaign.runtime.CampaignClock; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.core.WarCommandHelper; + +/** + * Staff tools for battle losses, under {@code /war admin}: + *
    + *
  • {@code losses }: every recorded battle and what the war's end will take;
  • + *
  • {@code losses set|add|remove [reason]}: fixes a + * battle's deaths, and its wounds follow;
  • + *
  • {@code army }: each regiment's slots, wounds per war and what can fight;
  • + *
  • {@code wounds add|remove [reason]}: wounds directly;
  • + *
  • {@code wounds company [set ]}: a mercenary company's wounds.
  • + *
+ */ +public final class WarArmyAdmin { + public static final String LOSSES_USAGE = + "§cUsage: /war admin losses [ attacker|defender set|add|remove [reason]]"; + public static final String ARMY_USAGE = "§cUsage: /war admin army "; + public static final String WOUNDS_USAGE = "§cUsage: /war admin wounds add|remove [reason]" + + " or /war admin wounds company [set ]"; + + private WarArmyAdmin() {} + + /** {@code args} start at {@code admin}. */ + public static List losses(String[] args, String by) { + if (args.length < 3) { + return List.of(LOSSES_USAGE); + } + War war = war(args[2]); + if (war == null) { + return List.of("§cNo active war with id " + args[2]); + } + if (args.length == 3) { + return ledgerLines(war); + } + if (args.length < 7) { + return List.of(LOSSES_USAGE); + } + Integer number = parse(args[3]); + String side = args[4].toLowerCase(Locale.ROOT); + Integer amount = parse(args[6]); + if (number == null || amount == null || !(side.equals(WarLedger.ATTACKER) || side.equals(WarLedger.DEFENDER))) { + return List.of(LOSSES_USAGE); + } + return WarWounds.editDeaths(war, number, side, args[5].toLowerCase(Locale.ROOT), amount, by, reason(args, 7)) + .lines(); + } + + public static List army(String[] args) { + if (args.length != 3) { + return List.of(ARMY_USAGE); + } + Faction faction = FactionManager.getByString(args[2]); + if (faction == null || faction.getMilitary() == null) { + return List.of("§cUnknown faction: " + args[2]); + } + List lines = new ArrayList<>(); + lines.add("§6" + faction.getName() + " §7(" + faction.getId() + ") army"); + List soldiers = faction.getMilitary().getRegiments().stream() + .filter(regiment -> !regiment.isLevy() && !regiment.isEquipment() && !regiment.isMercenary()) + .toList(); + for (Regiment regiment : soldiers) { + int law = Math.min(Math.max(0, regiment.getFreeSlots()), regiment.getCurrentSlots()); + lines.add("§e" + regiment.getId() + "§7: " + regiment.getCurrentSlots() + " slots (" + law + " law, " + + regiment.getPaidSlots() + " paid), " + WarWounds.woundedSlots(faction, regiment) + + " wounded, §a" + WarWounds.availableSlots(faction, regiment) + " can fight"); + for (War war : WarManager.getActive()) { + int lives = war.getLedger().isSettled() ? 0 + : war.getLedger().factionWoundLives(faction.getId(), regiment.getId()); + if (lives > 0) { + lines.add("§8 war " + war.getId() + ": " + lives + " wounded lives"); + } + } + } + Instant now = CampaignClock.now(); + for (Guild guild : faction.getGuildHandler().getGuilds()) { + MercenaryCompany company = guild.getCompany(); + if (company != null) { + lines.add(companyLine(company, now)); + } + } + return lines; + } + + public static List wounds(String[] args, String by) { + if (args.length >= 4 && args[2].equalsIgnoreCase("company")) { + MercenaryCompany company = WarWounds.company(args[3]); + if (company == null) { + return List.of("§cNo mercenary company in guild " + args[3]); + } + if (args.length == 4) { + return List.of(companyLine(company, CampaignClock.now())); + } + Integer lives = args.length == 6 && args[4].equalsIgnoreCase("set") ? parse(args[5]) : null; + if (lives == null || lives < 0) { + return List.of(WOUNDS_USAGE); + } + return WarWounds.setCompanyWounds(company, lives, by).lines(); + } + if (args.length < 7) { + return List.of(WOUNDS_USAGE); + } + War war = war(args[2]); + if (war == null) { + return List.of("§cNo active war with id " + args[2]); + } + Faction faction = FactionManager.getByString(args[3]); + Regiment regiment = faction == null || faction.getMilitary() == null ? null + : faction.getMilitary().getRegiment(args[4]); + if (regiment == null || regiment.isLevy() || regiment.isEquipment()) { + return List.of("§cUnknown faction regiment: " + args[3] + " " + args[4]); + } + String operation = args[5].toLowerCase(Locale.ROOT); + Integer lives = parse(args[6]); + if (lives == null || lives < 0 || !(operation.equals("add") || operation.equals("remove"))) { + return List.of(WOUNDS_USAGE); + } + return WarWounds.adjust(war, faction, regiment, operation.equals("add") ? lives : -lives, by, + reason(args, 7)).lines(); + } + + static List ledgerLines(War war) { + WarLedger ledger = war.getLedger(); + List lines = new ArrayList<>(); + lines.add("§6War " + war.getId() + ": " + ledger.battles().size() + " recorded battles"); + for (WarLedger.BattleEntry entry : ledger.battles()) { + lines.add("§e#" + entry.number() + " §7" + entry.resolution() + ", province " + entry.provinceId() + + ": attacker " + sideText(entry.attacker()) + "; defender " + sideText(entry.defender())); + } + for (WarLedger.Adjustment adjustment : ledger.adjustments()) { + lines.add("§8Adjusted " + adjustment.factionId() + " " + adjustment.regimentId() + " by " + + adjustment.lives() + " lives (" + adjustment.by() + ")"); + } + List preview = WarWounds.settlementPreview(war); + lines.add(preview.isEmpty() ? "§7At war end: no regiment slots are lost." : "§6At war end:"); + for (String line : preview) { + lines.add("§7- " + line); + } + return lines; + } + + private static String sideText(WarLedger.SideRecord side) { + int mercenary = 0; + for (BattleForce force : side.forces()) { + if (force.kind() == ForceKind.MERCENARY) { + mercenary += force.lives(); + } + } + return side.deaths() + " deaths of " + BattleForce.lives(side.forces()) + " lives" + + (mercenary > 0 ? " (" + mercenary + " mercenary)" : ""); + } + + private static String companyLine(MercenaryCompany company, Instant now) { + int slots = company.getSlots(); + return "§bCompany " + company.getName() + " §7(guild " + company.getGuild().getId() + "): " + + company.getWounds().lives(now, slots) + " wounded lives, " + + company.getWounds().woundedSlots(now, slots) + " of " + slots + " slots wounded"; + } + + private static War war(String id) { + War war = WarCommandHelper.parseWarId(id).map(WarManager::getById).orElse(null); + return war != null && war.isActive() ? war : null; + } + + private static Integer parse(String value) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException invalid) { + return null; + } + } + + private static String reason(String[] args, int from) { + return args.length > from ? String.join(" ", Arrays.copyOfRange(args, from, args.length)) : null; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/war/wounds/WarLedger.java b/src/main/java/net/tfminecraft/simplefactions/war/wounds/WarLedger.java new file mode 100644 index 00000000..1236baae --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/war/wounds/WarLedger.java @@ -0,0 +1,217 @@ +package net.tfminecraft.simplefactions.war.wounds; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Everything a war's battles cost, kept with the war. Each battle stores what every force + * brought and each side's deaths; staff corrections are added next to them. Faction wounds are + * worked out from these records every time, so a corrected battle changes them exactly and + * nothing can drift. Mercenary wounds live on their company, since they heal over time. + */ +public final class WarLedger { + public static final String ATTACKER = "attacker"; + public static final String DEFENDER = "defender"; + + /** One war side in one battle. */ + public record SideRecord(int deaths, List forces) { + public SideRecord { + deaths = Math.max(0, deaths); + forces = forces == null ? List.of() : List.copyOf(forces); + } + + public List wounds() { + return WoundAllocator.allocate(deaths, forces); + } + + SideRecord withDeaths(int newDeaths) { + return new SideRecord(newDeaths, forces); + } + } + + /** One resolved battle. {@code number} counts from 1 in the order the battles were resolved. */ + public record BattleEntry( + int number, + String battleId, + String resolution, + String at, + Integer provinceId, + SideRecord attacker, + SideRecord defender) { + public SideRecord side(String side) { + return ATTACKER.equals(side) ? attacker : defender; + } + + BattleEntry withSide(String side, SideRecord record) { + return ATTACKER.equals(side) + ? new BattleEntry(number, battleId, resolution, at, provinceId, record, defender) + : new BattleEntry(number, battleId, resolution, at, provinceId, attacker, record); + } + } + + /** Lives staff added to (positive) or took from (negative) one faction regiment's wounds. */ + public record Adjustment(String factionId, String regimentId, int lives, String by, String reason, String at) { + } + + /** What a staff correction changed, for the record. */ + public record Note(String at, String by, String text) { + } + + /** The saved form, read by Gson. Any list may be missing. */ + public record Data(List battles, List adjustments, List notes, boolean settled) { + } + + private final List battles = new ArrayList<>(); + private final List adjustments = new ArrayList<>(); + private final List notes = new ArrayList<>(); + private boolean settled; + private Map> woundCache; + + public static WarLedger fromData(Data data) { + WarLedger ledger = new WarLedger(); + if (data == null) { + return ledger; + } + if (data.battles() != null) { + ledger.battles.addAll(data.battles()); + } + if (data.adjustments() != null) { + ledger.adjustments.addAll(data.adjustments()); + } + if (data.notes() != null) { + ledger.notes.addAll(data.notes()); + } + ledger.settled = data.settled(); + return ledger; + } + + public Data toData() { + return new Data(List.copyOf(battles), List.copyOf(adjustments), List.copyOf(notes), settled); + } + + public List battles() { + return List.copyOf(battles); + } + + public List adjustments() { + return List.copyOf(adjustments); + } + + public List notes() { + return List.copyOf(notes); + } + + public BattleEntry battle(int number) { + for (BattleEntry entry : battles) { + if (entry.number() == number) { + return entry; + } + } + return null; + } + + public BattleEntry addBattle(String battleId, String resolution, String at, Integer provinceId, + SideRecord attacker, SideRecord defender) { + BattleEntry entry = new BattleEntry(battles.size() + 1, battleId, resolution, at, provinceId, attacker, defender); + battles.add(entry); + woundCache = null; + return entry; + } + + /** Replaces one side's deaths in a recorded battle and returns the battle as it was before. */ + public BattleEntry setDeaths(int number, String side, int deaths) { + for (int i = 0; i < battles.size(); i++) { + BattleEntry entry = battles.get(i); + if (entry.number() == number) { + battles.set(i, entry.withSide(side, entry.side(side).withDeaths(deaths))); + woundCache = null; + return entry; + } + } + return null; + } + + public void addAdjustment(Adjustment adjustment) { + adjustments.add(adjustment); + woundCache = null; + } + + public void addNote(Note note) { + notes.add(note); + } + + public boolean isSettled() { + return settled; + } + + public void markSettled() { + settled = true; + } + + /** + * Paid-slot wounds in lives per faction and regiment, both keyed in lower case. Never below 0 + * for a regiment, whatever staff took off. + */ + public Map> factionWoundLives() { + if (woundCache == null) { + woundCache = computeFactionWounds(); + } + return woundCache; + } + + public int factionWoundLives(String factionId, String regimentId) { + if (factionId == null || regimentId == null) { + return 0; + } + return factionWoundLives() + .getOrDefault(key(factionId), Map.of()) + .getOrDefault(key(regimentId), 0); + } + + private Map> computeFactionWounds() { + Map> totals = new LinkedHashMap<>(); + for (BattleEntry entry : battles) { + addWounds(totals, entry.attacker()); + addWounds(totals, entry.defender()); + } + for (Adjustment adjustment : adjustments) { + totals.computeIfAbsent(key(adjustment.factionId()), ignored -> new LinkedHashMap<>()) + .merge(key(adjustment.regimentId()), (long) adjustment.lives(), Long::sum); + } + Map> wounds = new LinkedHashMap<>(); + for (Map.Entry> faction : totals.entrySet()) { + Map regiments = new LinkedHashMap<>(); + for (Map.Entry regiment : faction.getValue().entrySet()) { + long lives = Math.max(0, Math.min(Integer.MAX_VALUE, regiment.getValue())); + if (lives > 0) { + regiments.put(regiment.getKey(), (int) lives); + } + } + if (!regiments.isEmpty()) { + wounds.put(faction.getKey(), Collections.unmodifiableMap(regiments)); + } + } + // Kept in the order the wounds were first taken, so staff see them in battle order. + return Collections.unmodifiableMap(wounds); + } + + private static void addWounds(Map> totals, SideRecord side) { + if (side == null) { + return; + } + for (Wound wound : side.wounds()) { + if (wound.kind() != ForceKind.MERCENARY && wound.paidLives() > 0) { + totals.computeIfAbsent(key(wound.ownerId()), ignored -> new LinkedHashMap<>()) + .merge(key(wound.regimentId()), (long) wound.paidLives(), Long::sum); + } + } + } + + static String key(String id) { + return id.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/war/wounds/WarWounds.java b/src/main/java/net/tfminecraft/simplefactions/war/wounds/WarWounds.java new file mode 100644 index 00000000..595cc5db --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/war/wounds/WarWounds.java @@ -0,0 +1,327 @@ +package net.tfminecraft.simplefactions.war.wounds; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.LogManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.war.battle.military.BattleCasualtyTally; +import net.tfminecraft.simplefactions.war.campaign.runtime.CampaignClock; +import net.tfminecraft.simplefactions.war.core.War; + +/** + * The one place that turns battle wounds into army strength. + *
    + *
  • A faction regiment's wounded slots are its paid-slot wounds over every active war, one slot + * per {@code deaths_per_regiment_loss} lives, never more than its paid slots. Available slots are + * the rest; only they fight and pay upkeep.
  • + *
  • When a war ends its wounds are settled once: each regiment loses that war's wounded paid + * slots for good. Law slots are never lost.
  • + *
  • Mercenary wounds go straight onto the company and heal over time.
  • + *
+ */ +public final class WarWounds { + private WarWounds() {} + + /** The answer to a staff tool: whether it worked and what to tell them. */ + public record Result(boolean ok, List lines) { + static Result error(String line) { + return new Result(false, List.of(line)); + } + } + + public static int deathsPerSlot() { + return Cache.warBattleDeathsPerRegimentLoss > 0 ? Cache.warBattleDeathsPerRegimentLoss : 5; + } + + /** Paid-slot wounds in lives on this regiment, over every active war not yet settled. */ + public static int woundLives(Faction faction, Regiment regiment) { + if (faction == null || regiment == null || !carriesWounds(regiment)) { + return 0; + } + long lives = 0; + for (War war : WarManager.getActive()) { + WarLedger ledger = war.getLedger(); + if (ledger != null && !ledger.isSettled()) { + lives += ledger.factionWoundLives(faction.getId(), regiment.getId()); + } + } + return (int) Math.min(Integer.MAX_VALUE, lives); + } + + public static int woundedSlots(Faction faction, Regiment regiment) { + if (faction == null || regiment == null) { + return 0; + } + return Math.min(regiment.getPaidSlots(), woundLives(faction, regiment) / deathsPerSlot()); + } + + public static int availablePaidSlots(Faction faction, Regiment regiment) { + return regiment == null ? 0 : regiment.getPaidSlots() - woundedSlots(faction, regiment); + } + + /** Slots that can fight now: every law slot and the unwounded paid ones. */ + public static int availableSlots(Faction faction, Regiment regiment) { + return regiment == null ? 0 : regiment.getCurrentSlots() - woundedSlots(faction, regiment); + } + + private static boolean carriesWounds(Regiment regiment) { + return !regiment.isLevy() && !regiment.isEquipment() && !regiment.isMercenary(); + } + + /** + * Records a resolved battle in the war's ledger, puts the mercenary wounds on their companies + * and returns, per faction regiment, how many slots this battle wounded. + */ + public static BattleCasualtyTally recordBattle(War war, String battleId, String resolution, Integer provinceId, + WarLedger.SideRecord attacker, WarLedger.SideRecord defender) { + WarLedger ledger = war.getLedger(); + Map> before = ledger.factionWoundLives(); + WarLedger.BattleEntry entry = ledger.addBattle(battleId, resolution, CampaignClock.now().toString(), + provinceId, attacker, defender); + BattleCasualtyTally tally = BattleCasualtyTally.empty(); + addTally(tally, WarLedger.ATTACKER, entry.attacker(), before, ledger.factionWoundLives()); + addTally(tally, WarLedger.DEFENDER, entry.defender(), before, ledger.factionWoundLives()); + Map companyLives = new LinkedHashMap<>(); + addCompanyLives(companyLives, entry.attacker().wounds(), 1); + addCompanyLives(companyLives, entry.defender().wounds(), 1); + applyCompanyLives(companyLives); + WarManager.persist(war); + LogManager.war("WAR_WOUNDS_BATTLE war=%s battle=%s number=%s attackerDeaths=%s defenderDeaths=%s", + war.getId(), battleId, entry.number(), entry.attacker().deaths(), entry.defender().deaths()); + return tally; + } + + /** Sets, adds or removes deaths for one side of a recorded battle; wounds follow. */ + public static Result editDeaths(War war, int number, String side, String operation, int amount, String by, + String reason) { + WarLedger ledger = war.getLedger(); + WarLedger.BattleEntry entry = ledger.battle(number); + if (entry == null) { + return Result.error("§cWar " + war.getId() + " has no battle " + number + ". See /war admin losses " + + war.getId() + "."); + } + if (ledger.isSettled()) { + return Result.error("§cThat war's wounds are already settled."); + } + if (amount < 0) { + return Result.error("§cThe amount must be 0 or more."); + } + int deathsBefore = entry.side(side).deaths(); + long deaths = switch (operation) { + case "set" -> amount; + case "add" -> (long) deathsBefore + amount; + case "remove" -> (long) deathsBefore - amount; + default -> Long.MIN_VALUE; + }; + if (deaths == Long.MIN_VALUE) { + return Result.error("§cUse set, add or remove."); + } + int newDeaths = (int) Math.max(0, Math.min(Integer.MAX_VALUE, deaths)); + Map> before = ledger.factionWoundLives(); + List woundsBefore = entry.side(side).wounds(); + ledger.setDeaths(number, side, newDeaths); + Map> after = ledger.factionWoundLives(); + Map companyLives = new LinkedHashMap<>(); + addCompanyLives(companyLives, ledger.battle(number).side(side).wounds(), 1); + addCompanyLives(companyLives, woundsBefore, -1); + applyCompanyLives(companyLives); + String text = "battle " + number + " " + side + " deaths " + deathsBefore + " -> " + newDeaths; + return finishEdit(war, by, reason, text, before, after, companyLives); + } + + /** Adds or removes wounded lives on one faction regiment directly. */ + public static Result adjust(War war, Faction faction, Regiment regiment, int lives, String by, String reason) { + WarLedger ledger = war.getLedger(); + if (ledger.isSettled()) { + return Result.error("§cThat war's wounds are already settled."); + } + Map> before = ledger.factionWoundLives(); + ledger.addAdjustment(new WarLedger.Adjustment(faction.getId(), regiment.getId(), lives, by, reason, + CampaignClock.now().toString())); + String text = (lives >= 0 ? "added " : "removed ") + Math.abs(lives) + " wounded lives on " + + faction.getId() + " " + regiment.getId(); + return finishEdit(war, by, reason, text, before, ledger.factionWoundLives(), Map.of()); + } + + private static Result finishEdit(War war, String by, String reason, String text, + Map> before, Map> after, + Map companyLives) { + String now = CampaignClock.now().toString(); + war.getLedger().addNote(new WarLedger.Note(now, by, text + (reason == null ? "" : " (" + reason + ")"))); + WarManager.persist(war); + LogManager.war("WAR_WOUNDS_EDIT war=%s by=%s: %s reason=%s", war.getId(), by, text, reason); + List lines = new ArrayList<>(); + lines.add("§aWar " + war.getId() + ": " + text + "."); + lines.addAll(slotChanges(before, after)); + for (Map.Entry company : companyLives.entrySet()) { + if (company.getValue() != 0) { + lines.add("§7Company " + company.getKey() + ": " + signed(company.getValue()) + " wounded lives"); + } + } + if (lines.size() == 1) { + lines.add("§7No wounded slots changed."); + } + return new Result(true, lines); + } + + /** Lines naming each regiment whose wounded slots in this war went up or down. */ + static List slotChanges(Map> before, Map> after) { + Set factions = new LinkedHashSet<>(before.keySet()); + factions.addAll(after.keySet()); + List lines = new ArrayList<>(); + for (String faction : factions) { + Set regiments = new LinkedHashSet<>(before.getOrDefault(faction, Map.of()).keySet()); + regiments.addAll(after.getOrDefault(faction, Map.of()).keySet()); + for (String regiment : regiments) { + int was = slots(faction, regiment, before.getOrDefault(faction, Map.of()).getOrDefault(regiment, 0)); + int now = slots(faction, regiment, after.getOrDefault(faction, Map.of()).getOrDefault(regiment, 0)); + if (was != now) { + lines.add("§7" + faction + " " + regiment + ": " + was + " -> " + now + " wounded slots"); + } + } + } + return lines; + } + + /** + * Applies the war's wounds once, when it ends: each regiment loses its wounded paid slots. The + * ledger is marked settled and saved first, so a crash can lose a settlement but never repeat it. + */ + public static List settle(War war) { + WarLedger ledger = war.getLedger(); + List lines = new ArrayList<>(); + if (ledger.isSettled()) { + return lines; + } + Map> wounds = ledger.factionWoundLives(); + ledger.markSettled(); + WarManager.persist(war); + for (Map.Entry> factionWounds : wounds.entrySet()) { + Faction faction = FactionManager.getByString(factionWounds.getKey()); + if (faction == null || faction.getMilitary() == null) { + continue; + } + boolean changed = false; + for (Map.Entry regimentWounds : factionWounds.getValue().entrySet()) { + Regiment regiment = faction.getMilitary().getRegiment(regimentWounds.getKey()); + int lost = regiment == null ? 0 : Math.min(regiment.getPaidSlots(), regimentWounds.getValue() / deathsPerSlot()); + if (lost > 0) { + regiment.setCurrentSlots(regiment.getCurrentSlots() - lost); + changed = true; + lines.add(faction.getId() + " " + regiment.getId() + " -" + lost); + } + } + if (changed) { + new Database().saveFaction(faction); + } + } + LogManager.war("WAR_WOUNDS_SETTLED war=%s lost=%s", war.getId(), lines); + return lines; + } + + /** What {@link #settle} would take now, per faction and regiment, without changing anything. */ + public static List settlementPreview(War war) { + List lines = new ArrayList<>(); + for (Map.Entry> factionWounds : war.getLedger().factionWoundLives().entrySet()) { + Faction faction = FactionManager.getByString(factionWounds.getKey()); + for (Map.Entry regimentWounds : factionWounds.getValue().entrySet()) { + Regiment regiment = faction == null || faction.getMilitary() == null ? null + : faction.getMilitary().getRegiment(regimentWounds.getKey()); + int slots = regimentWounds.getValue() / deathsPerSlot(); + int lost = regiment == null ? 0 : Math.min(regiment.getPaidSlots(), slots); + lines.add(factionWounds.getKey() + " " + regimentWounds.getKey() + ": " + regimentWounds.getValue() + + " lives = " + slots + " slots, loses " + lost); + } + } + return lines; + } + + public static MercenaryCompany company(String guildId) { + Guild guild = guildId == null ? null : FactionManager.getGuildByString(guildId); + return guild == null ? null : guild.getCompany(); + } + + /** Sets a company's wounded lives as staff, kept between 0 and its size. */ + public static Result setCompanyWounds(MercenaryCompany company, int lives, String by) { + Instant now = CampaignClock.now(); + int before = company.getWounds().lives(now, company.getSlots()); + company.getWounds().set(lives, now, company.getSlots()); + int after = company.getWounds().lives(now, company.getSlots()); + saveCompany(company); + LogManager.war("WAR_WOUNDS_COMPANY company=%s by=%s %s -> %s", company.getGuild().getId(), by, before, after); + return new Result(true, List.of("§aCompany " + company.getName() + ": wounded lives " + before + " -> " + after + + " (" + company.getWounds().woundedSlots(now, company.getSlots()) + " of " + company.getSlots() + + " slots wounded).")); + } + + private static void addTally(BattleCasualtyTally tally, String side, WarLedger.SideRecord record, + Map> before, Map> after) { + Set seen = new LinkedHashSet<>(); + for (Wound wound : record.wounds()) { + String faction = WarLedger.key(wound.ownerId()); + String regiment = WarLedger.key(wound.regimentId()); + if (wound.kind() != ForceKind.MERCENARY && seen.add(faction + "|" + regiment)) { + int was = slots(faction, regiment, before.getOrDefault(faction, Map.of()).getOrDefault(regiment, 0)); + int now = slots(faction, regiment, after.getOrDefault(faction, Map.of()).getOrDefault(regiment, 0)); + Faction owner = FactionManager.getByString(wound.ownerId()); + Regiment found = owner == null || owner.getMilitary() == null ? null + : owner.getMilitary().getRegiment(wound.regimentId()); + if (now > was && found != null) { + tally.add(owner, side, found, now - was); + } + } + } + } + + /** Wounded lives as slots, never more than the regiment's paid slots when it can be found. */ + static int slots(String factionId, String regimentId, int lives) { + Faction faction = FactionManager.getByString(factionId); + Regiment regiment = faction == null || faction.getMilitary() == null ? null + : faction.getMilitary().getRegiment(regimentId); + int slots = lives / deathsPerSlot(); + return regiment == null ? slots : Math.min(slots, regiment.getPaidSlots()); + } + + private static void addCompanyLives(Map companyLives, List wounds, int sign) { + for (Wound wound : wounds) { + if (wound.kind() == ForceKind.MERCENARY) { + companyLives.merge(wound.ownerId(), sign * wound.lives(), Integer::sum); + } + } + } + + private static void applyCompanyLives(Map companyLives) { + Instant now = CampaignClock.now(); + for (Map.Entry entry : companyLives.entrySet()) { + MercenaryCompany company = company(entry.getKey()); + if (company != null && entry.getValue() != 0) { + company.getWounds().add(entry.getValue(), now, company.getSlots()); + saveCompany(company); + } + } + } + + private static void saveCompany(MercenaryCompany company) { + Faction host = company.getGuild().getFaction(); + if (host != null) { + new Database().saveFaction(host); + } + } + + private static String signed(int value) { + return value > 0 ? "+" + value : String.valueOf(value); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/war/wounds/Wound.java b/src/main/java/net/tfminecraft/simplefactions/war/wounds/Wound.java new file mode 100644 index 00000000..d5dac876 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/war/wounds/Wound.java @@ -0,0 +1,11 @@ +package net.tfminecraft.simplefactions.war.wounds; + +/** + * Lives one regiment of one force lost in a battle. The law share is gone once the battle ends; + * the paid share stays as wounds. + */ +public record Wound(ForceKind kind, String ownerId, String regimentId, int lawLives, int paidLives) { + public int lives() { + return lawLives + paidLives; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/war/wounds/WoundAllocator.java b/src/main/java/net/tfminecraft/simplefactions/war/wounds/WoundAllocator.java new file mode 100644 index 00000000..6fdb99cc --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/war/wounds/WoundAllocator.java @@ -0,0 +1,101 @@ +package net.tfminecraft.simplefactions.war.wounds; + +import java.util.ArrayList; +import java.util.List; + +/** + * Turns one side's deaths in one battle into wounds, in three steps: + *
    + *
  1. between forces, in proportion to the lives each brought;
  2. + *
  3. inside a force, militia first, then its other regiments in proportion;
  4. + *
  5. inside a regiment, law and paid slots in proportion.
  6. + *
+ * No force takes more than it brought, so deaths beyond the pool (staff-added lives, deaths at + * zero lives) cost nobody. The result only depends on its inputs. + */ +public final class WoundAllocator { + private WoundAllocator() {} + + public static List allocate(int deaths, List forces) { + List wounds = new ArrayList<>(); + if (deaths <= 0 || forces == null || forces.isEmpty()) { + return wounds; + } + long[] forceLives = new long[forces.size()]; + for (int i = 0; i < forces.size(); i++) { + forceLives[i] = forces.get(i).lives(); + } + long[] forceShares = share(deaths, forceLives); + for (int i = 0; i < forces.size(); i++) { + if (forceShares[i] > 0) { + allocateForce(forces.get(i), forceShares[i], wounds); + } + } + return wounds; + } + + private static void allocateForce(BattleForce force, long share, List wounds) { + List parts = force.parts(); + long[] militia = new long[parts.size()]; + long[] others = new long[parts.size()]; + long militiaLives = 0; + for (int i = 0; i < parts.size(); i++) { + long lives = (long) parts.get(i).slots() * force.livesPerSlot(); + if (parts.get(i).militia()) { + militia[i] = lives; + militiaLives += lives; + } else { + others[i] = lives; + } + } + long onMilitia = Math.min(share, militiaLives); + long[] militiaShares = share(onMilitia, militia); + long[] otherShares = share(share - onMilitia, others); + for (int i = 0; i < parts.size(); i++) { + long partShare = militiaShares[i] + otherShares[i]; + if (partShare <= 0) { + continue; + } + ForcePart part = parts.get(i); + long[] split = share(partShare, new long[] { + (long) part.lawSlots() * force.livesPerSlot(), + (long) part.paidSlots() * force.livesPerSlot() }); + wounds.add(new Wound(force.kind(), force.ownerId(), part.regimentId(), (int) split[0], (int) split[1])); + } + } + + /** + * Splits {@code total} over {@code weights} by largest remainder, never giving an entry more + * than its weight. Ties go to the earlier entry, so the split is the same every time. + */ + public static long[] share(long total, long[] weights) { + long[] shares = new long[weights.length]; + long weightSum = 0; + for (long weight : weights) { + weightSum += weight; + } + long amount = Math.min(Math.max(0, total), weightSum); + if (amount == 0) { + return shares; + } + double[] remainders = new double[weights.length]; + long assigned = 0; + for (int i = 0; i < weights.length; i++) { + double exact = (double) amount * weights[i] / weightSum; + shares[i] = (long) Math.floor(exact); + remainders[i] = exact - shares[i]; + assigned += shares[i]; + } + for (long left = amount - assigned; left > 0; left--) { + int best = -1; + for (int i = 0; i < weights.length; i++) { + if (shares[i] < weights[i] && (best < 0 || remainders[i] > remainders[best])) { + best = i; + } + } + shares[best]++; + remainders[best] = -1; + } + return shares; + } +} diff --git a/src/main/resources/war.yml b/src/main/resources/war.yml index c1171ea4..ca0fc659 100644 --- a/src/main/resources/war.yml +++ b/src/main/resources/war.yml @@ -128,11 +128,19 @@ war: loser_loss_fraction: 0.5 devmode: phantom_count: 10 + # Every slot a force brings puts lives_per_regiment lives in its side's shared pool. When a + # battle ends, the side's deaths become wounds: split between forces by the lives each brought, + # militia first inside a force, then law and paid slots in proportion. Law slots are full again + # after every battle. Every deaths_per_regiment_loss wounded lives make one paid slot wounded: + # it brings no lives and costs no upkeep, and it is lost when the war ends. Mercenary companies + # never lose slots; their wounds heal by mercenary_heal_percent of the company's slots every + # mercenary_heal_hours. battle_military: lives_per_regiment: 5 min_side_lives: 1 - # Recorded deaths on a side become regiment losses at this rate. Leftover deaths are not lost. deaths_per_regiment_loss: 5 + mercenary_heal_percent: 25 + mercenary_heal_hours: 6 # One reward per player for every fighter still present when the battle # ended, win, lose, or draw. The per-battle toggle lives in the battle editor; # campaign raids ship with it off. diff --git a/src/test/java/net/tfminecraft/simplefactions/diplomacy/RealmRelationsBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/diplomacy/RealmRelationsBoundaryCoverageTest.java index 737658b5..ad977be8 100644 --- a/src/test/java/net/tfminecraft/simplefactions/diplomacy/RealmRelationsBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/diplomacy/RealmRelationsBoundaryCoverageTest.java @@ -21,10 +21,11 @@ import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; import net.tfminecraft.simplefactions.war.civilwar.CivilWarSnapshot; -import net.tfminecraft.simplefactions.war.commitment.LevySnapshotCalculator; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.core.WarCommitment; +import net.tfminecraft.simplefactions.war.battle.military.BattleForces; +import net.tfminecraft.simplefactions.war.battle.military.PoolMode; +import net.tfminecraft.simplefactions.war.wounds.BattleForce; +import net.tfminecraft.simplefactions.war.wounds.ForceKind; import net.tfminecraft.simplefactions.war.core.WarMapper; import net.tfminecraft.simplefactions.war.enums.WarGoalType; import net.tfminecraft.simplefactions.war.pathfinder.BelligerentTerritory; @@ -39,14 +40,11 @@ class RealmRelationsBoundaryCoverageTest { private static final int WAR_ID = 551091; private FactionDomainFixture domain; - private List previousCommitments; private Locale previousLocale; @BeforeEach void setUp() { previousLocale = Locale.getDefault(); - previousCommitments = new ArrayList<>(WarCommitmentService.getCommitmentsForWar(WAR_ID)); - WarCommitmentService.clearCommitments(WAR_ID); domain = new FactionDomainFixture(); } @@ -55,13 +53,13 @@ void tearDown() { try { domain.close(); } finally { - WarCommitmentService.restoreCommitments(WAR_ID, previousCommitments); + WarManager.get().removeIf(war -> war.getId() == WAR_ID); Locale.setDefault(previousLocale); } } @Test - void lateSubjectLeviesUseTheSameCaseIndependentIdentityAsWarCommitments() { + void lateSubjectsLendToTheirUppercaseHolderUnderTurkishLocale() { domain.lawGroup("conscription", Map.of("effects.faction.modifiers", List.of("LEVY(100)"))); Faction holder = domain.saved("IRON", "IronLeader"); Faction enemy = domain.saved("enemy", "EnemyLeader"); @@ -72,18 +70,16 @@ void lateSubjectLeviesUseTheSameCaseIndependentIdentityAsWarCommitments() { Regiment guard = regiment(source, "guard", 3); Locale.setDefault(Locale.forLanguageTag("tr-TR")); - List rows = WarCommitmentService.snapshotLevyForFighter(war, holder); + List levies = levies(war); - assertEquals(1, rows.size(), "A late subject must still contribute to its uppercase-ID holder"); - assertEquals(holder.getId(), rows.getFirst().factionId()); - assertEquals(source.getId(), rows.getFirst().sourceFactionId()); - assertEquals(1, rows.getFirst().count(), "The one actual member caps the contribution"); - assertEquals(3, guard.getCurrentSlots(), "Snapshotting must not consume the live army"); - assertTrue(WarCommitmentService.snapshotLevyForFighter(war, holder).isEmpty()); + assertEquals(1, levies.size(), "A late subject must still lend to its uppercase-ID holder"); + assertEquals(source.getId(), levies.getFirst().ownerId()); + assertEquals(1, levies.getFirst().slots(), "The one actual member caps the levy"); + assertEquals(3, guard.getCurrentSlots(), "Lending must not consume the live army"); } @Test - void releasingAnUppercaseSubjectRemovesItsCommittedLevyUnderTurkishLocale() { + void aReleasedSubjectLendsNothingButKeepsItsSoldiers() { domain.lawGroup("conscription", Map.of("effects.faction.modifiers", List.of("LEVY(100)"))); Faction holder = domain.saved("holder", "HolderLeader"); War war = new War(WAR_ID, holder, domain.saved("enemy", "EnemyLeader")); @@ -91,15 +87,22 @@ void releasingAnUppercaseSubjectRemovesItsCommittedLevyUnderTurkishLocale() { Faction source = domain.saved("IRIS", "IrisLeader"); domain.subject(holder, source); Regiment guard = regiment(source, "guard", 2); - assertEquals(1, WarCommitmentService.snapshotLevyForFighter(war, holder).size()); + guard.setFreeSlots(0); + assertEquals(1, levies(war).size()); Locale.setDefault(Locale.forLanguageTag("tr-TR")); - WarCommitmentService.removeLevySubtree(source); + RelationManager.endVassalage(holder, source, false); - assertTrue(WarCommitmentService.getCommitmentsForWar(WAR_ID).isEmpty()); + assertTrue(levies(war).isEmpty()); assertEquals(2, guard.getCurrentSlots(), "Releasing a levy cannot erase its source soldiers"); } + private static List levies(War war) { + return BattleForces.build(war, null, war.getAttackers(), PoolMode.OFFENSIVE, null, null).stream() + .filter(force -> force.kind() == ForceKind.LEVY) + .toList(); + } + @Test void incompleteSavedParticipantsCannotPromoteAnAllyToMainDefender() { Faction attacker = domain.saved("attacker", "AttackerLeader"); @@ -294,11 +297,13 @@ void nestedLevyRatesCapContributionsAndTheDisplayedLedgerTracksAdjustments() { "conscription", "half", Map.of("effects.faction.modifiers", List.of("LEVY(50)"))); middle.getLawHandler().getGroup("conscription").setCurrent(half); Regiment guard = regiment(source, "guard", 5); - assertEquals(2, LevySnapshotCalculator.directLevyContribution(source)); - assertEquals(1, LevySnapshotCalculator.levyContribution(source, holder)); - assertSame( - middle, - LevySnapshotCalculator.findNearestFighterHolder(source, Set.of("middle", "holder"))); + War war = new War(WAR_ID, holder, domain.saved("enemy", "EnemyLeader")); + WarManager.get().add(war); + List fightingSubjects = war.getAttackers().getMainParticipants().getFirst().getSubjects(); + assertEquals(2, levySlots(war, source), "A fighting middle overlord receives the levy whole"); + fightingSubjects.remove(middle); + // Two members cap the levy at 2, and the non-fighting middle overlord passes on half of it. + assertEquals(1, levySlots(war, source)); List entries = holder.getMilitary().getLevies(); assertEquals( 1, @@ -342,28 +347,24 @@ void levyQueriesRefuseMissingPathsAndZeroIntermediateContributions() { Faction middle = domain.saved("middle", "MiddleLeader"); Faction source = domain.saved("source", "SourceLeader"); regiment(source, "guard", 2); - assertEquals(0, LevySnapshotCalculator.levyContribution(source, holder)); - assertNull(LevySnapshotCalculator.findNearestFighterHolder(source, Set.of("holder"))); + War war = new War(WAR_ID, holder, domain.saved("enemy", "EnemyLeader")); + WarManager.get().add(war); + assertEquals(0, levySlots(war, source), "No relation, no levy"); domain.subject(holder, middle); domain.subject(middle, source); var none = domain.law("conscription", "none", Map.of("effects.faction.modifiers", List.of("LEVY(0)"))); middle.getLawHandler().getGroup("conscription").setCurrent(none); - assertEquals(0, LevySnapshotCalculator.levyContribution(source, holder)); - assertTrue( - LevySnapshotCalculator.collectLevyRowsForFighter(holder, Set.of("holder")).isEmpty()); - assertTrue( - LevySnapshotCalculator.collectLevyRowsForFighter(holder, Set.of("unrelated_fighter")) - .isEmpty()); + assertEquals(0, levySlots(war, source), "A 0% overlord passes nothing on"); FactionManager.factions.remove(middle); - assertNull(LevySnapshotCalculator.findNearestFighterHolder(source, Set.of("holder"))); - assertEquals(0, LevySnapshotCalculator.levyContribution(source, holder)); - assertEquals(0, LevySnapshotCalculator.directLevyContribution(null)); - assertEquals(0, LevySnapshotCalculator.levyContribution(source, null)); - assertNull(LevySnapshotCalculator.findNearestFighterHolder(source, Set.of())); - assertTrue(LevySnapshotCalculator.collectLevyRows(null).isEmpty()); - assertTrue(LevySnapshotCalculator.collectLevyRowsForFighter(holder, Set.of()).isEmpty()); - assertTrue(LevySnapshotCalculator.collectSubjectSubtreeIds(null).isEmpty()); + assertEquals(0, levySlots(war, source)); + } + + private static int levySlots(War war, Faction source) { + return levies(war).stream() + .filter(force -> force.ownerId().equals(source.getId())) + .mapToInt(BattleForce::slots) + .sum(); } @Test diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepWoundsTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepWoundsTest.java new file mode 100644 index 00000000..008fb36f --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepWoundsTest.java @@ -0,0 +1,44 @@ +package net.tfminecraft.simplefactions.economy.settlement; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.wounds.WarLedger; + +class FactionUpkeepWoundsTest { + /** Unpaid upkeep disbands only slots that cost something; wounded slots wait for the war's end. */ + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void unpaidUpkeepKeepsWoundedSlots(boolean bank) throws Exception { + try (var fixture = new FactionDomainFixture()) { + var faction = fixture.saved("realm", UUID.randomUUID().toString()); + var enemy = fixture.saved("enemy", UUID.randomUUID().toString()); + Regiment guard = new Regiment(fixture.regiment("guard", false, 1, 5)); + guard.setCurrentSlots(6); + faction.getMilitary().getRegiments().add(guard); + War war = new War(990088, faction, enemy); + war.getLedger().addAdjustment(new WarLedger.Adjustment("realm", "guard", 10, "staff", null, "t")); + WarManager.get().add(war); + try { + if (!bank) { + SettlementGuilds.capital(faction).setBank(null); + } + var upkeep = FactionUpkeepPlan.calculate(faction, 0); + // 1 law slot, 2 wounded paid slots kept; the 3 healthy paid slots cannot be paid for. + assertEquals(Map.of("guard", 3), upkeep.regimentSlots()); + assertEquals(0, upkeep.charge()); + } finally { + WarManager.get().remove(war); + } + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerCoverageTest.java index 645779ae..a60193ab 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/InventoryManagerCoverageTest.java @@ -1356,6 +1356,32 @@ void regimentSizeChangeRequiresConfirmationAndKeepsTheSelectedRegiment(boolean c verify(manager.militaryView).militaryView(null, player, faction, true); } + @Test + void woundedRegimentSlotsCannotBeDisbandedUntilTheWarEnds() { + Military military = mock(Military.class); + Regiment regiment = mock(Regiment.class); + when(faction.getMilitary()).thenReturn(military); + when(military.getRegiment("archers")).thenReturn(regiment); + when(regiment.getId()).thenReturn("archers"); + when(regiment.getPaidSlots()).thenReturn(1); + net.tfminecraft.simplefactions.war.core.War war = mock(net.tfminecraft.simplefactions.war.core.War.class); + net.tfminecraft.simplefactions.war.wounds.WarLedger ledger = new net.tfminecraft.simplefactions.war.wounds.WarLedger(); + ledger.addAdjustment(new net.tfminecraft.simplefactions.war.wounds.WarLedger.Adjustment( + "realm", "archers", 5, "staff", null, "t")); + when(war.getLedger()).thenReturn(ledger); + when(war.isActive()).thenReturn(true); + net.tfminecraft.simplefactions.managers.WarManager.get().add(war); + try { + manager.confirming.put(player, faction); + manager.confirmView(player, faction, "regiment", "archers"); + manager.clickButton(gui.click(player, 11)); + verify(regiment, never()).sizeDecrease(); + verify(player).sendMessage("§cWounded slots cannot be disbanded. They are lost when the war ends."); + } finally { + net.tfminecraft.simplefactions.managers.WarManager.get().remove(war); + } + } + @ParameterizedTest @ValueSource(strings = {"faction", "leader", "regiment"}) void staleRegimentConfirmationCannotChangeTheRegiment(String missing) { diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/WarManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/WarManagerCoverageTest.java index d01cf38d..14f61e7a 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/WarManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/WarManagerCoverageTest.java @@ -31,9 +31,10 @@ import net.tfminecraft.simplefactions.war.battle.engine.core.Battle; import net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager; import net.tfminecraft.simplefactions.war.civilwar.CivilWarSnapshot; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.core.WarCommitment; +import net.tfminecraft.simplefactions.war.wounds.TestForces; +import net.tfminecraft.simplefactions.war.wounds.WarLedger; +import net.tfminecraft.simplefactions.war.wounds.WarWounds; import net.tfminecraft.simplefactions.war.enums.BattleSchedulePhase; import net.tfminecraft.simplefactions.war.enums.WarEndReason; import net.tfminecraft.simplefactions.war.enums.WarGoalType; @@ -51,7 +52,6 @@ class WarManagerCoverageTest { private FactionDomainFixture fixture; private PersistenceFilesFixture files; private Map previousRequests; - private Map> previousCommitments; private List previousBattles; private Object previousDeclareError; private RelationType alliance; @@ -64,8 +64,6 @@ void setup() throws Exception { fixture = new FactionDomainFixture(); previousRequests = new HashMap<>(requests()); requests().clear(); - previousCommitments = new LinkedHashMap<>(commitments()); - commitments().clear(); previousBattles = new ArrayList<>(BattleManager.get()); BattleManager.get().clear(); previousDeclareError = field(WarManager.class, "lastDeclareError").get(null); @@ -91,10 +89,6 @@ void close() throws Exception { requests().clear(); requests().putAll(previousRequests); } - if (previousCommitments != null) { - commitments().clear(); - commitments().putAll(previousCommitments); - } if (previousBattles != null) { BattleManager.get().clear(); BattleManager.get().addAll(previousBattles); @@ -119,11 +113,6 @@ private static Map requests() throws Exception { return (Map) field(RequestManager.class, "requests").get(null); } - @SuppressWarnings("unchecked") - private static Map> commitments() throws Exception { - return (Map>) - field(WarCommitmentService.class, "commitmentsByWar").get(null); - } private void ally(Faction first, Faction second) { first.setRelation(second, new Relation(alliance, RelationLoader.getDefaultAttitude())); @@ -188,7 +177,6 @@ void disabledProvincesAndInvalidDeclarationsLeaveWarStateUnchanged() { assertNull(WarManager.declareWar(attacker, attacker, WarGoalType.WAR, null, null)); assertTrue(WarManager.getLastDeclareError().contains("own faction")); assertTrue(WarManager.get().isEmpty()); - assertTrue(WarManager.getCommitmentsForWar(0).isEmpty()); } @Test @@ -203,7 +191,6 @@ void declarationsRequireOffensiveRegimentsBeforeCreatingAnyWar() { assertTrue(WarManager.getLastDeclareError().contains("offensive regiment")); assertTrue(WarManager.get().isEmpty()); - assertTrue(WarManager.getCommitmentsForWar(0).isEmpty()); } @Test @@ -219,7 +206,6 @@ void aDisconnectedGraphDeniesWarBeforeCommittingTroopsOrSaving() { assertTrue(WarManager.getLastDeclareError().contains("no campaign route")); assertTrue(WarManager.get().isEmpty()); - assertTrue(WarManager.getCommitmentsForWar(0).isEmpty()); assertFalse(Files.exists(files.root.resolve("Wars/war_0.json"))); } @@ -280,7 +266,7 @@ void declarationsPreserveTheirGoalMetadataAndCommitTheRealParticipants(WarGoalTy assertEquals(governmentId, war.getGovernmentLawId()); assertEquals(leadershipId, war.getLeadershipLawId()); assertEquals(settlementId, war.getTargetSettlementId()); - assertFalse(WarManager.getCommitmentsForWar(0).isEmpty()); + assertTrue(war.getLedger().battles().isEmpty(), "A new war has fought no battles"); War stored = new Database().loadWars().getFirst(); assertEquals(goal, stored.getGoal()); assertEquals(war.getCampaignProvinces(), stored.getCampaignProvinces()); @@ -364,8 +350,10 @@ void endingAWarDeletesItsSaveAndNotifiesOnlyItsOnlineMembers(WarEndReason reason Player unrelated = fixture.player("Cara"); when(bob.isOnline()).thenReturn(false); War war = WarManager.addWar(new War(39, attacker, defender)); - WarCommitmentService.commitAllParticipants(war); - assertFalse(WarManager.getCommitmentsForWar(39).isEmpty()); + Regiment guard = attacker.getMilitary().getRegiment("guard"); + guard.setCurrentSlots(guard.getFreeSlots() + 2); + woundBattle(war, "attacker", "guard", 10); + assertEquals(2, WarWounds.woundedSlots(attacker, guard)); clearInvocations(alice, bob, unrelated); WarManager.endWar(war, reason); @@ -373,7 +361,9 @@ void endingAWarDeletesItsSaveAndNotifiesOnlyItsOnlineMembers(WarEndReason reason assertFalse(war.isActive()); assertEquals(reason, war.getEndReason()); assertNull(WarManager.getById(39)); - assertTrue(WarManager.getCommitmentsForWar(39).isEmpty()); + assertTrue(war.getLedger().isSettled()); + assertEquals(guard.getFreeSlots(), guard.getCurrentSlots(), "The wounded paid slots are lost; law slots stay"); + assertEquals(0, WarWounds.woundedSlots(attacker, guard)); assertFalse(Files.exists(files.root.resolve("Wars/war_39.json"))); String message = switch (reason) { @@ -451,7 +441,6 @@ void crossingAnEnemyPortBlockadeWithoutANavyCannotStartWarOrCivilWar(boolean civ WarManager.getLastDeclareError().toLowerCase(java.util.Locale.ROOT).contains("port"), WarManager.getLastDeclareError()); assertTrue(WarManager.get().isEmpty()); - assertTrue(WarManager.getCommitmentsForWar(0).isEmpty()); } @Test @@ -518,23 +507,13 @@ void aCivilWarStoresUniqueMainsAndBackersAndRefreshesRebelLawSlotsAfterReload(in assertTrue(war.isParticipating(backer)); int rebelSlots = attacker.getMilitary().getManpower(true); assertEquals(paidSlots, rebelSlots); - assertEquals( - rebelSlots, - WarManager.getCommitmentsForWar(war.getId()).stream() - .filter(row -> attacker.getId().equals(row.factionId())) - .mapToInt(WarCommitment::count) - .sum()); + assertTrue(war.getLedger().battles().isEmpty()); WarManager.start(); War restored = WarManager.getById(0); assertNotSame(war, restored); assertEquals("rebels", restored.getCivilWarSnapshot().getTempRebelFactionId()); assertEquals(rebelSlots, attacker.getMilitary().getManpower(true)); - assertEquals( - paidSlots, - WarManager.getCommitmentsForWar(restored.getId()).stream() - .filter(row -> attacker.getId().equals(row.factionId())) - .mapToInt(WarCommitment::count) - .sum()); + assertTrue(restored.getLedger().battles().isEmpty()); assertTrue(restored.isParticipating(extra)); assertTrue(restored.isParticipating(backer)); } @@ -588,24 +567,27 @@ void missingProvinceServicesPreventCampaignMutationAndWarCreation() { } @Test - void endingOneOfSeveralWarsPreservesOtherWarsAndTheirTroopCommitments() { + void endingOneOfSeveralWarsSettlesOnlyItsOwnWounds() { Faction attacker = fixture.saved("attacker", "Alice"); Faction defender = fixture.saved("defender", "Bob"); Faction other = fixture.saved("other", "Cara"); War retained = WarManager.addWar(new War(91, attacker, defender)); War ended = WarManager.addWar(new War(92, attacker, other)); - WarCommitmentService.commitAllParticipants(retained); - WarCommitmentService.commitAllParticipants(ended); - var retainedCommitments = new ArrayList<>(WarManager.getCommitmentsForWar(91)); - assertFalse(retainedCommitments.isEmpty()); + Regiment guard = attacker.getMilitary().getRegiment("guard"); + guard.setCurrentSlots(guard.getFreeSlots() + 3); + woundBattle(retained, "attacker", "guard", 5); + woundBattle(ended, "attacker", "guard", 10); + assertEquals(3, WarWounds.woundedSlots(attacker, guard), "One pool: both wars wound the same regiment"); WarManager.endWar(ended); assertSame(retained, WarManager.getById(91)); assertTrue(retained.isActive()); assertNull(WarManager.getById(92)); - assertEquals(retainedCommitments, WarManager.getCommitmentsForWar(91)); - assertTrue(WarManager.getCommitmentsForWar(92).isEmpty()); + assertTrue(ended.getLedger().isSettled()); + assertFalse(retained.getLedger().isSettled()); + assertEquals(guard.getFreeSlots() + 1, guard.getCurrentSlots(), "Only the ended war's 2 slots are lost"); + assertEquals(1, WarWounds.woundedSlots(attacker, guard), "The other war's wound stays pending"); assertTrue(Files.exists(files.root.resolve("Wars/war_91.json"))); assertFalse(Files.exists(files.root.resolve("Wars/war_92.json"))); } @@ -626,4 +608,16 @@ void aLegacyRaidNeedsNoCampaignRouteWhenProvinceServicesAreUnavailable() { assertTrue(WarManager.get().isEmpty()); assertFalse(Files.exists(files.root.resolve("Wars/war_93.json"))); } + + /** Records one battle in which {@code side}'s faction loses {@code deaths} lives on paid slots of a regiment. */ + private static void woundBattle(War war, String side, String regiment, int deaths) { + String factionId = "attacker".equals(side) ? war.getAttackers().getMainParticipants().getFirst().getLeader().getId() + : war.getDefenders().getMainParticipants().getFirst().getLeader().getId(); + WarLedger.SideRecord wounded = new WarLedger.SideRecord( + deaths, List.of(TestForces.faction(factionId, TestForces.paid(regiment, 100)))); + WarLedger.SideRecord untouched = new WarLedger.SideRecord(0, List.of()); + war.getLedger().addBattle("battle", "fought", "2026-01-01T00:00:00Z", 1, + "attacker".equals(side) ? wounded : untouched, "attacker".equals(side) ? untouched : wounded); + } + } diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/WarManagerStartCivilWarTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/WarManagerStartCivilWarTest.java index 04c1a2c0..3cb7a6a6 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/WarManagerStartCivilWarTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/WarManagerStartCivilWarTest.java @@ -23,7 +23,6 @@ import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.war.campaign.progression.CampaignNavyGate; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; import net.tfminecraft.simplefactions.war.core.Participant; import net.tfminecraft.simplefactions.war.core.War; import net.tfminecraft.simplefactions.war.declare.WarGoalValidator; @@ -59,7 +58,6 @@ void startCivilWar_skipsGoalValidator_setsMovementIdAndCivilWarFlag() { try (MockedStatic validator = mockStatic(WarGoalValidator.class); MockedStatic wars = mockStatic(WarManager.class, CALLS_REAL_METHODS); MockedStatic relations = mockStatic(RelationManager.class); - MockedStatic commit = mockStatic(WarCommitmentService.class); MockedStatic navy = mockStatic(CampaignNavyGate.class); MockedStatic bukkit = mockStatic(Bukkit.class)) { wars.when(() -> WarManager.populateCampaignIfNeeded(any())).thenReturn(true); @@ -102,7 +100,6 @@ void startCivilWar_putsForeignBackerOnBackersNotAllies() { try (MockedStatic validator = mockStatic(WarGoalValidator.class); MockedStatic wars = mockStatic(WarManager.class, CALLS_REAL_METHODS); MockedStatic relations = mockStatic(RelationManager.class); - MockedStatic commit = mockStatic(WarCommitmentService.class); MockedStatic navy = mockStatic(CampaignNavyGate.class); MockedStatic bukkit = mockStatic(Bukkit.class)) { wars.when(() -> WarManager.populateCampaignIfNeeded(any())).thenReturn(true); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ArmyMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ArmyMenusCoverageTest.java index 298547be..f18ff83c 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ArmyMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/ArmyMenusCoverageTest.java @@ -567,6 +567,18 @@ void regimentSummaryIncludesReducedUpkeepFrozenTimersAndBoundedLevyContributors( assertTrue(lore(military.creator.createMilitarySummary(faction)).contains("6.00d (From 8.00)")); assertTrue( lore(military.creator.createRegimentIcon(faction, r)).contains("6.00d (1.50d per slot)")); + net.tfminecraft.simplefactions.war.core.War war = + new net.tfminecraft.simplefactions.war.core.War(990077, faction, faction); + war.getLedger().addAdjustment(new net.tfminecraft.simplefactions.war.wounds.WarLedger.Adjustment( + faction.getId(), "infantry", 5, "staff", null, "t")); + net.tfminecraft.simplefactions.managers.WarManager.get().add(war); + try { + String wounded = lore(military.creator.createRegimentIcon(faction, r)); + assertTrue(wounded.contains("Wounded: 1 (cannot fight, lost when the war ends)"), wounded); + assertTrue(wounded.contains("4.50d (1.50d per slot)"), wounded); + } finally { + net.tfminecraft.simplefactions.managers.WarManager.get().remove(war); + } Regiment levy = regiment("levy", true, false, 0, 0); for (int n = 0; n < 12; n++) levy.addLevyEntry(new LevyEntry(faction, n + 1)); String levyLore = lore(military.creator.createRegimentIcon(faction, levy)); diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyPersistenceTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyPersistenceTest.java index 7ccad905..a85a4a50 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyPersistenceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompanyPersistenceTest.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.mercenary.company; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -118,6 +119,31 @@ void loadedCompanyKeepsFollowingTheGuildLeader() { assertFalse(loaded.isLeader("Ivar")); } + @Test + void woundsAndWhenTheyStartedHealingRoundTrip() { + CompanyFixture fixture = new CompanyFixture(0); + MercenaryCompany company = new MercenaryCompany( + fixture.guild, "Hired Blades", CompanyFixture.companyRegiment(), 0); + java.time.Instant now = java.time.Instant.parse("2026-11-11T12:00:00Z"); + assertNull(company.serialize().woundLives); + assertEquals(1, company.getHealthySlots(now)); + company.getWounds().add(5, now, company.getSlots()); + + MercenaryCompanyData data = company.serialize(); + assertEquals(5, data.woundLives); + assertEquals("2026-11-11T12:00:00Z", data.woundsHealFrom); + MercenaryCompany loaded = new MercenaryCompany(fixture.guild, data, CompanyFixture.companyRegiment()); + + assertEquals(5, loaded.getWounds().storedLives()); + assertEquals(now, loaded.getWounds().healFrom()); + assertEquals(0, loaded.getHealthySlots(now)); + data.woundsHealFrom = null; + assertEquals(java.time.Instant.EPOCH, + new MercenaryCompany(fixture.guild, data, CompanyFixture.companyRegiment()).getWounds().healFrom()); + data.woundLives = 0; + assertEquals(0, new MercenaryCompany(fixture.guild, data, CompanyFixture.companyRegiment()).getWounds().storedLives()); + } + private static MercenaryCompany reload(CompanyFixture fixture, MercenaryCompany company) { return new MercenaryCompany( fixture.guild, company.serialize(), CompanyFixture.companyRegiment()); diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryLivesTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryLivesTest.java index a4ebd843..23976d9c 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryLivesTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryLivesTest.java @@ -4,12 +4,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.UUID; import org.bukkit.Bukkit; @@ -22,6 +23,9 @@ import org.mockito.MockedStatic; import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.army.Military; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.mercenary.company.CompanyFixture; import net.tfminecraft.simplefactions.managers.WarManager; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.war.battle.engine.core.Battle; @@ -30,7 +34,6 @@ import net.tfminecraft.simplefactions.war.battle.enums.BattleType; import net.tfminecraft.simplefactions.war.battle.military.BattleLivesService; import net.tfminecraft.simplefactions.war.battle.military.BattleLivesService.SideLivesPreview; -import net.tfminecraft.simplefactions.war.battle.military.BattlePoolService; import net.tfminecraft.simplefactions.war.battle.template.BattleTemplate; import net.tfminecraft.simplefactions.war.battle.warband.Warband; import net.tfminecraft.simplefactions.war.core.War; @@ -48,6 +51,7 @@ void setUp() { Cache.warBattleMinSideLives = 1; fixture = ContractFixture.formed(2); when(fixture.hirer.getRelations()).thenReturn(new HashMap<>()); + giveRegiments(fixture.hirer, 2); when(MercenaryLoyalty.hostFaction(fixture.company).getRelations()) .thenReturn(new HashMap<>()); enemy = ContractFixture.faction("lives_enemy"); @@ -73,6 +77,15 @@ void tearDown() { ContractFixture.tearDown(); } + /** Two paid offensive slots, so the hirer's side brings regiments of its own. */ + private static void giveRegiments(Faction faction, int slots) { + Regiment regiment = CompanyFixture.regularPrototype("professional", slots, 1.0); + when(regiment.getPaidSlots()).thenReturn(slots); + Military military = mock(Military.class); + when(military.getRegiments()).thenReturn(new ArrayList<>(List.of(regiment))); + when(faction.getMilitary()).thenReturn(military); + } + private War war() { return new War(1, fixture.hirer, enemy); } @@ -100,25 +113,18 @@ void filledAttendingSlotsAddLivesAndEmptyAddsNone() { Battle empty = battle(1); - try (MockedStatic pool = mockStatic(BattlePoolService.class)) { - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(2); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getDefenders()))) - .thenReturn(2); - - SideLivesPreview attending = BattleLivesService.previewCampaignSideLives( - war, withMerc, BattleTemplate.ATTACKER_SIDE); - assertEquals(1, attending.mercenarySlots()); - assertEquals(3, attending.committedRegiments()); - assertEquals(15, attending.poolLives()); - assertEquals(1, attending.rosterFighters()); - assertEquals(14, attending.sideLives()); - - SideLivesPreview vacant = BattleLivesService.previewCampaignSideLives( - war, empty, BattleTemplate.ATTACKER_SIDE); - assertEquals(0, vacant.mercenarySlots()); - assertEquals(2, vacant.committedRegiments()); - } + SideLivesPreview attending = BattleLivesService.previewCampaignSideLives( + war, withMerc, BattleTemplate.ATTACKER_SIDE); + assertEquals(1, attending.mercenarySlots()); + assertEquals(3, attending.slots()); + assertEquals(15, attending.poolLives()); + assertEquals(1, attending.rosterFighters()); + assertEquals(14, attending.sideLives()); + + SideLivesPreview vacant = BattleLivesService.previewCampaignSideLives( + war, empty, BattleTemplate.ATTACKER_SIDE); + assertEquals(0, vacant.mercenarySlots()); + assertEquals(2, vacant.slots()); } @Test @@ -128,18 +134,13 @@ void aDualRolePlayerIsSubtractedOnceAndPreviewMatchesApply() { Warband band = Warband.createWithMemberIds("atk", sigrun, true, sigrun); live.getSideById(BattleTemplate.ATTACKER_SIDE).addBand(band); - try (MockedStatic pool = mockStatic(BattlePoolService.class); - MockedStatic wars = mockStatic(WarManager.class); + try (MockedStatic wars = mockStatic(WarManager.class); MockedStatic bukkit = mockStatic(Bukkit.class)) { BossBar bar = mock(BossBar.class); bukkit.when(() -> Bukkit.createBossBar(anyString(), any(BarColor.class), any(BarStyle.class))) .thenReturn(bar); bukkit.when(() -> Bukkit.createBossBar(anyString(), any(BarColor.class), any(BarStyle.class), any())) .thenReturn(bar); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(2); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getDefenders()))) - .thenReturn(2); wars.when(() -> WarManager.getById(1)).thenReturn(war); SideLivesPreview preview = BattleLivesService.previewCampaignSideLives( diff --git a/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryRosterTest.java b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryRosterTest.java index 2480107a..eceb9c54 100644 --- a/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryRosterTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/mercenary/contract/MercenaryRosterTest.java @@ -30,7 +30,8 @@ import net.tfminecraft.simplefactions.war.battle.engine.core.BattleFactory; import net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager; import net.tfminecraft.simplefactions.war.battle.enums.BattleType; -import net.tfminecraft.simplefactions.war.battle.military.BattlePoolService; +import net.tfminecraft.simplefactions.war.battle.military.BattleForces; +import net.tfminecraft.simplefactions.war.wounds.TestForces; import net.tfminecraft.simplefactions.war.battle.template.BattleTemplate; import net.tfminecraft.simplefactions.war.battle.warband.Warband; import net.tfminecraft.simplefactions.war.core.War; @@ -109,10 +110,10 @@ void aMercenaryJoinsTheContractedSideAgainstTheirOwnFaction() { war.setScheduledBattleProvinceId(PROVINCE_ID); try (MockedStatic factions = mockStatic(FactionManager.class, org.mockito.Mockito.CALLS_REAL_METHODS); - MockedStatic pool = mockStatic(BattlePoolService.class)) { + MockedStatic pool = mockStatic(BattleForces.class)) { factions.when(() -> FactionManager.getByMember("Sigrun")).thenReturn(enemy); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(5); + pool.when(() -> BattleForces.build(eq(war), any(), eq(war.getAttackers()), any(), any())) + .thenReturn(TestForces.regiments(5)); assertNull(CampaignBattleJoinService.validateWarbandMemberJoin( war, battle, BattleTemplate.ATTACKER_SIDE, attackers, "Sigrun", sigrunId)); @@ -150,13 +151,13 @@ void aCouncilMemberIsRefusedAndACitizenIsAllowed() { when(enemyGuild.isMember("Sigrun")).thenReturn(true); try (MockedStatic factions = mockStatic(FactionManager.class, org.mockito.Mockito.CALLS_REAL_METHODS); - MockedStatic pool = mockStatic(BattlePoolService.class)) { + MockedStatic pool = mockStatic(BattleForces.class)) { factions.when(() -> FactionManager.getByMember("Sigrun")).thenReturn(enemy); factions.when(() -> FactionManager.getByMember("Chancellor")).thenReturn(enemy); factions.when(() -> FactionManager.getGuildByMember("Chancellor")).thenReturn(enemyGuild); factions.when(() -> FactionManager.getGuildByMember("Sigrun")).thenReturn(enemyGuild); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(5); + pool.when(() -> BattleForces.build(eq(war), any(), eq(war.getAttackers()), any(), any())) + .thenReturn(TestForces.regiments(5)); fixture.company.enlist("Chancellor"); MercenaryEngagements.setUuidLookup(name -> "Sigrun".equalsIgnoreCase(name) ? sigrunId @@ -187,10 +188,10 @@ void theSlotCapRefusesTheSurplusPlayer() { : "Bjorn".equalsIgnoreCase(name) ? extra : null); try (MockedStatic factions = mockStatic(FactionManager.class, org.mockito.Mockito.CALLS_REAL_METHODS); - MockedStatic pool = mockStatic(BattlePoolService.class)) { + MockedStatic pool = mockStatic(BattleForces.class)) { factions.when(() -> FactionManager.getByMember("Bjorn")).thenReturn(fixture.hirer); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(5); + pool.when(() -> BattleForces.build(eq(war), any(), eq(war.getAttackers()), any(), any())) + .thenReturn(TestForces.regiments(5)); assertEquals("Every hired slot is already covered", CampaignBattleJoinService.validateWarbandMemberJoin( diff --git a/src/test/java/net/tfminecraft/simplefactions/war/actions/WarActionApplierTest.java b/src/test/java/net/tfminecraft/simplefactions/war/actions/WarActionApplierTest.java index 48793cb0..c2eef097 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/actions/WarActionApplierTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/actions/WarActionApplierTest.java @@ -33,10 +33,8 @@ import net.tfminecraft.simplefactions.war.campaign.buildup.BuildupService; import net.tfminecraft.simplefactions.war.campaign.runtime.BattleWindowService; import net.tfminecraft.simplefactions.war.campaign.runtime.CampaignClock; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; import net.tfminecraft.simplefactions.war.core.CallToArmsService; import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.core.WarCommitment; import net.tfminecraft.simplefactions.war.enums.BattleSchedulePhase; import net.tfminecraft.simplefactions.war.history.WarHistory; import net.tfminecraft.simplefactions.war.history.WarHistoryJson; @@ -48,7 +46,6 @@ class WarActionApplierTest { private FactionDomainFixture fixture; private PersistenceFilesFixture files; private CharacterKeyFixture characters; - private Map> previousCommitments; private final Map previousCache = new LinkedHashMap<>(); private WarHistoryOutbox outbox; private Faction attacker; @@ -65,8 +62,6 @@ void setup() throws Exception { Files.createDirectories(files.root.resolve("Data")); fixture = new FactionDomainFixture(); CampaignClock.reset(); - previousCommitments = new LinkedHashMap<>(commitments()); - commitments().clear(); previousCache.put("start", Cache.warBattleWindowStartHour); previousCache.put("end", Cache.warBattleWindowEndHour); previousCache.put("close", Cache.warVoteCloseHour); @@ -101,8 +96,6 @@ void close() throws Exception { try { WarHistory.resetForTests(); CampaignClock.reset(); - commitments().clear(); - commitments().putAll(previousCommitments); Cache.warBattleWindowStartHour = (Integer) previousCache.get("start"); Cache.warBattleWindowEndHour = (Integer) previousCache.get("end"); Cache.warVoteCloseHour = (Integer) previousCache.get("close"); @@ -114,12 +107,6 @@ void close() throws Exception { } } - @SuppressWarnings("unchecked") - private static Map> commitments() throws Exception { - Field field = WarCommitmentService.class.getDeclaredField("commitmentsByWar"); - field.setAccessible(true); - return (Map>) field.get(null); - } private War war(int id) { return WarManager.addWar(new War(id, attacker, defender)); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleJoinServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleJoinServiceTest.java index 5348d90d..9acd7858 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleJoinServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleJoinServiceTest.java @@ -1,6 +1,8 @@ package net.tfminecraft.simplefactions.war.battle.campaign; +import net.tfminecraft.simplefactions.war.battle.military.BattleForces; +import net.tfminecraft.simplefactions.war.wounds.TestForces; import net.tfminecraft.simplefactions.war.battle.campaign.warband.CampaignWarbandLeaveBlock; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -36,7 +38,7 @@ import net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager; import net.tfminecraft.simplefactions.war.battle.enums.BattleType; import net.tfminecraft.simplefactions.war.battle.military.BattleLivesService; -import net.tfminecraft.simplefactions.war.battle.military.BattlePoolService; + import net.tfminecraft.simplefactions.war.battle.template.BattleTemplate; import net.tfminecraft.simplefactions.war.campaign.progression.CampaignSideService.CampaignSideRole; import net.tfminecraft.simplefactions.war.battle.warband.Warband; @@ -95,11 +97,11 @@ void counterPush_defenderMayJoinAttackerSide() { "counter_attacker", war, war.getDefenders(), BattleTemplate.ATTACKER_SIDE); try (MockedStatic factions = mockStatic(FactionManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class)) { + MockedStatic pool = mockStatic(BattleForces.class)) { factions.when(() -> FactionManager.getByMember("Carol")).thenReturn(defender); factions.when(() -> FactionManager.getByMember("Alice")).thenReturn(attacker); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getDefenders()))) - .thenReturn(5); + pool.when(() -> BattleForces.build(eq(war), any(), eq(war.getDefenders()), any(), any())) + .thenReturn(TestForces.regiments(5)); assertNull(CampaignBattleJoinService.validateWarbandMemberJoin( war, battle, BattleTemplate.ATTACKER_SIDE, warband, "Carol", UUID.randomUUID())); @@ -118,9 +120,9 @@ void rosterCap_enforced() { Warband warband = campaignSideWarband(war, BattleTemplate.ATTACKER_SIDE); warband.addMember(UUID.randomUUID()); - try (MockedStatic pool = mockStatic(BattlePoolService.class)) { - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(0); + try (MockedStatic pool = mockStatic(BattleForces.class)) { + pool.when(() -> BattleForces.build(eq(war), any(), eq(war.getAttackers()), any(), any())) + .thenReturn(TestForces.regiments(0)); String error = CampaignBattleJoinService.validateJoin( war, battle, warband, BattleTemplate.ATTACKER_SIDE); @@ -137,9 +139,9 @@ void phantoms_countTowardCap_devmode() { Warband warband = campaignSideWarband(war, BattleTemplate.ATTACKER_SIDE); WarDevMode.seedDummyMembers(warband, 5); - try (MockedStatic pool = mockStatic(BattlePoolService.class)) { - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(0); + try (MockedStatic pool = mockStatic(BattleForces.class)) { + pool.when(() -> BattleForces.build(eq(war), any(), eq(war.getAttackers()), any(), any())) + .thenReturn(TestForces.regiments(0)); String error = CampaignBattleJoinService.validateJoin( war, battle, warband, BattleTemplate.ATTACKER_SIDE); @@ -156,9 +158,9 @@ void previewSideRosterCap_usesPoolLives() { WarDevMode.seedDummyMembers(warband, 10); battle.getSideById(BattleTemplate.ATTACKER_SIDE).addBand(warband); - try (MockedStatic pool = mockStatic(BattlePoolService.class)) { - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(10); + try (MockedStatic pool = mockStatic(BattleForces.class)) { + pool.when(() -> BattleForces.build(eq(war), any(), eq(war.getAttackers()), any(), any())) + .thenReturn(TestForces.regiments(10)); int cap = CampaignBattleJoinService.previewSidePoolLives( war, battle, BattleTemplate.ATTACKER_SIDE); @@ -199,12 +201,12 @@ void campaignRejoin_allowedWhenRosterHasRoom() { try (MockedStatic factions = mockStatic(FactionManager.class); MockedStatic wars = mockStatic(WarManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class); + MockedStatic pool = mockStatic(BattleForces.class); MockedStatic bukkit = mockStatic(Bukkit.class)) { factions.when(() -> FactionManager.getByString("atk")).thenReturn(attacker); wars.when(() -> WarManager.getById(1)).thenReturn(war); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(5); + pool.when(() -> BattleForces.build(eq(war), any(), eq(war.getAttackers()), any(), any())) + .thenReturn(TestForces.regiments(5)); bukkit.when(() -> Bukkit.getPlayer(any(UUID.class))).thenReturn(null); assertTrue(service.evaluateRejoin(warband, leaderId, "leader", attacker, state)); @@ -222,13 +224,13 @@ void midBattleJoin_blockedWhenNoLives() { try (MockedStatic factions = mockStatic(FactionManager.class); MockedStatic wars = mockStatic(WarManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class)) { + MockedStatic pool = mockStatic(BattleForces.class)) { factions.when(() -> FactionManager.getByMember("Alice")).thenReturn(attacker); wars.when(() -> WarManager.getById(1)).thenReturn(war); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(5); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getDefenders()))) - .thenReturn(5); + pool.when(() -> BattleForces.build(eq(war), any(), eq(war.getAttackers()), any(), any())) + .thenReturn(TestForces.regiments(5)); + pool.when(() -> BattleForces.build(eq(war), any(), eq(war.getDefenders()), any(), any())) + .thenReturn(TestForces.regiments(5)); org.bukkit.World world = mock(org.bukkit.World.class); for (net.tfminecraft.simplefactions.war.battle.engine.core.BattleSide side : battle.getSides()) { @@ -252,9 +254,9 @@ void validateJoin_successOnCorrectSide() { Battle battle = campaignBattle(1); Warband warband = campaignSideWarband(war, BattleTemplate.ATTACKER_SIDE); - try (MockedStatic pool = mockStatic(BattlePoolService.class)) { - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(5); + try (MockedStatic pool = mockStatic(BattleForces.class)) { + pool.when(() -> BattleForces.build(eq(war), any(), eq(war.getAttackers()), any(), any())) + .thenReturn(TestForces.regiments(5)); assertNull(CampaignBattleJoinService.validateJoin( war, battle, warband, BattleTemplate.ATTACKER_SIDE)); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleOutcomeServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleOutcomeServiceTest.java index c9f754c5..cd7f00f6 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleOutcomeServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleOutcomeServiceTest.java @@ -405,7 +405,7 @@ void handleBattleEnded_appliesCasualtiesBeforeOpenVote() { List steps = new ArrayList<>(); try (MockedStatic casualtyMock = mockStatic(BattleCasualtyService.class); MockedStatic scheduleMock = mockStatic(BattleScheduleService.class)) { - casualtyMock.when(() -> BattleCasualtyService.applyBattleCasualties(any(), any(), any())) + casualtyMock.when(() -> BattleCasualtyService.applyBattleCasualties(any(), any(), any(), any())) .then(inv -> { steps.add("casualties"); return null; diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleRosterServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleRosterServiceTest.java index 13c6c325..4bbac891 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleRosterServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleRosterServiceTest.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.war.battle.campaign; +import net.tfminecraft.simplefactions.war.battle.military.BattleForces; +import net.tfminecraft.simplefactions.war.wounds.TestForces; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -74,8 +76,8 @@ void enrollWarbands_createsEmptyShellOnBattleSide() { mockStatic(net.tfminecraft.simplefactions.managers.WarManager.class); MockedStatic factions = mockStatic(net.tfminecraft.simplefactions.managers.FactionManager.class); - MockedStatic pool = - mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.class)) { + MockedStatic pool = + mockStatic(BattleForces.class)) { mockBossBar(bukkit); Battle battle = createBattle(1); wars.when(() -> net.tfminecraft.simplefactions.managers.WarManager.getById(1)).thenReturn(war); @@ -83,10 +85,13 @@ void enrollWarbands_createsEmptyShellOnBattleSide() { .thenReturn(attacker); factions.when(() -> net.tfminecraft.simplefactions.managers.FactionManager.getByString("def")) .thenReturn(defender); - pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.totalCommittedRegiments( + pool.when(() -> BattleForces.build( org.mockito.ArgumentMatchers.eq(war), - org.mockito.ArgumentMatchers.eq(20), - org.mockito.ArgumentMatchers.any())).thenReturn(5); + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any())) + .thenReturn(TestForces.regiments(5)); CampaignBattleRosterService.enrollWarbands(war, battle); @@ -110,8 +115,8 @@ void enrollWarbands_counterPushPutsDefendersOnAttackerSide() { mockStatic(net.tfminecraft.simplefactions.managers.WarManager.class); MockedStatic factions = mockStatic(net.tfminecraft.simplefactions.managers.FactionManager.class); - MockedStatic pool = - mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.class)) { + MockedStatic pool = + mockStatic(BattleForces.class)) { mockBossBar(bukkit); Battle battle = createBattle(1); battle.setOffensiveCoalition(CampaignSideRole.DEFENDER); @@ -120,10 +125,13 @@ void enrollWarbands_counterPushPutsDefendersOnAttackerSide() { .thenReturn(attacker); factions.when(() -> net.tfminecraft.simplefactions.managers.FactionManager.getByString("def")) .thenReturn(defender); - pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.totalCommittedRegiments( + pool.when(() -> BattleForces.build( org.mockito.ArgumentMatchers.eq(war), - org.mockito.ArgumentMatchers.eq(20), - org.mockito.ArgumentMatchers.any())).thenReturn(5); + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any())) + .thenReturn(TestForces.regiments(5)); CampaignBattleRosterService.enrollWarbands(war, battle); @@ -148,8 +156,8 @@ void ensureEnrolled_deferredUntilSignupOpens() { mockStatic(net.tfminecraft.simplefactions.managers.WarManager.class); MockedStatic factions = mockStatic(net.tfminecraft.simplefactions.managers.FactionManager.class); - MockedStatic pool = - mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.class)) { + MockedStatic pool = + mockStatic(BattleForces.class)) { mockBossBar(bukkit); Battle battle = createBattle(1); wars.when(() -> net.tfminecraft.simplefactions.managers.WarManager.getById(1)).thenReturn(war); @@ -157,10 +165,13 @@ void ensureEnrolled_deferredUntilSignupOpens() { .thenReturn(attacker); factions.when(() -> net.tfminecraft.simplefactions.managers.FactionManager.getByString("def")) .thenReturn(defender); - pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.totalCommittedRegiments( + pool.when(() -> BattleForces.build( org.mockito.ArgumentMatchers.eq(war), - org.mockito.ArgumentMatchers.eq(20), - org.mockito.ArgumentMatchers.any())).thenReturn(5); + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any())) + .thenReturn(TestForces.regiments(5)); CampaignBattleRosterService.ensureEnrolledAt(war, battle, beforeSignup, false, false); @@ -181,8 +192,8 @@ void tryEnrollWhenSignupOpens_createsShellsAtSignupHour() { mockStatic(net.tfminecraft.simplefactions.managers.WarManager.class); MockedStatic factions = mockStatic(net.tfminecraft.simplefactions.managers.FactionManager.class); - MockedStatic pool = - mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.class)) { + MockedStatic pool = + mockStatic(BattleForces.class)) { mockBossBar(bukkit); Battle battle = createBattle(1); wars.when(() -> net.tfminecraft.simplefactions.managers.WarManager.getById(1)).thenReturn(war); @@ -190,10 +201,13 @@ void tryEnrollWhenSignupOpens_createsShellsAtSignupHour() { .thenReturn(attacker); factions.when(() -> net.tfminecraft.simplefactions.managers.FactionManager.getByString("def")) .thenReturn(defender); - pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.totalCommittedRegiments( + pool.when(() -> BattleForces.build( org.mockito.ArgumentMatchers.eq(war), - org.mockito.ArgumentMatchers.eq(20), - org.mockito.ArgumentMatchers.any())).thenReturn(5); + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any())) + .thenReturn(TestForces.regiments(5)); CampaignBattleRosterService.tryEnrollWhenSignupOpens(war, signupHour); @@ -212,8 +226,8 @@ void ensureEnrolled_fillsMissingSideShells() { mockStatic(net.tfminecraft.simplefactions.managers.WarManager.class); MockedStatic factions = mockStatic(net.tfminecraft.simplefactions.managers.FactionManager.class); - MockedStatic pool = - mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.class)) { + MockedStatic pool = + mockStatic(BattleForces.class)) { mockBossBar(bukkit); Battle battle = createBattle(1); wars.when(() -> net.tfminecraft.simplefactions.managers.WarManager.getById(1)).thenReturn(war); @@ -221,10 +235,13 @@ void ensureEnrolled_fillsMissingSideShells() { .thenReturn(attacker); factions.when(() -> net.tfminecraft.simplefactions.managers.FactionManager.getByString("def")) .thenReturn(defender); - pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.totalCommittedRegiments( + pool.when(() -> BattleForces.build( org.mockito.ArgumentMatchers.eq(war), - org.mockito.ArgumentMatchers.eq(20), - org.mockito.ArgumentMatchers.any())).thenReturn(5); + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any())) + .thenReturn(TestForces.regiments(5)); CampaignBattleRosterService.ensureEnrolledForced(war, battle); @@ -243,8 +260,8 @@ void enrollWarbands_idempotentWhenBandAlreadyOnSide() { mockStatic(net.tfminecraft.simplefactions.managers.WarManager.class); MockedStatic factions = mockStatic(net.tfminecraft.simplefactions.managers.FactionManager.class); - MockedStatic pool = - mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.class)) { + MockedStatic pool = + mockStatic(BattleForces.class)) { mockBossBar(bukkit); Battle battle = createBattle(1); wars.when(() -> net.tfminecraft.simplefactions.managers.WarManager.getById(1)).thenReturn(war); @@ -252,10 +269,13 @@ void enrollWarbands_idempotentWhenBandAlreadyOnSide() { .thenReturn(attacker); factions.when(() -> net.tfminecraft.simplefactions.managers.FactionManager.getByString("def")) .thenReturn(defender); - pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.totalCommittedRegiments( + pool.when(() -> BattleForces.build( org.mockito.ArgumentMatchers.eq(war), - org.mockito.ArgumentMatchers.eq(20), - org.mockito.ArgumentMatchers.any())).thenReturn(5); + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any())) + .thenReturn(TestForces.regiments(5)); CampaignBattleRosterService.enrollWarbands(war, battle); CampaignBattleRosterService.enrollWarbands(war, battle); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/WarbandCampaignSignupTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/WarbandCampaignSignupTest.java index debe490b..c7ec75a5 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/WarbandCampaignSignupTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/WarbandCampaignSignupTest.java @@ -1,6 +1,8 @@ package net.tfminecraft.simplefactions.war.battle.campaign; +import net.tfminecraft.simplefactions.war.battle.military.BattleForces; +import net.tfminecraft.simplefactions.war.wounds.TestForces; import net.tfminecraft.simplefactions.war.battle.campaign.warband.CampaignWarbandSignupService; import net.tfminecraft.simplefactions.war.battle.campaign.warband.CampaignWarbandBattleService; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -33,7 +35,7 @@ import net.tfminecraft.simplefactions.war.battle.engine.core.BattleFactory; import net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager; import net.tfminecraft.simplefactions.war.battle.enums.BattleType; -import net.tfminecraft.simplefactions.war.battle.military.BattlePoolService; + import net.tfminecraft.simplefactions.war.battle.template.BattleTemplate; import net.tfminecraft.simplefactions.war.battle.warband.Warband; import net.tfminecraft.simplefactions.war.battle.warband.WarbandManager; @@ -92,12 +94,12 @@ void firstSignup_setsLeader() { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic factions = mockStatic(FactionManager.class); MockedStatic wars = mockStatic(WarManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class)) { + MockedStatic pool = mockStatic(BattleForces.class)) { mockBukkitPlayerLookup(bukkit, aliceId, "Alice"); factions.when(() -> FactionManager.getByMember("Alice")).thenReturn(attacker); wars.when(() -> WarManager.getById(1)).thenReturn(war); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(5); + pool.when(() -> BattleForces.build(eq(war), org.mockito.ArgumentMatchers.any(), eq(war.getAttackers()), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(TestForces.regiments(5)); assertNull(CampaignWarbandSignupService.signupMember( aliceId, "Alice", shell, attacker, null, signupInstant())); @@ -116,14 +118,14 @@ void secondSignup_doesNotStealLeader() { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic factions = mockStatic(FactionManager.class); MockedStatic wars = mockStatic(WarManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class)) { + MockedStatic pool = mockStatic(BattleForces.class)) { mockBukkitPlayerLookup(bukkit, aliceId, "Alice"); mockBukkitPlayerLookup(bukkit, bobId, "Bob"); factions.when(() -> FactionManager.getByMember("Alice")).thenReturn(attacker); factions.when(() -> FactionManager.getByMember("Bob")).thenReturn(attacker); wars.when(() -> WarManager.getById(1)).thenReturn(war); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(5); + pool.when(() -> BattleForces.build(eq(war), org.mockito.ArgumentMatchers.any(), eq(war.getAttackers()), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(TestForces.regiments(5)); assertNull(CampaignWarbandSignupService.signupMember( aliceId, "Alice", shell, attacker, null, signupInstant())); @@ -143,14 +145,14 @@ void warLeaderSignup_promotesToLeader() { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic factions = mockStatic(FactionManager.class); MockedStatic wars = mockStatic(WarManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class)) { + MockedStatic pool = mockStatic(BattleForces.class)) { mockBukkitPlayerLookup(bukkit, aliceId, "Alice"); mockBukkitPlayerLookup(bukkit, bobId, "Bob"); factions.when(() -> FactionManager.getByMember("Bob")).thenReturn(attacker); factions.when(() -> FactionManager.getByMember("Alice")).thenReturn(attacker); wars.when(() -> WarManager.getById(1)).thenReturn(war); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(5); + pool.when(() -> BattleForces.build(eq(war), org.mockito.ArgumentMatchers.any(), eq(war.getAttackers()), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(TestForces.regiments(5)); assertNull(CampaignWarbandSignupService.signupMember( bobId, "Bob", shell, attacker, null, signupInstant())); @@ -172,12 +174,12 @@ void devmode_phantomsAfterFirstSignupOnly() { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic factions = mockStatic(FactionManager.class); MockedStatic wars = mockStatic(WarManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class)) { + MockedStatic pool = mockStatic(BattleForces.class)) { mockBukkitPlayerLookup(bukkit, aliceId, "Alice"); factions.when(() -> FactionManager.getByMember("Alice")).thenReturn(attacker); wars.when(() -> WarManager.getById(1)).thenReturn(war); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(50); + pool.when(() -> BattleForces.build(eq(war), org.mockito.ArgumentMatchers.any(), eq(war.getAttackers()), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(TestForces.regiments(50)); assertNull(CampaignWarbandSignupService.signupMember( aliceId, "Alice", shell, attacker, null, signupInstant())); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/warband/CampaignWarbandSignupServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/warband/CampaignWarbandSignupServiceTest.java index 4db03979..29f538b9 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/warband/CampaignWarbandSignupServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/warband/CampaignWarbandSignupServiceTest.java @@ -1,6 +1,8 @@ package net.tfminecraft.simplefactions.war.battle.campaign.warband; +import net.tfminecraft.simplefactions.war.battle.military.BattleForces; +import net.tfminecraft.simplefactions.war.wounds.TestForces; import net.tfminecraft.simplefactions.war.battle.campaign.warband.CampaignWarbandSignupService; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -34,7 +36,7 @@ import net.tfminecraft.simplefactions.war.battle.engine.core.BattleFactory; import net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager; import net.tfminecraft.simplefactions.war.battle.enums.BattleType; -import net.tfminecraft.simplefactions.war.battle.military.BattlePoolService; + import net.tfminecraft.simplefactions.war.battle.template.BattleTemplate; import net.tfminecraft.simplefactions.war.battle.warband.Warband; import net.tfminecraft.simplefactions.war.battle.warband.WarbandManager; @@ -120,12 +122,12 @@ void signupMember_blockedDuringRaidCall() { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic factions = mockStatic(FactionManager.class); MockedStatic wars = mockStatic(WarManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class)) { + MockedStatic pool = mockStatic(BattleForces.class)) { mockBukkitPlayerLookup(bukkit, aliceId, "Alice"); factions.when(() -> FactionManager.getByMember("Alice")).thenReturn(attacker); wars.when(() -> WarManager.getById(1)).thenReturn(war); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(5); + pool.when(() -> BattleForces.build(eq(war), org.mockito.ArgumentMatchers.any(), eq(war.getAttackers()), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(TestForces.regiments(5)); assertEquals("§cCampaign warband signup opens after the raid window, at 20:00.", CampaignWarbandSignupService.signupMember( @@ -161,7 +163,7 @@ void staffSignupMember_skipsTheSignupHoursRosterCapAndRejoinBlock() { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic factions = mockStatic(FactionManager.class); MockedStatic wars = mockStatic(WarManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class)) { + MockedStatic pool = mockStatic(BattleForces.class)) { mockBukkitPlayerLookup(bukkit, aliceId, "Alice"); factions.when(() -> FactionManager.getByMember("Alice")).thenReturn(attacker); wars.when(() -> WarManager.getById(1)).thenReturn(war); @@ -191,7 +193,7 @@ void staffSignupMember_passesAFullRosterInOpenHoursAndTheRejoinBlockMidBattle() try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic factions = mockStatic(FactionManager.class); MockedStatic wars = mockStatic(WarManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class); + MockedStatic pool = mockStatic(BattleForces.class); MockedStatic clock = mockStatic(net.tfminecraft.simplefactions.war.campaign.runtime.CampaignClock.class)) { clock.when(net.tfminecraft.simplefactions.war.campaign.runtime.CampaignClock::now).thenReturn(signupHour); @@ -216,7 +218,7 @@ void staffSignupMember_letsSomeoneWhoLeftBackIntoARunningBattle() { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic factions = mockStatic(FactionManager.class); MockedStatic wars = mockStatic(WarManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class)) { + MockedStatic pool = mockStatic(BattleForces.class)) { mockBukkitPlayerLookup(bukkit, bobId, "Bob"); factions.when(() -> FactionManager.getByMember("Bob")).thenReturn(attacker); wars.when(() -> WarManager.getById(1)).thenReturn(war); @@ -259,12 +261,12 @@ void signupMember_allowedAfterRaidCall() { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic factions = mockStatic(FactionManager.class); MockedStatic wars = mockStatic(WarManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class)) { + MockedStatic pool = mockStatic(BattleForces.class)) { mockBukkitPlayerLookup(bukkit, aliceId, "Alice"); factions.when(() -> FactionManager.getByMember("Alice")).thenReturn(attacker); wars.when(() -> WarManager.getById(1)).thenReturn(war); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(5); + pool.when(() -> BattleForces.build(eq(war), org.mockito.ArgumentMatchers.any(), eq(war.getAttackers()), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(TestForces.regiments(5)); assertNull(CampaignWarbandSignupService.signupMember( aliceId, "Alice", shell, attacker, null, signupHour)); @@ -281,12 +283,12 @@ void signupMember_tiesTheAccountToItsCoalition() { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic factions = mockStatic(FactionManager.class); MockedStatic wars = mockStatic(WarManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class)) { + MockedStatic pool = mockStatic(BattleForces.class)) { mockBukkitPlayerLookup(bukkit, aliceId, "Alice"); factions.when(() -> FactionManager.getByMember("Alice")).thenReturn(attacker); wars.when(() -> WarManager.getById(1)).thenReturn(war); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(5); + pool.when(() -> BattleForces.build(eq(war), org.mockito.ArgumentMatchers.any(), eq(war.getAttackers()), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(TestForces.regiments(5)); assertNull(CampaignWarbandSignupService.signupMember( aliceId, "Alice", shell, attacker, null, BattleWindowService.atScheduleHour(BATTLE_DAY, 20))); @@ -310,13 +312,13 @@ void signupMember_refusesTheOtherSideToAnAccountAlreadyTiedToOne() { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic factions = mockStatic(FactionManager.class); MockedStatic wars = mockStatic(WarManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class)) { + MockedStatic pool = mockStatic(BattleForces.class)) { mockBukkitPlayerLookup(bukkit, accountId, "Alice"); factions.when(() -> FactionManager.getByMember("Alice")).thenReturn(attacker); factions.when(() -> FactionManager.getByMember("Bob")).thenReturn(defender); wars.when(() -> WarManager.getById(1)).thenReturn(war); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), - org.mockito.ArgumentMatchers.any())).thenReturn(5); + pool.when(() -> BattleForces.build(eq(war), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(TestForces.regiments(5)); assertEquals(CampaignWarbandSideLock.OTHER_SIDE, CampaignWarbandSignupService.signupMember( accountId, "Bob", defenderShell, defender, null, signupHour)); @@ -350,13 +352,13 @@ void signupMember_raidWarbandNotBlocked() { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic factions = mockStatic(FactionManager.class); MockedStatic wars = mockStatic(WarManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class)) { + MockedStatic pool = mockStatic(BattleForces.class)) { mockBukkitPlayerLookup(bukkit, aliceId, "Alice"); factions.when(() -> FactionManager.getByMember("Alice")).thenReturn(attacker); wars.when(() -> WarManager.getById(1)).thenReturn(war); wars.when(WarManager::getActive).thenReturn(List.of(war)); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(5); + pool.when(() -> BattleForces.build(eq(war), org.mockito.ArgumentMatchers.any(), eq(war.getAttackers()), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(TestForces.regiments(5)); assertNull(CampaignWarbandSignupService.signupMember( aliceId, "Alice", raidShell, attacker, null, raidCall)); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleEngineListenersCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleEngineListenersCoverageTest.java index 2281ad54..45a017e1 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleEngineListenersCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleEngineListenersCoverageTest.java @@ -1024,7 +1024,7 @@ void theLivesEditorCanGoBackApplyNothingAndRefusesAStoppedBattle() { click(BattleLivesEditor.CONFIRM_SLOT); siege.setStarted(false); click(BattleLivesEditor.CONFIRM_SLOT); - verify(alice).sendMessage("§cLives can only be corrected while the battle runs. Use setlives before it starts."); + verify(alice).sendMessage("§cThe battle is not running, so nothing was changed."); // A staff restart after staging starts a new fight: the staged change is dropped, not applied to it. siege.setStarted(true); @@ -1070,9 +1070,10 @@ void theConfirmWarningExplainsThatLivesNeverDeleteRegiments() { int previous = net.tfminecraft.simplefactions.Cache.warBattleDeathsPerRegimentLoss; net.tfminecraft.simplefactions.Cache.warBattleDeathsPerRegimentLoss = 6; try { - assertEquals("Lives only limit respawns. Regiments are lost through deaths: every 6 deaths costs a side" - + " 1 regiment when the battle ends (attacker: 0 so far, defender: 4 so far). More lives means more" - + " possible deaths and losses. At 0 lives a side loses once all its soldiers are dead or jailed.", + assertEquals("Lives only limit respawns. Deaths wound the forces that fought, never more than the lives they" + + " brought, so added lives cost nobody: every 6 wounded lives wound 1 paid slot until the war ends" + + " (attacker: 0 so far, defender: 4 so far). Fix a finished battle's deaths with /war admin losses." + + " At 0 lives a side loses once all its soldiers are dead or jailed.", BattleLivesEditor.regimentWarning(battle)); } finally { net.tfminecraft.simplefactions.Cache.warBattleDeathsPerRegimentLoss = previous; @@ -1152,7 +1153,7 @@ void sideEditorSavesLocationsCapturePointsAndManualLifePoolsAndRejectsStaleState battle.setWarId(12); click(16); assertEquals(50, side.getLives()); - verify(alice).sendMessage("§cCampaign battle lives are computed from war commitment"); + verify(alice).sendMessage("§cCampaign battle lives come from the forces of each side"); battle.setStarted(true); click(10); verify(alice).sendMessage("§cCannot edit a battle while it has started"); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleJoinServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleJoinServiceTest.java index e7722654..4d7fa102 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleJoinServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleJoinServiceTest.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.war.battle.engine.core; +import net.tfminecraft.simplefactions.war.battle.military.BattleForces; +import net.tfminecraft.simplefactions.war.wounds.TestForces; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -24,7 +26,7 @@ import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.war.core.War; import net.tfminecraft.simplefactions.war.battle.enums.BattleType; -import net.tfminecraft.simplefactions.war.battle.military.BattlePoolService; + import net.tfminecraft.simplefactions.war.battle.warband.Warband; import net.tfminecraft.simplefactions.war.battle.warband.WarbandManager; @@ -128,7 +130,7 @@ void join_pendingLeaderShell_enrollsOnBattleSide() { try (MockedStatic bukkit = Mockito.mockStatic(org.bukkit.Bukkit.class); MockedStatic wars = mockStatic(WarManager.class); MockedStatic factions = mockStatic(FactionManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class)) { + MockedStatic pool = mockStatic(BattleForces.class)) { bukkit.when(() -> org.bukkit.Bukkit.createBossBar( Mockito.anyString(), Mockito.any(BarColor.class), Mockito.any(BarStyle.class))) .thenReturn(bossBar); @@ -138,8 +140,8 @@ void join_pendingLeaderShell_enrollsOnBattleSide() { wars.when(() -> WarManager.getById(1)).thenReturn(war); factions.when(() -> FactionManager.getByString("atk")).thenReturn(attacker); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(5); + pool.when(() -> BattleForces.build(eq(war), org.mockito.ArgumentMatchers.any(), eq(war.getAttackers()), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(TestForces.regiments(5)); Battle battle = BattleFactory.createBlank(BattleType.FIELD, "campaign_w1"); battle.setWarId(1); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleStaffServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleStaffServiceTest.java index ea9eb3a2..2b1253f2 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleStaffServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/BattleStaffServiceTest.java @@ -343,6 +343,40 @@ void livesCorrectionsAreChecked() { assertFalse(BattleStaffService.adjustLives(battle, "attacker", "add", 1, "Staff").ok()); } + @Test + void beforeACampaignBattleStaffSetTheLivesASideStartsWith() { + battle.setStarted(false); + battle.setWarId(7); + net.tfminecraft.simplefactions.war.core.War war = mock(net.tfminecraft.simplefactions.war.core.War.class); + try (MockedStatic wars = + mockStatic(net.tfminecraft.simplefactions.managers.WarManager.class); + MockedStatic lives = + mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattleLivesService.class)) { + wars.when(() -> net.tfminecraft.simplefactions.managers.WarManager.getById(7)).thenReturn(war); + lives.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattleLivesService + .previewCampaignSideLives(war, battle, "attacker")) + .thenAnswer(call -> new net.tfminecraft.simplefactions.war.battle.military.BattleLivesService + .SideLivesPreview(4, 20, 2, attacker.getLivesOverride() == null ? 18 : attacker.getLivesOverride(), 0)); + + assertEquals("§aSet attacker to start b1 with 25 lives (was 18). Use auto to go back to what its forces give.", + BattleStaffService.adjustLives(battle, "attacker", "add", 7, "Staff").message()); + assertEquals(Integer.valueOf(25), attacker.getLivesOverride()); + BattleStaffService.adjustLives(battle, "attacker", "remove", 100, "Staff"); + assertEquals(Integer.valueOf(0), attacker.getLivesOverride()); + assertEquals("Use add, remove, set or auto.", + BattleStaffService.adjustLives(battle, "attacker", "double", 1, "Staff").message()); + assertEquals("§aPut attacker back on the lives its forces give in b1 (18 now).", + BattleStaffService.adjustLives(battle, "attacker", "AUTO", 0, "Staff").message()); + assertNull(attacker.getLivesOverride()); + + battle.setWarId(8); + assertEquals("That battle is not running. Set its lives before it starts with /battle setlives.", + BattleStaffService.adjustLives(battle, "attacker", "set", 3, "Staff").message()); + battle.setWarId(null); + assertFalse(BattleStaffService.adjustLives(battle, "attacker", "set", 3, "Staff").ok()); + } + } + private static void assertSame(Object expected, Object actual) { org.junit.jupiter.api.Assertions.assertSame(expected, actual); } diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/CampaignBattleRuntimeBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/CampaignBattleRuntimeBoundaryCoverageTest.java index 2d3f7ab6..19513266 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/CampaignBattleRuntimeBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/engine/core/CampaignBattleRuntimeBoundaryCoverageTest.java @@ -330,9 +330,9 @@ void anotherCasualtyCannotOverflowOrEraseAFullPersistedCounter() throws Exceptio void aLargePositiveRegimentCountMustNotCollapseToTheMinimumBattleLives() throws Exception { try (Fixture rig = new Fixture()) { assertEquals(4, Cache.warBattleLivesPerRegiment); - int lives = BattleLivesService.computeSideLives(1_000_000_000, 1); + int lives = BattleLivesService.computeSideLives(Integer.MAX_VALUE, 1); assertTrue( - lives >= 1_000_000_000, + lives == Integer.MAX_VALUE - 1, "Positive pool multiplication must not wrap before the roster debit: " + lives); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleCasualtyLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleCasualtyLifecycleCoverageTest.java deleted file mode 100644 index dd47016d..00000000 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleCasualtyLifecycleCoverageTest.java +++ /dev/null @@ -1,619 +0,0 @@ -package net.tfminecraft.simplefactions.war.battle.military; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import java.util.*; -import net.tfminecraft.simplefactions.Cache; -import net.tfminecraft.simplefactions.army.Regiment; -import net.tfminecraft.simplefactions.database.FactionData; -import net.tfminecraft.simplefactions.database.JsonUtil; -import net.tfminecraft.simplefactions.database.WarData; -import net.tfminecraft.simplefactions.managers.FactionManager; -import net.tfminecraft.simplefactions.managers.RelationManager; -import net.tfminecraft.simplefactions.objects.Faction; -import net.tfminecraft.simplefactions.war.battle.engine.core.*; -import net.tfminecraft.simplefactions.war.battle.enums.BattleType; -import net.tfminecraft.simplefactions.war.campaign.raid.CampaignRaidLifecycleCoverageTest.Fixture; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; -import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.core.WarCommitment; -import net.tfminecraft.simplefactions.war.core.WarMapper; -import net.tfminecraft.simplefactions.war.enums.CampaignPhase; -import org.bukkit.configuration.file.YamlConfiguration; -import org.junit.jupiter.api.*; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -class BattleCasualtyLifecycleCoverageTest { - private Fixture rig; - private List previousCommitments; - private Battle battle; - - @BeforeEach - void setup() throws Exception { - rig = new Fixture(); - rig.remember(Cache.class, "warBattleDeathsPerRegimentLoss"); - Cache.warBattleDeathsPerRegimentLoss = 5; - previousCommitments = - new ArrayList<>(WarCommitmentService.getCommitmentsForWar(rig.war.getId())); - WarCommitmentService.clearCommitments(rig.war.getId()); - rig.war.setCampaignPhase(CampaignPhase.INVASION); - rig.war.setScheduledBattleProvinceId(20); - battle = BattleFactory.createBlank(BattleType.FIELD, "casualty_lifecycle"); - battle.setWarId(rig.war.getId()); - battle.setProvinceId(20); - } - - @AfterEach - void cleanup() throws Exception { - if (rig != null) { - if (previousCommitments != null) - WarCommitmentService.restoreCommitments(rig.war.getId(), previousCommitments); - rig.close(); - } - } - - @Test - void battleDeathsDebitRealRegimentsAndCommitmentsAndPersistTheResult() { - Regiment infantry = regiment(rig.attacker, "infantry", 8, true, false); - Regiment artillery = regiment(rig.attacker, "artillery", 9, true, true); - Regiment defenders = regiment(rig.defender, "guard", 6, false, false); - WarCommitmentService.commitAllParticipants(rig.war); - long beforeWrites = rig.warWrites(); - - BattleCasualtyService.applyBattleCasualties( - rig.war, battle, Map.of("attacker", 12, "defender", 5)); - - assertEquals(6, infantry.getCurrentSlots()); - assertEquals(9, artillery.getCurrentSlots()); - assertEquals(5, defenders.getCurrentSlots()); - assertEquals(6, committed(rig.attacker, "infantry")); - assertEquals(5, committed(rig.defender, "guard")); - assertEquals(beforeWrites + 1, rig.warWrites()); - verify(rig.alice).sendMessage("§7Campaign battle losses: attacker 2, defender 1 regiments."); - verify(rig.bob).sendMessage("§7Campaign battle losses: attacker 2, defender 1 regiments."); - } - - @Test - void reportedLossesMustNotExceedTheAvailableRegiments() { - Regiment only = regiment(rig.attacker, "infantry", 1, true, false); - WarCommitmentService.commitFaction(rig.war, rig.attacker); - - BattleCasualtyService.applyBattleCasualties(rig.war, battle, Map.of("attacker", 25)); - - assertEquals(0, only.getCurrentSlots()); - assertTrue(WarCommitmentService.getCommitmentsForWar(rig.war.getId()).isEmpty()); - verify(rig.alice).sendMessage("§7Campaign battle losses: attacker 1, defender 0 regiments."); - } - - @Test - void levyCasualtiesReduceTheSourceArmyWhenTheSubjectDoesNotFightDirectly() { - rig.domain.lawGroup("conscription", Map.of("effects.faction.modifiers", List.of("LEVY(100)"))); - Faction subject = rig.domain.saved("levy_source", "SubjectLeader"); - subject.addMember("SubjectSecond"); - rig.domain.subject(rig.attacker, subject); - Regiment source = regiment(subject, "guard", 6, false, false); - source.setSentToOverlord(2); - WarCommitmentService.commitAllParticipants(rig.war); - List rows = WarCommitmentService.getCommitmentsForWar(rig.war.getId()); - assertEquals(1, rows.size()); - assertTrue(rows.getFirst().isLevyRow()); - assertEquals(2, rows.getFirst().count()); - assertEquals(subject.getId(), rows.getFirst().sourceFactionId()); - - BattleCasualtyService.applyBattleCasualties(rig.war, battle, Map.of("attacker", 5)); - - assertEquals(5, source.getCurrentSlots()); - assertEquals(1, source.sentToOverlord()); - assertEquals(1, WarCommitmentService.getCommitmentsForWar(rig.war.getId()).getFirst().count()); - assertTrue( - rig.writes.stream() - .anyMatch( - write -> - write.value() instanceof FactionData saved - && saved.id.equals(subject.getId()))); - } - - @Test - void casualtyAllocationMustNotOverflowWhenConfiguredArmyWeightsExceedIntegerMaximum() { - Regiment first = regiment(rig.attacker, "first", 1_500_000_000, true, false); - Regiment second = regiment(rig.attacker, "second", 1_500_000_000, true, false); - WarCommitmentService.commitFaction(rig.war, rig.attacker); - - BattleCasualtyService.applyBattleCasualties(rig.war, battle, Map.of("attacker", 5)); - - assertEquals(2_999_999_999L, (long) first.getCurrentSlots() + second.getCurrentSlots()); - assertEquals( - 2_999_999_999L, - WarCommitmentService.getCommitmentsForWar(rig.war.getId()).stream() - .mapToLong(WarCommitment::count) - .sum()); - } - - @Test - void rawCommitmentQueriesAreCaseInsensitiveUnderTurkishLocale() { - regiment(rig.attacker, "infantry", 3, true, false); - WarCommitmentService.commitFaction(rig.war, rig.attacker); - Locale original = Locale.getDefault(); - try { - Locale.setDefault(Locale.forLanguageTag("tr-TR")); - assertEquals( - 3, - WarCommitmentService.totalCommittedRegiments( - rig.war.getId(), Set.of(rig.attacker.getId().toUpperCase(Locale.ROOT)), null)); - } finally { - Locale.setDefault(original); - } - } - - @ParameterizedTest - @ValueSource(booleans = {false, true}) - void endingOneVassalageRemovesOnlyItsLevyRowsAndKeepsOtherCommitments(boolean subjectFirst) { - rig.domain.lawGroup("conscription", Map.of("effects.faction.modifiers", List.of("LEVY(100)"))); - Faction released = rig.domain.saved("released_subject", "ReleasedLeader"); - Faction sibling = rig.domain.saved("remaining_subject", "RemainingLeader"); - rig.domain.subject(rig.attacker, released); - rig.domain.subject(rig.attacker, sibling); - regiment(released, "guard", 6, false, false); - regiment(sibling, "guard", 6, false, false); - regiment(rig.attacker, "infantry", 3, true, false); - WarCommitmentService.commitAllParticipants(rig.war); - assertEquals(3, WarCommitmentService.getCommitmentsForWar(rig.war.getId()).size()); - - assertTrue( - RelationManager.endVassalage( - subjectFirst ? released : rig.attacker, subjectFirst ? rig.attacker : released, false)); - - List remaining = WarCommitmentService.getCommitmentsForWar(rig.war.getId()); - assertEquals(2, remaining.size()); - assertTrue(remaining.stream().noneMatch(row -> released.getId().equals(row.sourceFactionId()))); - assertEquals( - 1, - remaining.stream() - .filter(row -> sibling.getId().equals(row.sourceFactionId())) - .mapToInt(WarCommitment::count) - .sum()); - assertEquals(3, committed(rig.attacker, "infantry")); - assertNull(RelationManager.getOverlord(released)); - assertEquals(rig.attacker.getId(), RelationManager.getOverlord(sibling)); - } - - @Test - void commitmentSnapshotsAreStableAndDebitsRejectInvalidRequestsWithoutLosingOtherRows() { - regiment(rig.attacker, "infantry", 3, true, false); - regiment(rig.attacker, "reserve", 0, true, false); - List first = WarCommitmentService.commitFaction(rig.war, rig.attacker); - assertEquals(first, WarCommitmentService.commitFaction(rig.war, rig.attacker)); - assertThrows(UnsupportedOperationException.class, () -> first.add(first.getFirst())); - assertEquals( - 0, - WarCommitmentService.debitCount(rig.war.getId(), rig.attacker.getId(), null, "reserve", 1)); - assertEquals( - 0, - WarCommitmentService.debitCount(rig.war.getId(), rig.attacker.getId(), null, "missing", 1)); - assertEquals(0, WarCommitmentService.debitCount(rig.war.getId(), null, null, "infantry", 1)); - assertEquals( - 0, - WarCommitmentService.debitCount( - rig.war.getId(), rig.attacker.getId(), null, "infantry", 0)); - assertEquals( - 0, WarCommitmentService.debitCount(-123, rig.attacker.getId(), null, "infantry", 1)); - assertEquals(first, WarCommitmentService.getCommitmentsForWar(rig.war.getId())); - assertEquals( - 3, - WarCommitmentService.debitCount( - rig.war.getId(), rig.attacker.getId(), null, "infantry", 10)); - assertEquals(1, WarCommitmentService.getCommitmentsForWar(rig.war.getId()).size()); - assertEquals( - 0, - WarCommitmentService.totalCommittedRegiments(rig.war.getId(), Set.of("unrelated"), null)); - assertEquals(0, WarCommitmentService.totalCommittedRegiments(rig.war.getId(), Set.of(), null)); - assertEquals( - 0, - WarCommitmentService.totalCommittedRegiments( - rig.war.getId(), Set.of(rig.attacker.getId()), row -> false)); - } - - @Test - void optionalCommitmentInputsLeaveAnExistingSnapshotUnchanged() { - regiment(rig.attacker, "infantry", 3, true, false); - List rows = WarCommitmentService.commitFaction(rig.war, rig.attacker); - assertTrue(WarCommitmentService.commitFaction(null, rig.attacker).isEmpty()); - assertTrue(WarCommitmentService.snapshotLevyForSide(null, rig.war.getAttackers()).isEmpty()); - assertTrue(WarCommitmentService.snapshotLevyForFighter(null, rig.attacker).isEmpty()); - assertTrue( - WarCommitmentService.snapshotLevyForFighter(rig.war, rig.domain.saved("neutral", "Neutral")) - .isEmpty()); - WarCommitmentService.commitAllParticipants(null); - WarCommitmentService.onVassalageEnded(null, rig.attacker); - WarCommitmentService.removeLevySubtree(null); - WarCommitmentService.restoreCommitments(-1, rows); - assertEquals(rows, WarCommitmentService.getCommitmentsForWar(rig.war.getId())); - assertEquals( - 3, - WarCommitmentService.totalCommittedRegiments( - rig.war.getId(), - new HashSet<>(Arrays.asList(null, rig.attacker.getId())), - row -> true)); - WarCommitmentService.restoreCommitments(rig.war.getId(), null); - assertTrue(WarCommitmentService.getCommitmentsForWar(rig.war.getId()).isEmpty()); - } - - @ParameterizedTest - @ValueSource(ints = {5, 15, 40}) - void ownProvinceMilitiaAbsorbsLossesBeforeProfessionalSoldiers(int deaths) { - rig.defender.addProvince(20); - Regiment militia = regiment(rig.defender, "militia", 2, false, false); - Regiment guard = regiment(rig.defender, "guard", 3, true, false); - regiment(rig.defender, "equipment", 8, false, true); - WarCommitmentService.commitFaction(rig.war, rig.defender); - - BattleCasualtyService.applyBattleCasualties(rig.war, battle, Map.of("defender", deaths)); - - int actualLoss = Math.min(5, deaths / 5); - assertEquals(Math.max(0, 2 - actualLoss), militia.getCurrentSlots()); - assertEquals(3 - Math.max(0, actualLoss - 2), guard.getCurrentSlots()); - assertEquals( - 5 - actualLoss, committed(rig.defender, "militia") + committed(rig.defender, "guard")); - verify(rig.bob) - .sendMessage( - "§7Campaign battle losses: attacker 0, defender " + actualLoss + " regiments."); - } - - @Test - void inapplicableAndSubRegimentDeathsLeaveTheRealArmyAndSnapshotUnchanged() { - Regiment guard = regiment(rig.attacker, "guard", 6, true, false); - List rows = WarCommitmentService.commitFaction(rig.war, rig.attacker); - long writes = rig.warWrites(); - BattleCasualtyService.applyBattleCasualties(rig.war, battle, Map.of("attacker", 4)); - BattleCasualtyService.applyBattleCasualties(rig.war, battle, Map.of("attacker", 0)); - BattleCasualtyService.applyBattleCasualties(rig.war, battle, null); - BattleCasualtyService.applyBattleCasualties(null, battle, Map.of("attacker", 10)); - battle.setBattleType(BattleType.RAID); - BattleCasualtyService.applyBattleCasualties(rig.war, battle, Map.of("attacker", 10)); - battle.setBattleType(BattleType.FIELD); - battle.setProvinceId(null); - rig.war.setScheduledBattleProvinceId(null); - BattleCasualtyService.applyBattleCasualties(rig.war, battle, Map.of("attacker", 10)); - assertEquals(6, guard.getCurrentSlots()); - assertEquals(rows, WarCommitmentService.getCommitmentsForWar(rig.war.getId())); - assertEquals(writes, rig.warWrites()); - Cache.warBattleDeathsPerRegimentLoss = 0; - assertEquals(1, BattleCasualtyService.regimentLossesForDeaths(9)); - assertEquals(0, BattleCasualtyService.regimentLossesForDeaths(-1)); - } - - @Test - void repeatedLevySnapshotsDoNotDuplicateRowsAndSeveringTheLastSourceClearsThem() { - rig.domain.lawGroup("conscription", Map.of("effects.faction.modifiers", List.of("LEVY(100)"))); - Faction subject = rig.domain.saved("last_levy_source", "SubjectLeader"); - rig.domain.subject(rig.attacker, subject); - regiment(subject, "guard", 0, false, false); - assertTrue(subject.getMilitary().adminAdjustSlots("guard", 2).allowed()); - assertEquals(2, subject.getMilitary().getRegiment("guard").getPaidSlots()); - List rows = WarCommitmentService.snapshotLevyForFighter(rig.war, rig.attacker); - assertEquals(1, rows.size()); - assertTrue(WarCommitmentService.snapshotLevyForFighter(rig.war, rig.attacker).isEmpty()); - assertEquals(rows, WarCommitmentService.getCommitmentsForWar(rig.war.getId())); - assertTrue(RelationManager.endVassalage(subject, rig.attacker, false)); - assertTrue(WarCommitmentService.getCommitmentsForWar(rig.war.getId()).isEmpty()); - assertEquals(2, subject.getMilitary().getRegiment("guard").getCurrentSlots()); - WarCommitmentService.removeLevySubtree(subject); - assertTrue(WarCommitmentService.getCommitmentsForWar(rig.war.getId()).isEmpty()); - } - - @Test - void persistedCommitmentSnapshotsRestoreTheirValuesAndDoNotAliasTheInputList() { - paidRegiment(rig.attacker, "infantry", 3, true); - paidRegiment(rig.defender, "guard", 2, false); - WarCommitmentService.commitAllParticipants(rig.war); - List original = - List.copyOf(WarCommitmentService.getCommitmentsForWar(rig.war.getId())); - WarData saved = WarMapper.toData(rig.war); - WarCommitmentService.clearCommitments(rig.war.getId()); - - War restored = - WarMapper.fromData(JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(saved), WarData.class)); - - assertNotNull(restored); - assertEquals(original, WarCommitmentService.getCommitmentsForWar(restored.getId())); - ArrayList replacement = new ArrayList<>(original); - WarCommitmentService.restoreCommitments(restored.getId(), replacement); - replacement.clear(); - assertEquals(original, WarCommitmentService.getCommitmentsForWar(restored.getId())); - assertThrows( - UnsupportedOperationException.class, - () -> WarCommitmentService.getCommitmentsForWar(restored.getId()).clear()); - BattleCasualtyService.applyBattleCasualties(restored, battle, Map.of("attacker", 5)); - assertEquals(2, committed(rig.attacker, "infantry")); - assertEquals(2, rig.attacker.getMilitary().getRegiment("infantry").getCurrentSlots()); - assertEquals(2, committed(rig.defender, "guard")); - } - - @Test - void releasingANestedFighterRemovesItsHeldLeviesButPreservesItsOwnArmyAndOtherSources() { - rig.domain.lawGroup("conscription", Map.of("effects.faction.modifiers", List.of("LEVY(100)"))); - Faction middle = rig.domain.saved("middle_fighter", "MiddleLeader"); - Faction grandchild = rig.domain.saved("grandchild_source", "GrandchildLeader"); - Faction sibling = rig.domain.saved("sibling_source", "SiblingLeader"); - rig.domain.subject(rig.attacker, middle); - rig.domain.subject(middle, grandchild); - rig.domain.subject(rig.attacker, sibling); - assertTrue(rig.war.getAttackers().getMainParticipants().getFirst().addBacker(middle)); - paidRegiment(rig.attacker, "infantry", 3, true); - paidRegiment(middle, "guard", 2, false); - paidRegiment(grandchild, "guard", 4, false); - paidRegiment(sibling, "guard", 4, false); - WarCommitmentService.commitAllParticipants(rig.war); - List before = - List.copyOf(WarCommitmentService.getCommitmentsForWar(rig.war.getId())); - assertEquals(4, before.size()); - assertTrue( - before.stream() - .anyMatch( - row -> - row.isLevyRow() - && middle.getId().equals(row.factionId()) - && grandchild.getId().equals(row.sourceFactionId()))); - - assertTrue(RelationManager.endVassalage(rig.attacker, middle, false)); - - List after = WarCommitmentService.getCommitmentsForWar(rig.war.getId()); - assertEquals(3, after.size()); - assertTrue( - after.stream() - .filter(WarCommitment::isLevyRow) - .allMatch(row -> sibling.getId().equals(row.sourceFactionId()))); - assertEquals(3, committed(rig.attacker, "infantry")); - assertEquals(2, committed(middle, "guard")); - assertEquals(2, middle.getMilitary().getRegiment("guard").getPaidSlots()); - assertEquals(4, grandchild.getMilitary().getRegiment("guard").getPaidSlots()); - assertEquals(middle.getId(), RelationManager.getOverlord(grandchild)); - assertNull(RelationManager.getOverlord(middle)); - } - - @Test - void casualtiesWithoutAnEligibleArmyDoNotAnnounceInventedLosses() { - paidRegiment(rig.attacker, "defensive_only", 3, false); - WarCommitmentService.commitAllParticipants(rig.war); - List before = - List.copyOf(WarCommitmentService.getCommitmentsForWar(rig.war.getId())); - - BattleCasualtyService.applyBattleCasualties( - rig.war, battle, Map.of("attacker", 15, "defender", 10)); - - assertEquals(before, WarCommitmentService.getCommitmentsForWar(rig.war.getId())); - assertEquals(3, rig.attacker.getMilitary().getRegiment("defensive_only").getCurrentSlots()); - verify(rig.alice, never()).sendMessage(startsWith("§7Campaign battle losses:")); - verify(rig.bob, never()).sendMessage(startsWith("§7Campaign battle losses:")); - } - - @Test - void militiaOnlyDefendersReportTheirActualLossWhenDeathsExceedTheirWholeArmy() { - rig.defender.addProvince(20); - Regiment militia = paidRegiment(rig.defender, "militia", 2, false); - WarCommitmentService.commitAllParticipants(rig.war); - BattleCasualtyService.applyBattleCasualties(rig.war, battle, Map.of("defender", 30)); - assertEquals(0, militia.getCurrentSlots()); - assertEquals(0, committed(rig.defender, "militia")); - verify(rig.bob).sendMessage("§7Campaign battle losses: attacker 0, defender 2 regiments."); - } - - @Test - void anUnconfiguredPublicBattleCannotDebitCampaignRegiments() { - Regiment infantry = paidRegiment(rig.attacker, "infantry", 3, true); - WarCommitmentService.commitAllParticipants(rig.war); - Battle unconfigured = new Battle("not_configured"); - unconfigured.setWarId(rig.war.getId()); - unconfigured.setProvinceId(20); - assertNull(unconfigured.getBattleType()); - assertFalse(BattleCasualtyService.shouldApply(rig.war, unconfigured, Map.of("attacker", 10))); - BattleCasualtyService.applyBattleCasualties(rig.war, unconfigured, Map.of("attacker", 10)); - assertEquals(3, infantry.getCurrentSlots()); - assertEquals(3, committed(rig.attacker, "infantry")); - } - - @ParameterizedTest - @ValueSource(strings = {"present", "missing_regiment", "missing_source"}) - void legacySourceSnapshotsRemainSafeAfterArmyConfigurationOrFactionRemoval(String sourceState) { - rig.domain.lawGroup("conscription", Map.of("effects.faction.modifiers", List.of("LEVY(100)"))); - Faction source = rig.domain.saved("legacy_source", "SourceLeader"); - source.addMember("SourceSecond"); - rig.domain.subject(rig.attacker, source); - Regiment guard = paidRegiment(source, "guard", 2, false); - Regiment reserve = paidRegiment(source, "reserve", 0, false); - assertTrue(rig.war.getAttackers().getMainParticipants().getFirst().addBacker(source)); - WarCommitmentService.commitAllParticipants(rig.war); - rig.war.getAttackers().getMainParticipants().getFirst().clean(source); - assertEquals(1, WarCommitmentService.snapshotLevyForFighter(rig.war, rig.attacker).size()); - guard.setSentToOverlord(2); - WarData saved = WarMapper.toData(rig.war); - if (sourceState.equals("missing_regiment")) source.getMilitary().getRegiments().remove(guard); - if (sourceState.equals("missing_source")) FactionManager.deleteFaction(source); - if (!sourceState.equals("present")) { - WarCommitmentService.clearCommitments(rig.war.getId()); - assertNotNull( - WarMapper.fromData(JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(saved), WarData.class))); - } - int defenderSlots = rig.defender.getMilitary().getManpower(false); - - assertDoesNotThrow( - () -> BattleCasualtyService.applyBattleCasualties(rig.war, battle, Map.of("attacker", 5))); - - assertEquals( - 1, - WarCommitmentService.getCommitmentsForWar(rig.war.getId()).stream() - .filter(WarCommitment::isLevyRow) - .mapToInt(WarCommitment::count) - .sum()); - assertEquals(1, committed(source, "guard")); - assertEquals(0, reserve.getCurrentSlots()); - assertEquals(defenderSlots, rig.defender.getMilitary().getManpower(false)); - if (sourceState.equals("present")) { - assertEquals(1, guard.getCurrentSlots()); - assertEquals(1, guard.sentToOverlord()); - } else if (sourceState.equals("missing_regiment")) { - assertNull(source.getMilitary().getRegiment("guard")); - assertEquals(2, guard.getCurrentSlots()); - } else { - assertNull(FactionManager.getByString(source.getId())); - assertEquals(2, guard.getCurrentSlots()); - } - } - - @Test - void proportionalLevyLossesChooseTheLargerSourcesWithoutDebitingZeroAllocations() { - rig.domain.lawGroup("conscription", Map.of("effects.faction.modifiers", List.of("LEVY(100)"))); - Faction small = rig.domain.saved("small_levy", "SmallLeader"); - Faction large = rig.domain.saved("large_levy", "LargeLeader"); - Faction enemySource = rig.domain.saved("enemy_levy", "EnemyLeader"); - for (int i = 0; i < 3; i++) large.addMember("LargeMember" + i); - enemySource.addMember("EnemyMember1"); - enemySource.addMember("EnemyMember2"); - rig.domain.subject(rig.attacker, small); - rig.domain.subject(rig.attacker, large); - rig.domain.subject(rig.defender, enemySource); - Regiment smallGuard = paidRegiment(small, "guard", 1, false); - Regiment largeLight = paidRegiment(large, "light", 1, false); - Regiment largeHeavy = paidRegiment(large, "heavy", 9, false); - Regiment enemyGuard = paidRegiment(enemySource, "guard", 3, false); - assertTrue(rig.war.getAttackers().getMainParticipants().getFirst().addBacker(large)); - WarCommitmentService.commitAllParticipants(rig.war); - rig.war.getAttackers().getMainParticipants().getFirst().clean(large); - assertEquals(1, WarCommitmentService.snapshotLevyForFighter(rig.war, rig.attacker).size()); - rig.attacker.getMilitary().getLevies(); - rig.defender.getMilitary().getLevies(); - List before = - List.copyOf(WarCommitmentService.getCommitmentsForWar(rig.war.getId())); - assertEquals( - 4, - before.stream() - .filter(row -> large.getId().equals(row.sourceFactionId())) - .mapToInt(WarCommitment::count) - .sum()); - - BattleCasualtyService.applyBattleCasualties( - rig.war, battle, Map.of("attacker", 5, "defender", 5)); - - assertEquals(1, smallGuard.getCurrentSlots()); - assertEquals(1, largeLight.getCurrentSlots()); - assertEquals(8, largeHeavy.getCurrentSlots()); - assertEquals(2, enemyGuard.getCurrentSlots()); - assertEquals(1, committed(large, "light")); - assertEquals(8, committed(large, "heavy")); - List after = WarCommitmentService.getCommitmentsForWar(rig.war.getId()); - assertEquals( - 1, - after.stream() - .filter(row -> small.getId().equals(row.sourceFactionId())) - .mapToInt(WarCommitment::count) - .sum()); - assertEquals( - 3, - after.stream() - .filter(row -> large.getId().equals(row.sourceFactionId())) - .mapToInt(WarCommitment::count) - .sum()); - assertEquals( - 2, - after.stream() - .filter(row -> enemySource.getId().equals(row.sourceFactionId())) - .mapToInt(WarCommitment::count) - .sum()); - verify(rig.alice).sendMessage("§7Campaign battle losses: attacker 1, defender 1 regiments."); - } - - @Test - void aSmallerCurrentLevyLedgerDoesNotGoNegativeWhenAnOlderSnapshotTakesLosses() { - rig.domain.lawGroup("conscription", Map.of("effects.faction.modifiers", List.of("LEVY(100)"))); - Faction source = rig.domain.saved("shrinking_source", "SourceLeader"); - source.addMember("DepartingOne"); - source.addMember("DepartingTwo"); - rig.domain.subject(rig.attacker, source); - Regiment guard = paidRegiment(source, "guard", 4, false); - assertTrue(rig.war.getAttackers().getMainParticipants().getFirst().addBacker(source)); - WarCommitmentService.commitAllParticipants(rig.war); - rig.war.getAttackers().getMainParticipants().getFirst().clean(source); - assertEquals( - 3, WarCommitmentService.snapshotLevyForFighter(rig.war, rig.attacker).getFirst().count()); - rig.attacker.getMilitary().getLevies(); - assertEquals(3, guard.sentToOverlord()); - source.forceRemoveMember("DepartingOne"); - source.forceRemoveMember("DepartingTwo"); - rig.attacker.getMilitary().getLevies(); - assertEquals(1, guard.sentToOverlord()); - - BattleCasualtyService.applyBattleCasualties(rig.war, battle, Map.of("attacker", 10)); - - assertEquals(2, guard.getCurrentSlots()); - assertEquals(2, committed(source, "guard")); - assertEquals(0, guard.sentToOverlord()); - assertEquals( - 1, - WarCommitmentService.getCommitmentsForWar(rig.war.getId()).stream() - .filter(WarCommitment::isLevyRow) - .mapToInt(WarCommitment::count) - .sum()); - assertEquals(List.of("SourceLeader"), source.getMembers()); - } - - @Test - void publicSnapshotRestoreToleratesAnEmptyLevyRowAlongsideAValidSource() { - rig.domain.lawGroup("conscription", Map.of("effects.faction.modifiers", List.of("LEVY(100)"))); - Faction funded = rig.domain.saved("funded_source", "FundedLeader"); - Faction empty = rig.domain.saved("empty_source", "EmptyLeader"); - rig.domain.subject(rig.attacker, funded); - rig.domain.subject(rig.attacker, empty); - Regiment guard = paidRegiment(funded, "guard", 2, false); - Regiment emptyGuard = paidRegiment(empty, "guard", 0, false); - WarCommitmentService.commitAllParticipants(rig.war); - List restored = - new ArrayList<>(WarCommitmentService.getCommitmentsForWar(rig.war.getId())); - assertEquals(1, restored.size()); - restored.add( - new WarCommitment( - rig.war.getId(), - rig.attacker.getId(), - empty.getId(), - WarCommitment.LEVY_REGIMENT_ID, - 0, - restored.getFirst().committedAt())); - WarCommitmentService.restoreCommitments(rig.war.getId(), restored); - rig.attacker.getMilitary().getLevies(); - - BattleCasualtyService.applyBattleCasualties(rig.war, battle, Map.of("attacker", 5)); - - assertEquals(1, guard.getCurrentSlots()); - assertEquals(0, emptyGuard.getCurrentSlots()); - assertTrue( - WarCommitmentService.getCommitmentsForWar(rig.war.getId()).stream() - .allMatch(row -> row.count() == 0)); - verify(rig.alice).sendMessage("§7Campaign battle losses: attacker 1, defender 0 regiments."); - } - - private Regiment paidRegiment(Faction faction, String id, int slots, boolean offense) { - Regiment regiment = regiment(faction, id, 0, offense, false); - if (slots > 0) assertTrue(faction.getMilitary().adminAdjustSlots(id, slots).allowed()); - return regiment; - } - - private Regiment regiment( - Faction faction, String id, int slots, boolean offense, boolean equipment) { - YamlConfiguration yaml = new YamlConfiguration(); - yaml.set("item.material", "PAPER"); - yaml.set("default-slots", slots); - yaml.set("offense", offense); - yaml.set("equipment", equipment); - Regiment regiment = new Regiment(id, yaml); - faction.getMilitary().getRegiments().add(regiment); - return regiment; - } - - private int committed(Faction faction, String regiment) { - return WarCommitmentService.getCommitmentsForWar(rig.war.getId()).stream() - .filter(row -> row.factionId().equals(faction.getId()) && row.regimentId().equals(regiment)) - .mapToInt(WarCommitment::count) - .sum(); - } -} diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleCasualtyServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleCasualtyServiceTest.java index 72282efd..faf4c488 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleCasualtyServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleCasualtyServiceTest.java @@ -1,358 +1,173 @@ package net.tfminecraft.simplefactions.war.battle.military; - -import net.tfminecraft.simplefactions.war.campaign.progression.BelligerentRole; +import static net.tfminecraft.simplefactions.war.wounds.WoundFixture.regiment; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.doAnswer; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static org.mockito.Mockito.CALLS_REAL_METHODS; -import java.util.ArrayList; -import java.util.HashMap; + import java.util.List; import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; +import org.bukkit.entity.Player; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; -import org.bukkit.Bukkit; - -import net.tfminecraft.simplefactions.Cache; -import net.tfminecraft.simplefactions.army.Military; -import net.tfminecraft.simplefactions.army.Regiment; -import net.tfminecraft.simplefactions.managers.FactionManager; -import net.tfminecraft.simplefactions.managers.TitleManager; -import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.objects.Faction; -import net.tfminecraft.simplefactions.objects.FactionModifier; -import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.core.WarCommitment; import net.tfminecraft.simplefactions.war.battle.engine.core.Battle; +import net.tfminecraft.simplefactions.war.battle.engine.core.BattleSide; import net.tfminecraft.simplefactions.war.battle.enums.BattleType; import net.tfminecraft.simplefactions.war.battle.template.BattleTemplate; import net.tfminecraft.simplefactions.war.campaign.progression.CampaignSideService.CampaignSideRole; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; -import net.tfminecraft.simplefactions.war.enums.CampaignPhase; -import net.tfminecraft.simplefactions.enums.FactionModifiers; - -import java.time.Instant; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.enums.WarEndReason; +import net.tfminecraft.simplefactions.war.history.BattleResolution; +import net.tfminecraft.simplefactions.war.wounds.BattleForce; +import net.tfminecraft.simplefactions.war.wounds.TestForces; +import net.tfminecraft.simplefactions.war.wounds.WarLedger; +import net.tfminecraft.simplefactions.war.wounds.WoundFixture; class BattleCasualtyServiceTest { - private static final List CREATED_WAR_IDS = new ArrayList<>(); private static final int PROVINCE_ID = 42; - private List savedWars; - private Map factionsById; - private MockedStatic bukkitMock; - private MockedStatic warManagerMock; - private int savedDeathsPerRegiment; + private WoundFixture fixture; @BeforeEach void setUp() { - savedDeathsPerRegiment = Cache.warBattleDeathsPerRegimentLoss; - Cache.warBattleDeathsPerRegimentLoss = 1; - savedWars = new ArrayList<>(WarManager.get()); - WarManager.get().clear(); - factionsById = new HashMap<>(); - bukkitMock = mockStatic(Bukkit.class); - bukkitMock.when(() -> Bukkit.getPlayerExact(anyString())).thenReturn(null); - warManagerMock = mockStatic(WarManager.class, CALLS_REAL_METHODS); - warManagerMock.when(() -> WarManager.persist(any())).then(inv -> null); + fixture = new WoundFixture(); } @AfterEach void tearDown() { - Cache.warBattleDeathsPerRegimentLoss = savedDeathsPerRegiment; - warManagerMock.close(); - bukkitMock.close(); - for (War war : new ArrayList<>(WarManager.get())) { - WarCommitmentService.clearCommitments(war.getId()); - } - // Test wars are not registered, so their commitment rows are cleared by id. - for (int id : CREATED_WAR_IDS) { - WarCommitmentService.clearCommitments(id); - } - CREATED_WAR_IDS.clear(); - WarManager.get().clear(); - WarManager.get().addAll(savedWars); + fixture.close(); } @Test - void militiaDebitedFirst_onOwnLand() { - Faction attacker = fighter("atk", Map.of("professional", 10)); - Faction defender = fighter("def", Map.of("militia", 6, "garrison", 10)); - War war = baseWar(1, attacker, defender); - war.setCampaignPhase(CampaignPhase.INVASION); - seedOwnRow(war, "def", "militia", 6); - seedOwnRow(war, "def", "garrison", 10); - - Battle battle = campaignBattle(war, PROVINCE_ID); - Map casualties = Map.of(BattleTemplate.DEFENDER_SIDE, 8); - - try (MockedStatic titles = mockStatic(TitleManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(defender); - stubFactions(factions); - - BattleCasualtyService.applyBattleCasualties(war, battle, casualties); - - assertEquals(0, commitmentCount(war.getId(), "def", null, "militia")); - assertEquals(8, commitmentCount(war.getId(), "def", null, "garrison")); - assertEquals(0, liveSlots("def", "militia")); - assertEquals(8, liveSlots("def", "garrison")); - } + void aFoughtBattleIsRecordedWithTheForcesItStartedWith() { + Faction atk = fixture.faction("atk", regiment("professional", 10, 0)); + Faction def = fixture.faction("def", regiment("professional", 6, 0)); + War war = fixture.war(1, atk, def); + Battle battle = battle(war, BattleType.FIELD, TestForces.regiments("atk", 10), TestForces.regiments("def", 6)); + + BattleCasualtyTally tally = BattleCasualtyService.applyBattleCasualties(war, battle, + Map.of(BattleTemplate.ATTACKER_SIDE, 12, BattleTemplate.DEFENDER_SIDE, 30), BattleResolution.FOUGHT); + + WarLedger.BattleEntry entry = war.getLedger().battle(1); + assertEquals("b1", entry.battleId()); + assertEquals("fought", entry.resolution()); + assertEquals(PROVINCE_ID, entry.provinceId()); + assertEquals(12, entry.attacker().deaths()); + assertEquals(TestForces.regiments("atk", 10), entry.attacker().forces()); + assertEquals(List.of("atk attacker professional 2", "def defender professional 6"), lines(tally)); + assertEquals(10, atk.getMilitary().getRegiment("professional").getCurrentSlots(), + "nothing is taken until the war ends"); } @Test - void proportionalAcrossContributors() { - Faction attacker = fighter("atk", Map.of("professional", 10)); - Faction ally = fighter("ally", Map.of("professional", 5)); - Faction defender = fighter("def", Map.of("professional", 1)); - War war = baseWar(2, attacker, defender); - war.getAttackers().getMainParticipants().get(0).getAllies().put(ally, true); - war.setCampaignPhase(CampaignPhase.INVASION); - seedOwnRow(war, "atk", "professional", 10); - seedOwnRow(war, "ally", "professional", 5); - - Battle battle = campaignBattle(war, PROVINCE_ID); - Map casualties = Map.of(BattleTemplate.ATTACKER_SIDE, 3); - - try (MockedStatic titles = mockStatic(TitleManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(defender); - stubFactions(factions); + void inACounterPushTheBattleAttackersAreTheWarDefenders() { + Faction atk = fixture.faction("atk", regiment("professional", 10, 0)); + Faction def = fixture.faction("def", regiment("professional", 6, 0)); + War war = fixture.war(2, atk, def); + Battle battle = battle(war, BattleType.SIEGE, TestForces.regiments("def", 6), TestForces.regiments("atk", 10)); + when(battle.getOffensiveCoalition()).thenReturn(CampaignSideRole.DEFENDER); - BattleCasualtyTally tally = BattleCasualtyService.applyBattleCasualties(war, battle, casualties); + BattleCasualtyTally tally = BattleCasualtyService.applyBattleCasualties(war, battle, + Map.of(BattleTemplate.ATTACKER_SIDE, 15), BattleResolution.FOUGHT); - assertEquals(8, commitmentCount(war.getId(), "atk", null, "professional")); - assertEquals(4, commitmentCount(war.getId(), "ally", null, "professional")); - assertEquals(java.util.Set.of("atk attacker professional 2", "ally attacker professional 1"), - new java.util.HashSet<>(lines(tally))); - assertEquals(8, liveSlots("atk", "professional")); - assertEquals(4, liveSlots("ally", "professional")); - } + assertEquals(15, war.getLedger().battle(1).defender().deaths()); + assertEquals(0, war.getLedger().battle(1).attacker().deaths()); + assertEquals(List.of("def defender professional 3"), lines(tally)); } @Test - void overlordMilitiaNotDebited_inVassalLand() { - Faction overlord = fighter("o", Map.of("militia", 9, "professional", 2)); - Faction vassal = fighter("v", Map.of("militia", 4, "garrison", 1)); - Faction defender = fighter("def", Map.of("professional", 1)); - War war = baseWar(3, overlord, defender); - war.getAttackers().getMainParticipants().get(0).getSubjects().add(vassal); - war.setCampaignPhase(CampaignPhase.COUNTER_PUSH); - war.setInitiativeHolder(net.tfminecraft.simplefactions.war.campaign.progression.BelligerentRole.DEFENDER); - seedOwnRow(war, "o", "militia", 9); - seedOwnRow(war, "o", "professional", 2); - seedOwnRow(war, "v", "militia", 4); - seedOwnRow(war, "v", "garrison", 1); - - Battle battle = campaignBattle(war, PROVINCE_ID); - Map casualties = Map.of(BattleTemplate.ATTACKER_SIDE, 4); - - try (MockedStatic titles = mockStatic(TitleManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(vassal); - stubFactions(factions); + void anAutoresolveReadsTheForcesNowAndNoCasualtiesMeansNoDeaths() { + War war = fixture.war(3, fixture.faction("atk"), fixture.faction("def")); + Battle carrier = mock(Battle.class); + when(carrier.getId()).thenReturn("autoresolve-3"); + when(carrier.getWarId()).thenReturn(3); + when(carrier.getBattleType()).thenReturn(BattleType.FIELD); + when(carrier.getProvinceId()).thenReturn(null); + war.setScheduledBattleProvinceId(PROVINCE_ID); + try (MockedStatic forces = mockStatic(BattleForces.class)) { + forces.when(() -> BattleForces.build(eq(war), eq(PROVINCE_ID), same(war.getAttackers()), eq(carrier), any())) + .thenReturn(TestForces.regiments("atk", 3)); + forces.when(() -> BattleForces.build(eq(war), eq(PROVINCE_ID), same(war.getDefenders()), eq(carrier), any())) + .thenReturn(List.of()); - BattleCasualtyService.applyBattleCasualties(war, battle, casualties); - - assertEquals(9, commitmentCount(war.getId(), "o", null, "militia")); - assertEquals(0, commitmentCount(war.getId(), "v", null, "militia")); - assertEquals(0, liveSlots("v", "militia")); - assertEquals(9, liveSlots("o", "militia")); + BattleCasualtyService.applyBattleCasualties(war, carrier, null, BattleResolution.AUTORESOLVE); } - } - - @Test - void noWinnerStillApplies() { - Faction attacker = fighter("atk", Map.of("professional", 5)); - Faction defender = fighter("def", Map.of("garrison", 4)); - War war = baseWar(4, attacker, defender); - war.setCampaignPhase(CampaignPhase.INVASION); - seedOwnRow(war, "atk", "professional", 5); - seedOwnRow(war, "def", "garrison", 4); - - Battle battle = campaignBattle(war, PROVINCE_ID); - Map casualties = Map.of( - BattleTemplate.ATTACKER_SIDE, 2, - BattleTemplate.DEFENDER_SIDE, 1); - - try (MockedStatic titles = mockStatic(TitleManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(defender); - stubFactions(factions); - BattleCasualtyService.applyBattleCasualties(war, battle, casualties); - - assertEquals(3, commitmentCount(war.getId(), "atk", null, "professional")); - assertEquals(3, commitmentCount(war.getId(), "def", null, "garrison")); - } + WarLedger.BattleEntry entry = war.getLedger().battle(1); + assertEquals("autoresolve", entry.resolution()); + assertEquals(TestForces.regiments("atk", 3), entry.attacker().forces()); + assertEquals(0, entry.attacker().deaths()); } @Test - void deathsBelowOneRegiment_areNotLost() { - Cache.warBattleDeathsPerRegimentLoss = 5; - Faction attacker = fighter("atk", Map.of("professional", 10)); - Faction defender = fighter("def", Map.of("militia", 6, "garrison", 10)); - War war = baseWar(7, attacker, defender); - war.setCampaignPhase(CampaignPhase.INVASION); - seedOwnRow(war, "def", "militia", 6); - seedOwnRow(war, "def", "garrison", 10); - - Battle battle = campaignBattle(war, PROVINCE_ID); - Map casualties = Map.of(BattleTemplate.DEFENDER_SIDE, 4); + void onlyActiveWarFieldBattlesAndSiegesAreRecorded() { + War war = fixture.war(4, fixture.faction("atk"), fixture.faction("def")); + Battle raid = battle(war, BattleType.RAID, List.of(), List.of()); + Battle manual = battle(war, BattleType.FIELD, List.of(), List.of()); + when(manual.getWarId()).thenReturn(null); - try (MockedStatic titles = mockStatic(TitleManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(defender); - stubFactions(factions); - - BattleCasualtyService.applyBattleCasualties(war, battle, casualties); - - assertEquals(6, commitmentCount(war.getId(), "def", null, "militia")); - assertEquals(10, commitmentCount(war.getId(), "def", null, "garrison")); - assertEquals(6, liveSlots("def", "militia")); - assertEquals(10, liveSlots("def", "garrison")); - } + assertTrue(BattleCasualtyService.applyBattleCasualties(war, raid, Map.of(), BattleResolution.FOUGHT).losses().isEmpty()); + assertFalse(BattleCasualtyService.shouldApply(war, manual)); + assertFalse(BattleCasualtyService.shouldApply(war, null)); + assertFalse(BattleCasualtyService.shouldApply(null, raid)); + war.end(WarEndReason.ADMIN_END); + assertFalse(BattleCasualtyService.shouldApply(war, battle(war, BattleType.SIEGE, List.of(), List.of()))); + assertTrue(war.getLedger().battles().isEmpty()); } @Test - void floorDeaths_debitOneRegimentPerFive() { - Cache.warBattleDeathsPerRegimentLoss = 5; - Faction attacker = fighter("atk", Map.of("professional", 10)); - Faction defender = fighter("def", Map.of("militia", 6, "professional", 10)); - War war = baseWar(8, attacker, defender); - war.setCampaignPhase(CampaignPhase.INVASION); - seedOwnRow(war, "def", "militia", 6); - seedOwnRow(war, "def", "professional", 10); + void woundsAreAnnouncedToOnlineMembersOfBothSides() { + Faction atk = fixture.faction("atk", regiment("professional", 10, 0)); + Faction def = fixture.faction("def", regiment("professional", 6, 0)); + when(atk.getMembers()).thenReturn(List.of("Online", "Offline")); + when(def.getMembers()).thenReturn(List.of("Away")); + War war = fixture.war(5, atk, def); + Player online = mock(Player.class); + when(online.isOnline()).thenReturn(true); + Player away = mock(Player.class); + Battle battle = battle(war, BattleType.FIELD, TestForces.regiments("atk", 10), TestForces.regiments("def", 6)); - Battle battle = campaignBattle(war, PROVINCE_ID); - Map casualties = Map.of(BattleTemplate.DEFENDER_SIDE, 12); + try (MockedStatic members = mockStatic(Members.class)) { + members.when(() -> Members.player("Online")).thenReturn(online); + members.when(() -> Members.player("Away")).thenReturn(away); - try (MockedStatic titles = mockStatic(TitleManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(defender); - stubFactions(factions); + BattleCasualtyService.applyBattleCasualties(war, battle, + Map.of(BattleTemplate.ATTACKER_SIDE, 5, BattleTemplate.DEFENDER_SIDE, 10), BattleResolution.FOUGHT); + verify(online).sendMessage("§7Campaign battle wounds: attacker 1, defender 2 regiments." + + " Wounded regiments cannot fight and are lost when the war ends."); + verify(away, never()).sendMessage(any(String.class)); - BattleCasualtyService.applyBattleCasualties(war, battle, casualties); - - assertEquals(4, commitmentCount(war.getId(), "def", null, "militia")); - assertEquals(10, commitmentCount(war.getId(), "def", null, "professional")); - assertEquals(4, liveSlots("def", "militia")); - assertEquals(10, liveSlots("def", "professional")); + BattleCasualtyService.applyBattleCasualties(war, battle, Map.of(BattleTemplate.ATTACKER_SIDE, 1), + BattleResolution.FOUGHT); + verify(online).sendMessage(any(String.class)); } } @Test - void regimentLossesForDeaths_usesFloor() { + void deathsTurnIntoSlotsAtTheConfiguredRate() { + assertEquals(5, BattleCasualtyService.deathsPerRegimentLoss()); assertEquals(0, BattleCasualtyService.regimentLossesForDeaths(0)); - assertEquals(0, BattleCasualtyService.regimentLossesForDeaths(-3)); - Cache.warBattleDeathsPerRegimentLoss = 5; assertEquals(0, BattleCasualtyService.regimentLossesForDeaths(4)); - assertEquals(1, BattleCasualtyService.regimentLossesForDeaths(5)); assertEquals(2, BattleCasualtyService.regimentLossesForDeaths(11)); - Cache.warBattleDeathsPerRegimentLoss = 0; - assertEquals(1, BattleCasualtyService.regimentLossesForDeaths(5)); - } - - @Test - void equipmentSlotsAreNotLost() { - Faction attacker = fighter("atk", Map.of("professional", 2)); - Faction defender = fighter("def", Map.of("professional", 4, "artillery", 5)); - War war = baseWar(8801, attacker, defender); - war.setCampaignPhase(CampaignPhase.INVASION); - seedOwnRow(war, "def", "professional", 4); - seedOwnRow(war, "def", "artillery", 5); - - Battle battle = campaignBattle(war, PROVINCE_ID); - Map casualties = Map.of(BattleTemplate.DEFENDER_SIDE, 5); - - try (MockedStatic titles = mockStatic(TitleManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(defender); - stubFactions(factions); - - BattleCasualtyService.applyBattleCasualties(war, battle, casualties); - - assertEquals(0, commitmentCount(war.getId(), "def", null, "professional")); - assertEquals(5, commitmentCount(war.getId(), "def", null, "artillery")); - assertEquals(0, liveSlots("def", "professional")); - assertEquals(5, liveSlots("def", "artillery")); - } - } - - @Test - void levyDebitSkipsEquipmentOnTheSource() { - Faction attacker = fighter("atk", Map.of()); - Faction source = fighter("src", Map.of("professional", 8, "artillery", 8)); - Faction defender = fighter("def", Map.of("professional", 1)); - Regiment artillery = source.getMilitary().getRegiment("artillery"); - AtomicInteger artillerySent = new AtomicInteger(8); - when(artillery.sentToOverlord()).thenAnswer(inv -> artillerySent.get()); - War war = baseWar(8802, attacker, defender); - war.setCampaignPhase(CampaignPhase.INVASION); - seedOwnRow(war, "src", "professional", 8); - seedOwnRow(war, "src", "artillery", 8); - seedLevyRow(war, "atk", "src", 6); - - Battle battle = campaignBattle(war, PROVINCE_ID); - Map casualties = Map.of(BattleTemplate.ATTACKER_SIDE, 3); - - try (MockedStatic titles = mockStatic(TitleManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(defender); - stubFactions(factions); - - BattleCasualtyService.applyBattleCasualties(war, battle, casualties); - - assertEquals(5, commitmentCount(war.getId(), "src", null, "professional")); - assertEquals(8, commitmentCount(war.getId(), "src", null, "artillery")); - assertEquals(5, liveSlots("src", "professional")); - assertEquals(8, liveSlots("src", "artillery")); - assertEquals(8, artillerySent.get()); - } } @Test - void counterPush_attackerSideDeathsDebitWarDefenders() { - Faction attacker = fighter("atk", Map.of("professional", 10)); - Faction defender = fighter("def", Map.of("professional", 10)); - War war = baseWar(9, attacker, defender); - war.setInitiativeHolder(BelligerentRole.DEFENDER); - seedOwnRow(war, "atk", "professional", 10); - seedOwnRow(war, "def", "professional", 10); - - Battle battle = campaignBattle(war, PROVINCE_ID); - when(battle.getOffensiveCoalition()).thenReturn(CampaignSideRole.DEFENDER); - Map casualties = Map.of(BattleTemplate.ATTACKER_SIDE, 3); - - try (MockedStatic titles = mockStatic(TitleManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(attacker); - stubFactions(factions); - - BattleCasualtyTally tally = BattleCasualtyService.applyBattleCasualties(war, battle, casualties); - - assertEquals(10, commitmentCount(war.getId(), "atk", null, "professional")); - assertEquals(7, commitmentCount(war.getId(), "def", null, "professional")); - // The battle's attacking side was the war's defenders, so the losses are theirs. - assertEquals(List.of("def defender professional 3"), lines(tally)); - } - } - - @Test - void theTallySumsRepeatedDebitsOfOneRegimentAndKeepsItsName() { - Faction faction = fighter("atk", Map.of("professional", 4)); - Regiment regiment = faction.getMilitary().getRegiment("professional"); - when(regiment.getName()).thenReturn("#c9a227Professionals"); + void theTallySumsRepeatedWoundsOfOneRegimentAndKeepsItsName() { + Faction faction = fixture.faction("atk", regiment("professional", 4, 0)); + var regiment = faction.getMilitary().getRegiment("professional"); BattleCasualtyTally tally = BattleCasualtyTally.empty(); tally.add(faction, "attacker", regiment, 1); @@ -360,179 +175,30 @@ void theTallySumsRepeatedDebitsOfOneRegimentAndKeepsItsName() { tally.add(faction, "defender", regiment, 1); assertEquals(List.of("atk attacker professional 3", "atk defender professional 1"), lines(tally)); - assertEquals("#c9a227Professionals", tally.losses().get(0).regimentName()); - } - - @Test - void nothingIsTalliedWhenNoCasualtiesApply() { - Faction attacker = fighter("atk", Map.of("professional", 5)); - Faction defender = fighter("def", Map.of("professional", 4)); - War war = baseWar(10, attacker, defender); - Battle battle = campaignBattle(war, PROVINCE_ID); - - assertTrue(BattleCasualtyService.applyBattleCasualties(war, battle, Map.of()).losses().isEmpty()); - } - - private static List lines(BattleCasualtyTally tally) { - List lines = new ArrayList<>(); - for (BattleCasualtyTally.Loss loss : tally.losses()) { - lines.add(loss.faction().getId() + " " + loss.side() + " " + loss.regimentId() + " " + loss.count()); - } - return lines; - } - - @Test - void skipsManualBattle() { - Faction attacker = fighter("atk", Map.of("professional", 5)); - Faction defender = fighter("def", Map.of("professional", 4)); - War war = baseWar(5, attacker, defender); - seedOwnRow(war, "atk", "professional", 5); - - Battle battle = mock(Battle.class); - when(battle.getWarId()).thenReturn(null); - when(battle.getBattleType()).thenReturn(BattleType.FIELD); - assertFalse(BattleCasualtyService.shouldApply(war, battle, Map.of(BattleTemplate.ATTACKER_SIDE, 3))); - assertEquals(5, commitmentCount(war.getId(), "atk", null, "professional")); - } - - @Test - void levyDebitsSourceSlots() { - Faction attacker = fighter("atk", Map.of()); - Faction source = fighter("src", Map.of("professional", 8)); - Faction defender = fighter("def", Map.of("professional", 1)); - Regiment sourceRegiment = source.getMilitary().getRegiment("professional"); - AtomicInteger sent = new AtomicInteger(8); - when(sourceRegiment.sentToOverlord()).thenAnswer(inv -> sent.get()); - doAnswer(inv -> { - sent.set(inv.getArgument(0, Integer.class)); - return null; - }).when(sourceRegiment).setSentToOverlord(org.mockito.ArgumentMatchers.anyInt()); - War war = baseWar(6, attacker, defender); - war.setCampaignPhase(CampaignPhase.INVASION); - seedOwnRow(war, "src", "professional", 8); - seedLevyRow(war, "atk", "src", 6); - - Battle battle = campaignBattle(war, PROVINCE_ID); - Map casualties = Map.of(BattleTemplate.ATTACKER_SIDE, 3); - - try (MockedStatic titles = mockStatic(TitleManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(defender); - stubFactions(factions); - - BattleCasualtyTally tally = BattleCasualtyService.applyBattleCasualties(war, battle, casualties); - - assertEquals(3, commitmentCount(war.getId(), "atk", "src", WarCommitment.LEVY_REGIMENT_ID)); - assertEquals(5, commitmentCount(war.getId(), "src", null, "professional")); - assertEquals(5, liveSlots("src", "professional")); - // The lending subject is the one that lost the regiments. - assertEquals(List.of("src attacker professional 3"), lines(tally)); - } + assertEquals("Name professional", tally.losses().get(0).regimentName()); } - private static Battle campaignBattle(War war, int provinceId) { + private static Battle battle(War war, BattleType type, List attackerForces, + List defenderForces) { Battle battle = mock(Battle.class); + when(battle.getId()).thenReturn("b1"); when(battle.getWarId()).thenReturn(war.getId()); - when(battle.getProvinceId()).thenReturn(provinceId); - when(battle.getBattleType()).thenReturn(BattleType.FIELD); + when(battle.getProvinceId()).thenReturn(PROVINCE_ID); + when(battle.getBattleType()).thenReturn(type); + BattleSide attacker = mock(BattleSide.class); + when(attacker.hasForces()).thenReturn(true); + when(attacker.getForces()).thenReturn(attackerForces); + BattleSide defender = mock(BattleSide.class); + when(defender.hasForces()).thenReturn(true); + when(defender.getForces()).thenReturn(defenderForces); + when(battle.getSideById(BattleTemplate.ATTACKER_SIDE)).thenReturn(attacker); + when(battle.getSideById(BattleTemplate.DEFENDER_SIDE)).thenReturn(defender); return battle; } - private void stubFactions(MockedStatic factions) { - factions.when(() -> FactionManager.getByString(anyString())) - .thenAnswer(inv -> factionsById.get(inv.getArgument(0, String.class).toLowerCase())); - } - - private static void seedOwnRow(War war, String factionId, String regimentId, int count) { - appendRowForTest(new WarCommitment( - war.getId(), - factionId, - null, - regimentId, - count, - Instant.now())); - } - - private static void seedLevyRow(War war, String holderId, String sourceId, int count) { - appendRowForTest(new WarCommitment( - war.getId(), - holderId, - sourceId, - WarCommitment.LEVY_REGIMENT_ID, - count, - Instant.now())); - } - - private static void appendRowForTest(WarCommitment row) { - try { - java.lang.reflect.Field field = WarCommitmentService.class.getDeclaredField("commitmentsByWar"); - field.setAccessible(true); - @SuppressWarnings("unchecked") - Map> store = - (Map>) field.get(null); - store.computeIfAbsent(row.warId(), ignored -> new ArrayList<>()).add(row); - } catch (ReflectiveOperationException e) { - throw new RuntimeException(e); - } - } - - private static int commitmentCount(int warId, String factionId, String sourceId, String regimentId) { - return BattleCasualtyService.getCommitmentCount(warId, factionId, sourceId, regimentId); - } - - private int liveSlots(String factionId, String regimentId) { - Faction faction = factionsById.get(factionId.toLowerCase()); - if (faction == null || faction.getMilitary() == null) { - return 0; - } - Regiment regiment = faction.getMilitary().getRegiment(regimentId); - return regiment != null ? regiment.getCurrentSlots() : 0; - } - - private static War baseWar(int id, Faction attacker, Faction defender) { - when(attacker.getName()).thenReturn("Attacker"); - when(defender.getName()).thenReturn("Defender"); - WarCommitmentService.clearCommitments(id); - CREATED_WAR_IDS.add(id); - War war = new War(id, attacker, defender); - war.setCampaignProvinces(List.of(PROVINCE_ID, 43, 44)); - war.setCursorIndex(0); - return war; - } - - private Faction fighter(String id, Map slots) { - Faction faction = mock(Faction.class); - when(faction.getId()).thenReturn(id); - when(faction.getMembers()).thenReturn(List.of("a", "b", "c", "d", "e", "f", "g", "h", "i", "j")); - FactionModifier levy = mock(FactionModifier.class); - when(levy.getAmount()).thenReturn(100.0); - when(faction.getModifier(FactionModifiers.LEVY)).thenReturn(levy); - - Military military = mock(Military.class); - Map regimentById = new HashMap<>(); - List regiments = new ArrayList<>(); - for (Map.Entry entry : slots.entrySet()) { - AtomicInteger currentSlots = new AtomicInteger(entry.getValue()); - Regiment regiment = mock(Regiment.class); - when(regiment.getId()).thenReturn(entry.getKey()); - when(regiment.isLevy()).thenReturn(false); - when(regiment.isOffensive()).thenReturn("professional".equals(entry.getKey())); - when(regiment.isEquipment()).thenReturn("artillery".equals(entry.getKey())); - when(regiment.getCurrentSlots()).thenAnswer(inv -> currentSlots.get()); - doAnswer(inv -> { - currentSlots.updateAndGet(value -> Math.max(0, value - 1)); - return null; - }).when(regiment).sizeDecrease(); - regimentById.put(entry.getKey().toLowerCase(), regiment); - regiments.add(regiment); - } - when(military.getRegiments()).thenReturn(regiments); - when(military.getRegiment(anyString())).thenAnswer(inv -> { - String regimentId = inv.getArgument(0, String.class); - return regimentById.get(regimentId.toLowerCase()); - }); - when(faction.getMilitary()).thenReturn(military); - factionsById.put(id.toLowerCase(), faction); - return faction; + private static List lines(BattleCasualtyTally tally) { + return tally.losses().stream() + .map(loss -> loss.faction().getId() + " " + loss.side() + " " + loss.regimentId() + " " + loss.count()) + .toList(); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleForcesTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleForcesTest.java new file mode 100644 index 00000000..6ec80f28 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleForcesTest.java @@ -0,0 +1,471 @@ +package net.tfminecraft.simplefactions.war.battle.military; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.army.Military; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.enums.FactionModifiers; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.RelationManager; +import net.tfminecraft.simplefactions.managers.TitleManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.mercenary.contract.MercenaryEngagements; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.FactionModifier; +import net.tfminecraft.simplefactions.war.battle.engine.core.Battle; +import net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager; +import net.tfminecraft.simplefactions.war.battle.engine.core.BattleSide; +import net.tfminecraft.simplefactions.war.campaign.progression.BelligerentRole; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.enums.CampaignPhase; +import net.tfminecraft.simplefactions.war.wounds.BattleForce; +import net.tfminecraft.simplefactions.war.wounds.ForceKind; +import net.tfminecraft.simplefactions.war.wounds.ForcePart; +import net.tfminecraft.simplefactions.war.wounds.TestForces; +import net.tfminecraft.simplefactions.war.wounds.WarLedger; + +class BattleForcesTest { + private static final int PROVINCE_ID = 42; + private List savedWars; + private int savedLives; + private int savedDeaths; + + @BeforeEach + void setUp() { + savedWars = new ArrayList<>(WarManager.get()); + WarManager.get().clear(); + savedLives = Cache.warBattleLivesPerRegiment; + savedDeaths = Cache.warBattleDeathsPerRegimentLoss; + Cache.warBattleLivesPerRegiment = 5; + Cache.warBattleDeathsPerRegimentLoss = 5; + } + + @AfterEach + void tearDown() { + WarManager.get().clear(); + WarManager.get().addAll(savedWars); + Cache.warBattleLivesPerRegiment = savedLives; + Cache.warBattleDeathsPerRegimentLoss = savedDeaths; + } + + @Test + void invasionAttackerBringsOffensiveRegimentsAndDefenderEverythingWithMilitiaAtHome() { + Faction attacker = faction("atk", regiment("professional", 10, 0, true), regiment("militia", 4, 4, false)); + Faction defender = faction("def", regiment("professional", 8, 2, true), regiment("garrison", 2, 0, false), + regiment("militia", 6, 6, false)); + War war = war(1, attacker, defender); + + try (MockedStatic titles = mockStatic(TitleManager.class)) { + titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(defender); + + assertEquals(PoolMode.OFFENSIVE, BattleForces.resolvePoolMode(war, war.getAttackers())); + assertEquals(PoolMode.DEFENSIVE, BattleForces.resolvePoolMode(war, war.getDefenders())); + List attack = BattleForces.build(war, PROVINCE_ID, war.getAttackers(), null, null); + assertEquals(List.of(new ForcePart("professional", false, 0, 10)), attack.get(0).parts()); + assertEquals(50, BattleForce.lives(attack)); + + List defence = BattleForces.build(war, PROVINCE_ID, war.getDefenders(), null, null); + assertEquals(ForceKind.REGIMENTS, defence.get(0).kind()); + assertEquals("def", defence.get(0).ownerId()); + assertEquals(List.of(new ForcePart("professional", false, 2, 6), new ForcePart("garrison", false, 0, 2), + new ForcePart("militia", true, 6, 0)), defence.get(0).parts()); + assertEquals(16, BattleForces.regimentSlots(war, PROVINCE_ID, war.getDefenders(), PoolMode.DEFENSIVE)); + } + } + + @Test + void counterPushSwapsWhoAttacks() { + Faction attacker = faction("atk", regiment("professional", 10, 0, true), regiment("militia", 4, 0, false)); + Faction defender = faction("def", regiment("professional", 8, 0, true), regiment("militia", 6, 0, false)); + War war = war(2, attacker, defender); + war.setCampaignPhase(CampaignPhase.COUNTER_PUSH); + war.setInitiativeHolder(BelligerentRole.DEFENDER); + + try (MockedStatic titles = mockStatic(TitleManager.class)) { + titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(attacker); + assertEquals(PoolMode.DEFENSIVE, BattleForces.resolvePoolMode(war, war.getAttackers())); + assertEquals(14, BattleForce.regimentSlots(BattleForces.build(war, PROVINCE_ID, war.getAttackers(), null, null))); + assertEquals(8, BattleForce.regimentSlots(BattleForces.build(war, PROVINCE_ID, war.getDefenders(), null, null))); + } + } + + @Test + void militiaOnlyDefendsItsOwnersProvinceUnlessItIsACivilWar() { + Faction owner = faction("owner", regiment("militia", 5, 5, false)); + Faction ally = faction("ally", regiment("militia", 7, 7, false)); + War war = war(3, owner, faction("def", regiment("professional", 1, 0, true))); + war.getAttackers().getMainParticipants().get(0).getAllies().put(ally, true); + + try (MockedStatic titles = mockStatic(TitleManager.class)) { + titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(owner); + assertEquals(5, BattleForces.regimentSlots(war, PROVINCE_ID, war.getAttackers(), PoolMode.DEFENSIVE)); + assertEquals(0, BattleForces.regimentSlots(war, PROVINCE_ID, war.getAttackers(), PoolMode.OFFENSIVE)); + assertEquals(0, BattleForces.regimentSlots(war, null, war.getAttackers(), PoolMode.DEFENSIVE)); + + war.setMovementId("mov-cw"); + assertEquals(12, BattleForces.regimentSlots(war, PROVINCE_ID, war.getAttackers(), PoolMode.OFFENSIVE)); + } + } + + @Test + void equipmentMercenaryAndLevyRowsAreNotSoldiers() { + Regiment artillery = regiment("artillery", 3, 0, true); + when(artillery.isEquipment()).thenReturn(true); + Regiment company = regiment("mercenary", 2, 0, true); + when(company.isMercenary()).thenReturn(true); + Regiment levy = regiment("levy", 9, 0, true); + when(levy.isLevy()).thenReturn(true); + Faction attacker = faction("atk", regiment("professional", 4, 0, true), artillery, company, levy); + War war = war(4, attacker, faction("def")); + + assertEquals(4, BattleForces.regimentSlots(war, PROVINCE_ID, war.getAttackers(), PoolMode.OFFENSIVE)); + assertTrue(BattleForces.build(war, PROVINCE_ID, war.getDefenders(), PoolMode.DEFENSIVE, null, null).isEmpty()); + } + + @Test + void woundedPaidSlotsStayHomeButLawSlotsAlwaysFight() { + Faction attacker = faction("atk", regiment("professional", 10, 4, true)); + War war = war(5, attacker, faction("def")); + War other = war(6, attacker, faction("def2")); + other.getLedger().addAdjustment(new WarLedger.Adjustment("ATK", "professional", 17, "staff", null, "now")); + WarManager.get().add(other); + + List forces = BattleForces.build(war, PROVINCE_ID, war.getAttackers(), null, null); + // 17 wounded lives are 3 wounded slots out of the 6 paid ones; the 4 law slots are whole. + assertEquals(List.of(new ForcePart("professional", false, 4, 3)), forces.get(0).parts()); + + other.getLedger().markSettled(); + assertEquals(10, BattleForce.regimentSlots(BattleForces.build(war, PROVINCE_ID, war.getAttackers(), null, null))); + } + + @Test + void subjectsLendTheirLevyShareOfEveryRegimentIncludingMilitia() { + Faction holder = faction("holder", regiment("professional", 2, 0, true)); + Faction subject = faction("subject", regiment("militia", 6, 4, false), regiment("garrison", 4, 0, false)); + levyPercent(subject, 50); + War war = war(7, holder, faction("def")); + + try (MockedStatic relations = mockStatic(RelationManager.class); + MockedStatic titles = mockStatic(TitleManager.class); + MockedStatic factions = mockStatic(FactionManager.class)) { + factions.when(() -> FactionManager.getByString("holder")).thenReturn(holder); + subjects(relations, holder, subject); + relations.when(() -> RelationManager.getOverlord(subject)).thenReturn("holder"); + titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(null); + + List forces = BattleForces.build(war, PROVINCE_ID, war.getAttackers(), null, null); + + assertEquals(2, forces.size()); + BattleForce levy = forces.get(1); + assertEquals(ForceKind.LEVY, levy.kind()); + assertEquals("subject", levy.ownerId()); + // Half of 6 militia (4 law, 2 paid) and half of 4 paid garrison, in any battle type. + assertEquals(List.of(new ForcePart("militia", true, 2, 1), new ForcePart("garrison", false, 0, 2)), + levy.parts()); + } + } + + @Test + void aSubjectDefendingItsOwnProvinceBringsAllItsMilitiaAndLendsTheRest() { + Faction holder = faction("holder", regiment("professional", 2, 0, true)); + Faction subject = faction("subject", regiment("militia", 6, 4, false), regiment("garrison", 4, 0, false)); + levyPercent(subject, 50); + War war = war(8, faction("atk"), holder); + + try (MockedStatic relations = mockStatic(RelationManager.class); + MockedStatic titles = mockStatic(TitleManager.class); + MockedStatic factions = mockStatic(FactionManager.class)) { + factions.when(() -> FactionManager.getByString("holder")).thenReturn(holder); + subjects(relations, holder, subject); + relations.when(() -> RelationManager.getOverlord(subject)).thenReturn("holder"); + titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(subject); + + List defence = BattleForces.build(war, PROVINCE_ID, war.getDefenders(), null, null); + assertEquals(ForceKind.MILITIA, defence.get(1).kind()); + assertEquals(List.of(new ForcePart("militia", true, 4, 2)), defence.get(1).parts()); + assertEquals(List.of(new ForcePart("garrison", false, 0, 2)), defence.get(2).parts()); + + // Attacking into its own province (a counter-attack) the militia stays a levy share. + List attack = BattleForces.build(war, PROVINCE_ID, war.getDefenders(), PoolMode.OFFENSIVE, null, null); + assertEquals(ForceKind.LEVY, attack.get(1).kind()); + assertEquals(2, attack.size()); + } + } + + @Test + void aSubjectWithoutMilitiaDefendingHomeStillLends() { + Faction holder = faction("holder", regiment("professional", 2, 0, true)); + Faction subject = faction("subject", regiment("garrison", 4, 0, false)); + War war = war(9, faction("atk"), holder); + + try (MockedStatic relations = mockStatic(RelationManager.class); + MockedStatic titles = mockStatic(TitleManager.class); + MockedStatic factions = mockStatic(FactionManager.class)) { + factions.when(() -> FactionManager.getByString("holder")).thenReturn(holder); + subjects(relations, holder, subject); + relations.when(() -> RelationManager.getOverlord(subject)).thenReturn("holder"); + titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(subject); + + List defence = BattleForces.build(war, PROVINCE_ID, war.getDefenders(), null, null); + assertEquals(List.of(ForceKind.REGIMENTS, ForceKind.LEVY), defence.stream().map(BattleForce::kind).toList()); + } + } + + @Test + void nestedLeviesGoToTheNearestFighterAndEachOverlordTakesItsShare() { + Faction main = faction("m", regiment("professional", 1, 0, true)); + Faction middle = faction("v", regiment("professional", 1, 0, true)); + Faction deep = faction("v3", regiment("garrison", 8, 0, false), regiment("militia", 3, 3, false)); + levyPercent(middle, 50); + Faction orphan = faction("orphan", regiment("garrison", 8, 0, false)); + War war = war(10, main, faction("def")); + + try (MockedStatic relations = mockStatic(RelationManager.class); + MockedStatic factions = mockStatic(FactionManager.class)) { + subjects(relations, main, middle); + subjects(relations, middle, deep, orphan); + relations.when(() -> RelationManager.getOverlord(deep)).thenReturn("v"); + relations.when(() -> RelationManager.getOverlord(middle)).thenReturn("m"); + relations.when(() -> RelationManager.getOverlord(orphan)).thenReturn(""); + factions.when(() -> FactionManager.getByString("v")).thenReturn(middle); + factions.when(() -> FactionManager.getByString("m")).thenReturn(main); + + // The middle overlord does not fight, so the deep levy passes through it at 50%. + List forces = BattleForces.build(war, PROVINCE_ID, war.getAttackers(), null, null); + BattleForce deepLevy = forces.stream().filter(force -> force.ownerId().equals("v3")).findFirst().orElseThrow(); + assertEquals(List.of(new ForcePart("garrison", false, 0, 4), new ForcePart("militia", true, 2, 0)), + deepLevy.parts()); + assertFalse(forces.stream().anyMatch(force -> force.ownerId().equals("orphan"))); + + // When the middle overlord fights, the deep levy goes to it whole. + war.getAttackers().getMainParticipants().get(0).getSubjects().add(middle); + BattleForce direct = BattleForces.build(war, PROVINCE_ID, war.getAttackers(), null, null).stream() + .filter(force -> force.ownerId().equals("v3")).findFirst().orElseThrow(); + assertEquals(11, direct.slots()); + } + } + + @Test + void levyStopsAtTheSubjectsActiveMembers() { + Faction holder = faction("holder"); + Faction subject = faction("subject", regiment("garrison", 8, 0, false), regiment("professional", 8, 0, true), + regiment("militia", 8, 0, false)); + when(subject.getMembers()).thenReturn(List.of("a", "b", "c", "d", "e", "f", "g", "h", "i", "j")); + War war = war(11, holder, faction("def")); + + try (MockedStatic relations = mockStatic(RelationManager.class); + MockedStatic factions = mockStatic(FactionManager.class)) { + factions.when(() -> FactionManager.getByString("holder")).thenReturn(holder); + subjects(relations, holder, subject); + relations.when(() -> RelationManager.getOverlord(subject)).thenReturn("holder"); + + BattleForce levy = BattleForces.build(war, PROVINCE_ID, war.getAttackers(), null, null).get(0); + assertEquals(List.of(new ForcePart("garrison", false, 0, 8), new ForcePart("professional", false, 0, 2)), + levy.parts()); + + when(subject.getMembers()).thenReturn(null); + assertTrue(BattleForces.build(war, PROVINCE_ID, war.getAttackers(), null, null).isEmpty()); + } + } + + @Test + void aLevyRoundedAwayByAnOverlordIsNotLent() { + Faction main = faction("m"); + Faction middle = faction("v"); + levyPercent(middle, 10); + Faction deep = faction("v3", regiment("garrison", 2, 0, false)); + War war = war(12, main, faction("def")); + + try (MockedStatic relations = mockStatic(RelationManager.class); + MockedStatic factions = mockStatic(FactionManager.class)) { + subjects(relations, main, middle); + subjects(relations, middle, deep); + relations.when(() -> RelationManager.getOverlord(deep)).thenReturn("v"); + relations.when(() -> RelationManager.getOverlord(middle)).thenReturn("m"); + factions.when(() -> FactionManager.getByString("v")).thenReturn(middle); + factions.when(() -> FactionManager.getByString("m")).thenReturn(main); + + // 10% of the deep subject's 2 slots rounds to nothing, and the middle one has no army. + assertTrue(BattleForces.build(war, PROVINCE_ID, war.getAttackers(), null, null).isEmpty()); + } + } + + @Test + void holderLookupFollowsTheOverlordChain() { + Faction child = faction("child"); + Faction parent = faction("parent"); + try (MockedStatic relations = mockStatic(RelationManager.class); + MockedStatic factions = mockStatic(FactionManager.class)) { + relations.when(() -> RelationManager.getOverlord(child)).thenReturn("parent"); + relations.when(() -> RelationManager.getOverlord(parent)).thenReturn(null); + factions.when(() -> FactionManager.getByString("parent")).thenReturn(parent); + + assertEquals(parent, BattleForces.findNearestFighterHolder(child, Set.of("parent"))); + assertNull(BattleForces.findNearestFighterHolder(child, Set.of("other"))); + factions.when(() -> FactionManager.getByString("parent")).thenReturn(null); + assertNull(BattleForces.findNearestFighterHolder(child, Set.of("other"))); + } + } + + @Test + void mercenariesBringTheirMembersOnTheRosterUpToTheirHealthySlots() { + War war = war(13, faction("atk", regiment("professional", 1, 0, true)), faction("def")); + MercenaryCompany company = company("guild-a", 3); + MercenaryEngagements.Engagement first = new MercenaryEngagements.Engagement(company, + mock(net.tfminecraft.simplefactions.mercenary.contract.MercenaryContract.class)); + MercenaryEngagements.Engagement second = new MercenaryEngagements.Engagement(company, + mock(net.tfminecraft.simplefactions.mercenary.contract.MercenaryContract.class)); + BattleSide roster = mock(BattleSide.class); + Battle battle = mock(Battle.class); + Battle running = mock(Battle.class); + BattleSide runningSide = mock(BattleSide.class); + when(running.hasStarted()).thenReturn(true); + when(running.getSides()).thenReturn(List.of(runningSide)); + when(runningSide.getForces()).thenReturn(List.of(TestForces.mercenary("GUILD-A", 1), + TestForces.mercenary("guild-b", 4), TestForces.regiments(2).get(0))); + Battle idle = mock(Battle.class); + BattleManager.get().addAll(List.of(running, idle, battle)); + try (MockedStatic engagements = mockStatic(MercenaryEngagements.class)) { + engagements.when(() -> MercenaryEngagements.on(war, war.getAttackers())).thenReturn(List.of(first, second)); + engagements.when(() -> MercenaryEngagements.coveringMembers(first, roster)).thenReturn(5); + engagements.when(() -> MercenaryEngagements.coveringMembers(second, roster)).thenReturn(1); + engagements.when(() -> MercenaryEngagements.membersOnRoster(company, roster)).thenReturn(5); + + List forces = BattleForces.build(war, PROVINCE_ID, war.getAttackers(), battle, roster); + + // 3 healthy slots, 1 already fighting elsewhere: the first contract takes the other 2. + assertEquals(2, forces.size()); + assertEquals(new BattleForce(ForceKind.MERCENARY, "guild-a", 5, + List.of(new ForcePart("mercenary", false, 0, 2))), forces.get(1)); + assertEquals(1, BattleForce.regimentSlots(forces)); + } finally { + BattleManager.get().removeAll(List.of(running, idle, battle)); + } + } + + @Test + void aCompanyHiredByTwoAlliesOnOneSideCountsItsRosterOnce() { + War war = war(16, faction("atk"), faction("def")); + MercenaryCompany company = company("guild-c", 10); + MercenaryEngagements.Engagement first = new MercenaryEngagements.Engagement(company, + mock(net.tfminecraft.simplefactions.mercenary.contract.MercenaryContract.class)); + MercenaryEngagements.Engagement second = new MercenaryEngagements.Engagement(company, + mock(net.tfminecraft.simplefactions.mercenary.contract.MercenaryContract.class)); + BattleSide roster = mock(BattleSide.class); + try (MockedStatic engagements = mockStatic(MercenaryEngagements.class)) { + engagements.when(() -> MercenaryEngagements.on(war, war.getAttackers())).thenReturn(List.of(first, second)); + engagements.when(() -> MercenaryEngagements.coveringMembers(any(), any())).thenReturn(3); + engagements.when(() -> MercenaryEngagements.membersOnRoster(company, roster)).thenReturn(3); + + List forces = BattleForces.build(war, PROVINCE_ID, war.getAttackers(), null, roster); + + // Three members on the roster are three soldiers, not three per contract. + assertEquals(3, forces.stream().mapToInt(BattleForce::slots).sum()); + } + } + + @Test + void nothingWithoutAWarOrSide() { + assertTrue(BattleForces.build(null, PROVINCE_ID, null, PoolMode.OFFENSIVE, null, null).isEmpty()); + War war = war(14, faction("atk"), faction("def")); + assertTrue(BattleForces.build(war, PROVINCE_ID, war.getAttackers(), null, null, null).isEmpty()); + assertTrue(BattleForces.build(war, PROVINCE_ID, null, PoolMode.OFFENSIVE, null, null).isEmpty()); + assertEquals(PoolMode.DEFENSIVE, BattleForces.resolvePoolMode(null, war.getAttackers())); + assertEquals(PoolMode.DEFENSIVE, BattleForces.resolvePoolMode(war, null)); + assertFalse(BattleForces.isMilitiaEligible(null, PROVINCE_ID)); + Faction noId = mock(Faction.class); + assertFalse(BattleForces.isMilitiaEligible(noId, PROVINCE_ID)); + Faction factionWithoutMilitary = mock(Faction.class); + when(factionWithoutMilitary.getId()).thenReturn("bare"); + War bare = war(15, factionWithoutMilitary, faction("def")); + assertTrue(BattleForces.build(bare, PROVINCE_ID, bare.getAttackers(), null, null).isEmpty()); + try (MockedStatic titles = mockStatic(TitleManager.class)) { + titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(null); + assertFalse(BattleForces.isMilitiaEligible(factionWithoutMilitary, PROVINCE_ID)); + } + } + + @Test + void aSideWithNoOffensiveCoalitionDefends() { + War war = mock(War.class); + when(war.getInitiativeHolder()).thenReturn(null); + assertEquals(PoolMode.DEFENSIVE, BattleForces.resolvePoolMode(war, mock(net.tfminecraft.simplefactions.war.core.Side.class))); + } + + private static MercenaryCompany company(String guildId, int healthySlots) { + MercenaryCompany company = mock(MercenaryCompany.class); + Guild guild = mock(Guild.class); + when(guild.getId()).thenReturn(guildId); + when(company.getGuild()).thenReturn(guild); + Regiment regiment = mock(Regiment.class); + when(regiment.getId()).thenReturn("mercenary"); + when(company.getRegiment()).thenReturn(regiment); + when(company.getHealthySlots(any())).thenReturn(healthySlots); + return company; + } + + private static void subjects(MockedStatic relations, Faction overlord, Faction... subjects) { + relations.when(() -> RelationManager.getSubjects(overlord)).thenReturn(Arrays.asList(subjects)); + } + + private static void levyPercent(Faction faction, double percent) { + FactionModifier levy = mock(FactionModifier.class); + when(levy.getAmount()).thenReturn(percent); + when(faction.getModifier(FactionModifiers.LEVY)).thenReturn(levy); + } + + private static War war(int id, Faction attacker, Faction defender) { + when(attacker.getName()).thenReturn("Attacker"); + when(defender.getName()).thenReturn("Defender"); + War war = new War(id, attacker, defender); + war.setCampaignProvinces(List.of(PROVINCE_ID, 43, 44)); + war.setCursorIndex(0); + war.setCampaignPhase(CampaignPhase.INVASION); + return war; + } + + static Faction faction(String id, Regiment... regiments) { + Faction faction = mock(Faction.class); + when(faction.getId()).thenReturn(id); + when(faction.getMembers()).thenReturn(List.of("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", + "m", "n", "o", "p", "q", "r", "s", "t")); + levyPercent(faction, 100); + Military military = mock(Military.class); + when(military.getRegiments()).thenReturn(List.of(regiments)); + for (Regiment regiment : regiments) { + when(military.getRegiment(regiment.getId())).thenReturn(regiment); + } + when(faction.getMilitary()).thenReturn(military); + return faction; + } + + static Regiment regiment(String id, int current, int free, boolean offensive) { + Regiment regiment = mock(Regiment.class); + when(regiment.getId()).thenReturn(id); + when(regiment.isOffensive()).thenReturn(offensive); + when(regiment.getCurrentSlots()).thenReturn(current); + when(regiment.getFreeSlots()).thenReturn(free); + when(regiment.getPaidSlots()).thenReturn(Math.max(0, current - free)); + return regiment; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleLivesServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleLivesServiceTest.java index 614ba6ac..eb4de0b6 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleLivesServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleLivesServiceTest.java @@ -42,6 +42,7 @@ import net.tfminecraft.simplefactions.war.battle.military.BattleLivesService.SideLivesPreview; import net.tfminecraft.simplefactions.war.battle.template.BattleTemplate; import net.tfminecraft.simplefactions.war.campaign.progression.CampaignSideService.CampaignSideRole; +import net.tfminecraft.simplefactions.war.wounds.TestForces; import java.lang.reflect.Constructor; import java.util.function.Consumer; @@ -58,10 +59,10 @@ void setUp() { @Test void computeSideLives_formula() { - assertEquals(48, BattleLivesService.computeSideLives(10, 2)); - assertEquals(11, BattleLivesService.computeSideLives(3, 4)); + assertEquals(48, BattleLivesService.computeSideLives(50, 2)); + assertEquals(11, BattleLivesService.computeSideLives(15, 4)); assertEquals(0, BattleLivesService.computeSideLives(0, 10)); - assertEquals(1, BattleLivesService.computeSideLives(1, 10)); + assertEquals(1, BattleLivesService.computeSideLives(5, 10)); } @Test @@ -70,12 +71,12 @@ void previewCampaignSideLives_asymmetric() { withMockBossBar(bukkit -> { Battle battle = campaignFieldBattle(1, PROVINCE_ID); - try (MockedStatic pool = mockStatic(BattlePoolService.class); + try (MockedStatic pool = mockStatic(BattleForces.class); MockedStatic lives = mockStatic(BattleLivesService.class, CALLS_REAL_METHODS)) { - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(10); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getDefenders()))) - .thenReturn(3); + pool.when(() -> BattleForces.build(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()), any(), any())) + .thenReturn(TestForces.regiments(10)); + pool.when(() -> BattleForces.build(eq(war), eq(PROVINCE_ID), eq(war.getDefenders()), any(), any())) + .thenReturn(TestForces.regiments(3)); lives.when(() -> BattleLivesService.countRosterFighters(battle.getSideById(BattleTemplate.ATTACKER_SIDE))) .thenReturn(12); lives.when(() -> BattleLivesService.countRosterFighters(battle.getSideById(BattleTemplate.DEFENDER_SIDE))) @@ -86,7 +87,7 @@ void previewCampaignSideLives_asymmetric() { SideLivesPreview defender = BattleLivesService.previewCampaignSideLives( war, battle, BattleTemplate.DEFENDER_SIDE); - assertEquals(10, attacker.committedRegiments()); + assertEquals(10, attacker.slots()); assertEquals(50, attacker.poolLives()); assertEquals(12, attacker.rosterFighters()); assertEquals(38, attacker.sideLives()); @@ -102,13 +103,13 @@ void applyCampaignLives_asymmetricSides() { Battle battle = campaignFieldBattle(1, PROVINCE_ID); try (MockedStatic wars = mockStatic(WarManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class); + MockedStatic pool = mockStatic(BattleForces.class); MockedStatic lives = mockStatic(BattleLivesService.class, CALLS_REAL_METHODS)) { wars.when(() -> WarManager.getById(1)).thenReturn(war); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(10); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getDefenders()))) - .thenReturn(3); + pool.when(() -> BattleForces.build(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()), any(), any())) + .thenReturn(TestForces.regiments(10)); + pool.when(() -> BattleForces.build(eq(war), eq(PROVINCE_ID), eq(war.getDefenders()), any(), any())) + .thenReturn(TestForces.regiments(3)); lives.when(() -> BattleLivesService.countRosterFighters(battle.getSideById(BattleTemplate.ATTACKER_SIDE))) .thenReturn(2); lives.when(() -> BattleLivesService.countRosterFighters(battle.getSideById(BattleTemplate.DEFENDER_SIDE))) @@ -131,13 +132,13 @@ void applyCampaignLives_counterPushSwapsWarSides() { battle.setOffensiveCoalition(CampaignSideRole.DEFENDER); try (MockedStatic wars = mockStatic(WarManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class); + MockedStatic pool = mockStatic(BattleForces.class); MockedStatic lives = mockStatic(BattleLivesService.class, CALLS_REAL_METHODS)) { wars.when(() -> WarManager.getById(1)).thenReturn(war); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()))) - .thenReturn(10); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), eq(war.getDefenders()))) - .thenReturn(3); + pool.when(() -> BattleForces.build(eq(war), eq(PROVINCE_ID), eq(war.getAttackers()), any(), any())) + .thenReturn(TestForces.regiments(10)); + pool.when(() -> BattleForces.build(eq(war), eq(PROVINCE_ID), eq(war.getDefenders()), any(), any())) + .thenReturn(TestForces.regiments(3)); lives.when(() -> BattleLivesService.countRosterFighters(battle.getSideById(BattleTemplate.ATTACKER_SIDE))) .thenReturn(2); lives.when(() -> BattleLivesService.countRosterFighters(battle.getSideById(BattleTemplate.DEFENDER_SIDE))) @@ -158,11 +159,11 @@ void applyCampaignLives_respectsMinFloor() { Battle battle = campaignFieldBattle(2, PROVINCE_ID); try (MockedStatic wars = mockStatic(WarManager.class); - MockedStatic pool = mockStatic(BattlePoolService.class); + MockedStatic pool = mockStatic(BattleForces.class); MockedStatic lives = mockStatic(BattleLivesService.class, CALLS_REAL_METHODS)) { wars.when(() -> WarManager.getById(2)).thenReturn(war); - pool.when(() -> BattlePoolService.totalCommittedRegiments(eq(war), eq(PROVINCE_ID), any(Side.class))) - .thenReturn(1); + pool.when(() -> BattleForces.build(eq(war), eq(PROVINCE_ID), any(Side.class), any(), any())) + .thenReturn(TestForces.regiments(1)); lives.when(() -> BattleLivesService.countRosterFighters(any())).thenReturn(10); BattleLivesService.applyCampaignLives(battle); @@ -173,6 +174,94 @@ void applyCampaignLives_respectsMinFloor() { }); } + @Test + void applyCampaignLives_recordsForcesAndUsesStaffStartingLives() { + War war = campaignWar(4); + withMockBossBar(bukkit -> { + Battle battle = campaignFieldBattle(4, PROVINCE_ID); + battle.getSideById(BattleTemplate.DEFENDER_SIDE).setLivesOverride(7); + + try (MockedStatic wars = mockStatic(WarManager.class); + MockedStatic pool = mockStatic(BattleForces.class)) { + wars.when(() -> WarManager.getById(4)).thenReturn(war); + pool.when(() -> BattleForces.build(eq(war), eq(PROVINCE_ID), any(Side.class), any(), any())) + .thenReturn(TestForces.regiments(4)); + + BattleLivesService.applyCampaignLives(battle); + + BattleSide attacker = battle.getSideById(BattleTemplate.ATTACKER_SIDE); + assertEquals(TestForces.regiments(4), attacker.getForces()); + assertEquals(20, attacker.getLives()); + assertEquals(7, battle.getSideById(BattleTemplate.DEFENDER_SIDE).getLives()); + assertEquals(7, BattleLivesService.previewCampaignSideLives(war, battle, BattleTemplate.DEFENDER_SIDE) + .sideLives()); + } + }); + } + + @Test + void applyCampaignLives_needsAnActiveWarAndUsesTheScheduledProvince() { + War war = campaignWar(5); + war.setScheduledBattleProvinceId(PROVINCE_ID); + withMockBossBar(bukkit -> { + Battle battle = BattleFactory.createBlank(BattleType.SIEGE, "campaign_w5"); + battle.setWarId(5); + battle.removeSide(BattleTemplate.DEFENDER_SIDE); + try (MockedStatic wars = mockStatic(WarManager.class); + MockedStatic pool = mockStatic(BattleForces.class)) { + pool.when(() -> BattleForces.build(eq(war), eq(PROVINCE_ID), any(Side.class), any(), any())) + .thenReturn(TestForces.regiments(2)); + + BattleLivesService.applyCampaignLives(battle); + assertEquals(false, battle.getSideById(BattleTemplate.ATTACKER_SIDE).hasForces()); + + wars.when(() -> WarManager.getById(5)).thenReturn(war); + war.end(net.tfminecraft.simplefactions.war.enums.WarEndReason.ADMIN_END); + BattleLivesService.applyCampaignLives(battle); + assertEquals(false, battle.getSideById(BattleTemplate.ATTACKER_SIDE).hasForces()); + } + }); + War active = campaignWar(6); + active.setScheduledBattleProvinceId(PROVINCE_ID); + withMockBossBar(bukkit -> { + Battle battle = BattleFactory.createBlank(BattleType.SIEGE, "campaign_w6"); + battle.setWarId(6); + battle.removeSide(BattleTemplate.DEFENDER_SIDE); + try (MockedStatic wars = mockStatic(WarManager.class); + MockedStatic pool = mockStatic(BattleForces.class)) { + wars.when(() -> WarManager.getById(6)).thenReturn(active); + pool.when(() -> BattleForces.build(eq(active), eq(PROVINCE_ID), any(Side.class), any(), any())) + .thenReturn(TestForces.regiments(2)); + + BattleLivesService.applyCampaignLives(battle); + assertEquals(10, battle.getSideById(BattleTemplate.ATTACKER_SIDE).getLives()); + } + }); + } + + @Test + void previewCountsMercenarySlotsAndRefusesUnknownSides() { + War war = campaignWar(7); + withMockBossBar(bukkit -> { + Battle battle = campaignFieldBattle(7, PROVINCE_ID); + try (MockedStatic pool = mockStatic(BattleForces.class)) { + pool.when(() -> BattleForces.build(eq(war), eq(PROVINCE_ID), any(Side.class), any(), any())) + .thenReturn(List.of(TestForces.regiments(2).get(0), TestForces.mercenary("guild", 3))); + + SideLivesPreview preview = BattleLivesService.previewCampaignSideLives(war, battle, BattleTemplate.ATTACKER_SIDE); + assertEquals(new SideLivesPreview(5, 25, 0, 25, 3), preview); + SideLivesPreview none = new SideLivesPreview(0, 0, 0, 0, 0); + assertEquals(none, BattleLivesService.previewCampaignSideLives(null, battle, BattleTemplate.ATTACKER_SIDE)); + assertEquals(none, BattleLivesService.previewCampaignSideLives(war, null, BattleTemplate.ATTACKER_SIDE)); + assertEquals(none, BattleLivesService.previewCampaignSideLives(war, battle, null)); + assertEquals(none, BattleLivesService.previewCampaignSideLives(war, battle, "nobody")); + battle.removeSide(BattleTemplate.DEFENDER_SIDE); + assertEquals(none, BattleLivesService.previewCampaignSideLives(war, battle, BattleTemplate.DEFENDER_SIDE)); + assertEquals(0, BattleLivesService.countRosterFighters(null)); + } + }); + } + @Test void countRosterFighters_countsUniqueWarbandMembers() { withMockBossBar(bukkit -> { @@ -201,7 +290,7 @@ void setSideLives_rejectsCampaignBattle() { IllegalStateException.class, () -> BattleSideSetupService.setSideLives(battle, side, 25)); - assertEquals("Campaign battle lives are computed from war commitment", error.getMessage()); + assertEquals("Campaign battle lives come from the forces of each side", error.getMessage()); }); } diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleMilitaryBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleMilitaryBoundaryCoverageTest.java index 08521b77..1e62b98a 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleMilitaryBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattleMilitaryBoundaryCoverageTest.java @@ -3,7 +3,6 @@ import static org.junit.jupiter.api.Assertions.*; import com.google.gson.Gson; -import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -25,10 +24,10 @@ import net.tfminecraft.simplefactions.war.campaign.zoc.FortControlService; import net.tfminecraft.simplefactions.war.campaign.zoc.FortZocIndex; import net.tfminecraft.simplefactions.war.campaign.zoc.FortZocIndex.OperationalFort; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.core.WarCommitment; import net.tfminecraft.simplefactions.war.core.WarMapper; +import net.tfminecraft.simplefactions.war.wounds.BattleForce; +import net.tfminecraft.simplefactions.war.wounds.ForcePart; import net.tfminecraft.simplefactions.war.enums.WarGoalType; import org.bukkit.configuration.file.YamlConfiguration; import org.junit.jupiter.api.Test; @@ -150,7 +149,7 @@ void incompletePublicRosterEntriesDoNotExcludeValidFactionMembers() { } @Test - void missingOrForeignPoolRequestsCannotSpendAnActualArmiesRegiments() { + void missingOrForeignForceRequestsCannotSpendAnActualArmiesRegiments() { try (FactionDomainFixture domain = new FactionDomainFixture()) { Faction attacker = domain.saved("pool_iron", "Alice"); Faction defender = domain.saved("pool_river", "Bob"); @@ -158,22 +157,18 @@ void missingOrForeignPoolRequestsCannotSpendAnActualArmiesRegiments() { Regiment guard = regiment(attacker, "guard", 7, true); War war = war(attacker, defender); War other = new War(998012, outsider, defender); - assertEquals( - PoolMode.DEFENSIVE, BattlePoolService.resolvePoolMode(null, 10, war.getAttackers())); - assertEquals(PoolMode.DEFENSIVE, BattlePoolService.resolvePoolMode(war, 10, null)); - assertEquals( - PoolMode.DEFENSIVE, BattlePoolService.resolvePoolMode(war, 10, other.getAttackers())); - assertFalse(BattlePoolService.isMilitiaEligible(null, 10)); + assertEquals(PoolMode.DEFENSIVE, BattleForces.resolvePoolMode(null, war.getAttackers())); + assertEquals(PoolMode.DEFENSIVE, BattleForces.resolvePoolMode(war, null)); + assertEquals(PoolMode.DEFENSIVE, BattleForces.resolvePoolMode(war, other.getAttackers())); + assertFalse(BattleForces.isMilitiaEligible(null, 10)); outsider.setId(null); - assertFalse(BattlePoolService.isMilitiaEligible(outsider, 10)); - assertTrue( - BattlePoolService.eligibleRegiments(null, 10, war.getAttackers(), PoolMode.OFFENSIVE) - .isEmpty()); - assertTrue(BattlePoolService.eligibleRegiments(war, 10, null, PoolMode.OFFENSIVE).isEmpty()); - assertTrue(BattlePoolService.eligibleRegiments(war, 10, war.getAttackers(), null).isEmpty()); - assertEquals( - Map.of(attacker.getId(), Map.of("guard", 7)), - BattlePoolService.eligibleRegiments(war, 10, war.getAttackers(), PoolMode.OFFENSIVE)); + assertFalse(BattleForces.isMilitiaEligible(outsider, 10)); + assertTrue(BattleForces.build(null, 10, war.getAttackers(), PoolMode.OFFENSIVE, null, null).isEmpty()); + assertTrue(BattleForces.build(war, 10, null, PoolMode.OFFENSIVE, null, null).isEmpty()); + assertTrue(BattleForces.build(war, 10, war.getAttackers(), null, null, null).isEmpty()); + List forces = BattleForces.build(war, 10, war.getAttackers(), PoolMode.OFFENSIVE, null, null); + assertEquals(attacker.getId(), forces.getFirst().ownerId()); + assertEquals(List.of(new ForcePart("guard", false, 7, 0)), forces.getFirst().parts()); assertEquals(7, guard.getCurrentSlots()); } } @@ -182,6 +177,7 @@ void missingOrForeignPoolRequestsCannotSpendAnActualArmiesRegiments() { @ValueSource(booleans = {false, true}) void largeConfiguredArmiesCannotWrapTheirAvailablePoolBelowOneContributingArmy(boolean levies) { try (FactionDomainFixture domain = new FactionDomainFixture()) { + domain.lawGroup("conscription", Map.of("effects.faction.modifiers", List.of("LEVY(100)"))); Faction attacker = domain.saved("pool_iron", "Alice"); Faction defender = domain.saved("pool_river", "Bob"); Faction firstSource = levies ? domain.saved("pool_first_subject", "Charlie") : attacker; @@ -193,41 +189,15 @@ void largeConfiguredArmiesCannotWrapTheirAvailablePoolBelowOneContributingArmy(b Regiment first = regiment(firstSource, "first", 1_500_000_000, !levies); Regiment second = regiment(secondSource, "second", 1_500_000_000, !levies); War war = war(attacker, defender); - List previous = WarCommitmentService.getCommitmentsForWar(war.getId()); - try { - WarCommitmentService.restoreCommitments( - war.getId(), - levies - ? List.of( - new WarCommitment( - war.getId(), - attacker.getId(), - firstSource.getId(), - WarCommitment.LEVY_REGIMENT_ID, - 1_500_000_000, - Instant.EPOCH), - new WarCommitment( - war.getId(), - attacker.getId(), - secondSource.getId(), - WarCommitment.LEVY_REGIMENT_ID, - 1_500_000_000, - Instant.EPOCH)) - : List.of()); - - int available = - BattlePoolService.totalCommittedRegiments( - war, 10, war.getAttackers(), PoolMode.OFFENSIVE); - - assertTrue( - available >= 1_500_000_000, - "Adding a second positive army must not overflow to a smaller or empty pool: " - + available); - assertEquals(1_500_000_000, first.getCurrentSlots()); - assertEquals(1_500_000_000, second.getCurrentSlots()); - } finally { - WarCommitmentService.restoreCommitments(war.getId(), previous); - } + // Subjects that do not fight themselves lend levies. + war.getAttackers().getMainParticipants().getFirst().getSubjects().clear(); + + int available = BattleForces.regimentSlots(war, 10, war.getAttackers(), PoolMode.OFFENSIVE); + + // Two huge armies saturate instead of wrapping; a subject lends no more than its one member. + assertEquals(levies ? 2 : Integer.MAX_VALUE, available); + assertEquals(1_500_000_000, first.getCurrentSlots()); + assertEquals(1_500_000_000, second.getCurrentSlots()); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattlePoolServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattlePoolServiceTest.java deleted file mode 100644 index 9b932d98..00000000 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/military/BattlePoolServiceTest.java +++ /dev/null @@ -1,360 +0,0 @@ -package net.tfminecraft.simplefactions.war.battle.military; - - -import net.tfminecraft.simplefactions.war.campaign.progression.BelligerentRole; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; - -import net.tfminecraft.simplefactions.army.Military; -import net.tfminecraft.simplefactions.army.Regiment; -import net.tfminecraft.simplefactions.managers.TitleManager; -import net.tfminecraft.simplefactions.managers.WarManager; -import net.tfminecraft.simplefactions.objects.Faction; -import net.tfminecraft.simplefactions.objects.FactionModifier; -import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.core.WarCommitment; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; -import net.tfminecraft.simplefactions.war.enums.CampaignPhase; -import net.tfminecraft.simplefactions.enums.FactionModifiers; - -class BattlePoolServiceTest { - private static final int PROVINCE_ID = 42; - private List savedWars; - - @BeforeEach - void setUp() { - savedWars = new ArrayList<>(WarManager.get()); - WarManager.get().clear(); - } - - @AfterEach - void tearDown() { - for (War war : new ArrayList<>(WarManager.get())) { - WarCommitmentService.clearCommitments(war.getId()); - } - WarManager.get().clear(); - WarManager.get().addAll(savedWars); - } - - @Test - void invasion_attackerOffensiveDefenderDefensive() { - Faction attacker = fighter("atk", Map.of("professional", 10, "militia", 4)); - Faction defender = fighter("def", Map.of("professional", 8, "militia", 6)); - War war = baseWar(1, attacker, defender); - war.setCampaignPhase(CampaignPhase.INVASION); - - try (MockedStatic titles = mockStatic(TitleManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(defender); - - assertEquals(PoolMode.OFFENSIVE, BattlePoolService.resolvePoolMode(war, PROVINCE_ID, war.getAttackers())); - assertEquals(PoolMode.DEFENSIVE, BattlePoolService.resolvePoolMode(war, PROVINCE_ID, war.getDefenders())); - - assertEquals(10, BattlePoolService.totalCommittedRegiments(war, PROVINCE_ID, war.getAttackers())); - assertEquals(14, BattlePoolService.totalCommittedRegiments(war, PROVINCE_ID, war.getDefenders())); - } - } - - @Test - void counterPush_poolsSwap() { - Faction attacker = fighter("atk", Map.of("professional", 10, "militia", 4)); - Faction defender = fighter("def", Map.of("professional", 8, "militia", 6)); - War war = baseWar(2, attacker, defender); - war.setCampaignPhase(CampaignPhase.COUNTER_PUSH); - war.setInitiativeHolder(net.tfminecraft.simplefactions.war.campaign.progression.BelligerentRole.DEFENDER); - - try (MockedStatic titles = mockStatic(TitleManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(attacker); - - assertEquals(PoolMode.DEFENSIVE, BattlePoolService.resolvePoolMode(war, PROVINCE_ID, war.getAttackers())); - assertEquals(PoolMode.OFFENSIVE, BattlePoolService.resolvePoolMode(war, PROVINCE_ID, war.getDefenders())); - - assertEquals(14, BattlePoolService.totalCommittedRegiments(war, PROVINCE_ID, war.getAttackers())); - assertEquals(8, BattlePoolService.totalCommittedRegiments(war, PROVINCE_ID, war.getDefenders())); - } - } - - @Test - void militia_ownLandOnly() { - Faction owner = fighter("owner", Map.of("militia", 5)); - Faction ally = fighter("ally", Map.of("militia", 7)); - War war = baseWar(3, owner, fighter("def", Map.of("professional", 1))); - war.getAttackers().getMainParticipants().get(0).getAllies().put(ally, true); - war.setCampaignPhase(CampaignPhase.INVASION); - - try (MockedStatic titles = mockStatic(TitleManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(owner); - - Map> eligible = BattlePoolService.eligibleRegiments( - war, - PROVINCE_ID, - war.getAttackers(), - PoolMode.DEFENSIVE); - - assertEquals(5, eligible.get("owner").get(BattlePoolService.MILITIA_REGIMENT_ID)); - assertFalse(eligible.containsKey("ally")); - } - } - - @Test - void militia_vassalLand_overlordExcluded() { - Faction overlord = fighter("o", Map.of("militia", 9, "professional", 2)); - Faction vassal = fighter("v", Map.of("militia", 4, "professional", 1)); - Faction defender = fighter("def", Map.of("professional", 1)); - War war = baseWar(4, overlord, defender); - war.getAttackers().getMainParticipants().get(0).getSubjects().add(vassal); - war.setCampaignPhase(CampaignPhase.INVASION); - - try (MockedStatic titles = mockStatic(TitleManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(vassal); - - Map> eligible = BattlePoolService.eligibleRegiments( - war, - PROVINCE_ID, - war.getAttackers(), - PoolMode.DEFENSIVE); - - assertEquals(4, eligible.get("v").get(BattlePoolService.MILITIA_REGIMENT_ID)); - assertFalse(eligible.getOrDefault("o", Map.of()).containsKey(BattlePoolService.MILITIA_REGIMENT_ID)); - } - } - - @Test - void levy_countedInBothPools() { - Faction attacker = fighter("atk", Map.of("professional", 5)); - Faction defender = fighter("def", Map.of("professional", 1)); - War war = baseWar(5, attacker, defender); - seedLevyRow(war, "atk", "subject", 12); - - assertEquals(17, BattlePoolService.totalCommittedRegiments(war, PROVINCE_ID, war.getAttackers(), PoolMode.OFFENSIVE)); - assertEquals(17, BattlePoolService.totalCommittedRegiments(war, PROVINCE_ID, war.getAttackers(), PoolMode.DEFENSIVE)); - } - - @Test - void defensivePool_includesEveryRegiment_militiaOnlyOnOwnedLand() { - Faction defender = fighter("def", Map.of( - "professional", 8, - "mercenary", 3, - "garrison", 2, - "militia", 6)); - Faction attacker = fighter("atk", Map.of("professional", 1)); - War war = baseWar(9, attacker, defender); - war.setCampaignPhase(CampaignPhase.INVASION); - seedLevyRow(war, "def", "subject", 4); - - try (MockedStatic titles = mockStatic(TitleManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(null); - - Map> neutral = BattlePoolService.eligibleRegiments( - war, - PROVINCE_ID, - war.getDefenders(), - PoolMode.DEFENSIVE); - assertEquals(8, neutral.get("def").get("professional")); - assertEquals(3, neutral.get("def").get("mercenary")); - assertEquals(2, neutral.get("def").get("garrison")); - assertEquals(4, neutral.get("def").get(WarCommitment.LEVY_REGIMENT_ID)); - assertFalse(neutral.get("def").containsKey(BattlePoolService.MILITIA_REGIMENT_ID)); - - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(defender); - Map> owned = BattlePoolService.eligibleRegiments( - war, - PROVINCE_ID, - war.getDefenders(), - PoolMode.DEFENSIVE); - assertEquals(6, owned.get("def").get(BattlePoolService.MILITIA_REGIMENT_ID)); - - Map> offensive = BattlePoolService.eligibleRegiments( - war, - PROVINCE_ID, - war.getDefenders(), - PoolMode.OFFENSIVE); - assertEquals(8, offensive.get("def").get("professional")); - assertEquals(3, offensive.get("def").get("mercenary")); - assertEquals(4, offensive.get("def").get(WarCommitment.LEVY_REGIMENT_ID)); - assertFalse(offensive.get("def").containsKey("garrison")); - assertFalse(offensive.get("def").containsKey(BattlePoolService.MILITIA_REGIMENT_ID)); - } - } - - @Test - void civilWar_allTypesBothPools_militiaNotOwnLandGated() { - Faction attacker = fighter("atk", Map.of("professional", 10, "militia", 4)); - Faction defender = fighter("def", Map.of("professional", 8, "militia", 6)); - War war = baseWar(8, attacker, defender); - war.setMovementId("mov-cw"); - war.setCampaignPhase(CampaignPhase.INVASION); - seedLevyRow(war, "atk", "levySource", 3); - - try (MockedStatic titles = mockStatic(TitleManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(defender); - - Map> offensive = BattlePoolService.eligibleRegiments( - war, - PROVINCE_ID, - war.getAttackers(), - PoolMode.OFFENSIVE); - assertEquals(10, offensive.get("atk").get("professional")); - assertEquals(4, offensive.get("atk").get(BattlePoolService.MILITIA_REGIMENT_ID)); - assertEquals(3, offensive.get("atk").get(WarCommitment.LEVY_REGIMENT_ID)); - - Map> defensive = BattlePoolService.eligibleRegiments( - war, - PROVINCE_ID, - war.getAttackers(), - PoolMode.DEFENSIVE); - assertEquals(10, defensive.get("atk").get("professional")); - assertEquals(4, defensive.get("atk").get(BattlePoolService.MILITIA_REGIMENT_ID)); - assertEquals(3, defensive.get("atk").get(WarCommitment.LEVY_REGIMENT_ID)); - } - } - - @Test - void nestedLevy_holderOnSide() { - Faction main = fighter("m", Map.of("professional", 1)); - Faction subject = fighter("v", Map.of("professional", 1)); - Faction defender = fighter("def", Map.of("professional", 1)); - War war = baseWar(6, main, defender); - war.getAttackers().getMainParticipants().get(0).getSubjects().add(subject); - seedLevyRow(war, "v", "v2", 4); - seedLevyRow(war, "v", "v3", 2); - - Map> eligible = BattlePoolService.eligibleRegiments( - war, - PROVINCE_ID, - war.getAttackers(), - PoolMode.OFFENSIVE); - - assertEquals(6, eligible.get("v").get(WarCommitment.LEVY_REGIMENT_ID)); - assertEquals(1, eligible.get("m").get("professional")); - } - - @Test - void totalCommittedRegiments_sumsEligible() { - Faction attacker = fighter("atk", Map.of("professional", 3, "militia", 2)); - Faction defender = fighter("def", Map.of("professional", 4, "militia", 5)); - War war = baseWar(7, attacker, defender); - war.setCampaignPhase(CampaignPhase.INVASION); - seedLevyRow(war, "atk", "levySource", 6); - - try (MockedStatic titles = mockStatic(TitleManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(defender); - - PoolMode attackerMode = BattlePoolService.resolvePoolMode(war, PROVINCE_ID, war.getAttackers()); - Map> eligible = - BattlePoolService.eligibleRegiments(war, PROVINCE_ID, war.getAttackers(), attackerMode); - int summed = eligible.values().stream().flatMap(map -> map.values().stream()).mapToInt(Integer::intValue).sum(); - - assertEquals(summed, BattlePoolService.totalCommittedRegiments(war, PROVINCE_ID, war.getAttackers())); - assertEquals(9, summed); - } - } - - @Test - void equipment_excludedFromOffensiveAndDefensivePools() { - Faction attacker = fighter("atk", Map.of("professional", 4, "artillery", 3, "militia", 2)); - Faction defender = fighter("def", Map.of("professional", 1, "artillery", 5)); - War war = baseWar(8, attacker, defender); - war.setCampaignPhase(CampaignPhase.INVASION); - - try (MockedStatic titles = mockStatic(TitleManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(defender); - - Map> offensive = BattlePoolService.eligibleRegiments( - war, PROVINCE_ID, war.getAttackers(), PoolMode.OFFENSIVE); - assertEquals(4, offensive.get("atk").get("professional")); - assertFalse(offensive.get("atk").containsKey("artillery")); - assertEquals(4, BattlePoolService.totalCommittedRegiments(war, PROVINCE_ID, war.getAttackers())); - - Map> defensive = BattlePoolService.eligibleRegiments( - war, PROVINCE_ID, war.getDefenders(), PoolMode.DEFENSIVE); - assertEquals(1, defensive.get("def").get("professional")); - assertFalse(defensive.get("def").containsKey("artillery")); - assertEquals(1, BattlePoolService.totalCommittedRegiments(war, PROVINCE_ID, war.getDefenders())); - } - } - - @Test - void isMilitiaEligible_matchesDirectOwner() { - Faction owner = fighter("owner", Map.of("militia", 1)); - Faction other = fighter("other", Map.of("militia", 1)); - - try (MockedStatic titles = mockStatic(TitleManager.class)) { - titles.when(() -> TitleManager.getByProvince(PROVINCE_ID)).thenReturn(owner); - assertTrue(BattlePoolService.isMilitiaEligible(owner, PROVINCE_ID)); - assertFalse(BattlePoolService.isMilitiaEligible(other, PROVINCE_ID)); - } - } - - private static void seedLevyRow(War war, String holderId, String sourceId, int count) { - appendLevyRowForTest(new WarCommitment( - war.getId(), - holderId, - sourceId, - WarCommitment.LEVY_REGIMENT_ID, - count, - Instant.now())); - } - - private static void appendLevyRowForTest(WarCommitment row) { - try { - java.lang.reflect.Field field = WarCommitmentService.class.getDeclaredField("commitmentsByWar"); - field.setAccessible(true); - @SuppressWarnings("unchecked") - Map> store = - (Map>) field.get(null); - store.computeIfAbsent(row.warId(), ignored -> new ArrayList<>()).add(row); - } catch (ReflectiveOperationException e) { - throw new RuntimeException(e); - } - } - - private static War baseWar(int id, Faction attacker, Faction defender) { - when(attacker.getName()).thenReturn("Attacker"); - when(defender.getName()).thenReturn("Defender"); - War war = new War(id, attacker, defender); - war.setCampaignProvinces(List.of(PROVINCE_ID, 43, 44)); - war.setCursorIndex(0); - return war; - } - - private static Faction fighter(String id, Map slots) { - Faction faction = mock(Faction.class); - when(faction.getId()).thenReturn(id); - when(faction.getMembers()).thenReturn(List.of("a", "b", "c", "d", "e", "f", "g", "h", "i", "j")); - FactionModifier levy = mock(FactionModifier.class); - when(levy.getAmount()).thenReturn(100.0); - when(faction.getModifier(FactionModifiers.LEVY)).thenReturn(levy); - - Military military = mock(Military.class); - List regiments = new ArrayList<>(); - for (Map.Entry entry : slots.entrySet()) { - Regiment regiment = mock(Regiment.class); - when(regiment.getId()).thenReturn(entry.getKey()); - when(regiment.isLevy()).thenReturn(false); - when(regiment.isOffensive()).thenReturn( - "professional".equals(entry.getKey()) - || "mercenary".equals(entry.getKey()) - || "artillery".equals(entry.getKey())); - when(regiment.isEquipment()).thenReturn("artillery".equals(entry.getKey())); - when(regiment.getCurrentSlots()).thenReturn(entry.getValue()); - regiments.add(regiment); - } - when(military.getRegiments()).thenReturn(regiments); - when(faction.getMilitary()).thenReturn(military); - return faction; - } -} diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/ui/BattleCommandsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/ui/BattleCommandsCoverageTest.java index 64790ee5..5e5ff7d5 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/ui/BattleCommandsCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/ui/BattleCommandsCoverageTest.java @@ -835,8 +835,8 @@ void manualLivesPersistButCampaignLivesStayComputed() { battle.setWarId(12); assertTrue(run("battle", "setlives", "front", "50")); assertEquals(37, battle.getLives()); - verify(alice).sendMessage(contains("computed from war commitment")); - verify(alice).sendMessage(contains("Adjust regiments")); + verify(alice).sendMessage(contains("come from each side's forces")); + verify(alice).sendMessage(contains("/battle lives front set ")); } @ParameterizedTest diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/ui/BattleInventoryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/ui/BattleInventoryCoverageTest.java index 3889593e..a82a83b8 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/ui/BattleInventoryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/ui/BattleInventoryCoverageTest.java @@ -621,8 +621,8 @@ void campaignSummaryShowsCommitmentsAndProvidesSafeResetInstructions() { lore(top().getItem(3), "Defender: §e0"); menus.sideView(alice, battle); lore(top().getItem(0), "Lives: §e23"); - lore(top().getItem(0), "Regiments §e3"); - lore(top().getItem(1), "no committed regiments"); + lore(top().getItem(0), "Slots §e3"); + lore(top().getItem(1), "no regiments can fight"); battle.setCampaignRaid(true); menus.battleView(alice, battle); assertEquals("§cCampaign raid", name(top().getItem(22))); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/ui/BattleStaffUiTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/ui/BattleStaffUiTest.java index 81bf6aba..0b8e8c68 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/ui/BattleStaffUiTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/ui/BattleStaffUiTest.java @@ -346,9 +346,13 @@ void holdAndLivesCheckTheirArguments() { battleCommand("hold", "b1", "maybe"); verify(admin, org.mockito.Mockito.times(2)).sendMessage("§cUsage: /battle hold on|off"); battleCommand("lives", "b1", "attacker"); - verify(admin).sendMessage("§cUsage: /battle lives add|remove|set "); + verify(admin).sendMessage("§cUsage: /battle lives add|remove|set , or auto before a campaign battle starts"); battleCommand("lives", "b1", "attacker", "add", "lots"); verify(admin).sendMessage("§cThe amount must be a whole number."); + staff.when(() -> BattleStaffService.adjustLives(battle, "attacker", "auto", 0, "Admin")) + .thenReturn(new BattleStaffService.Result(true, "§aBack to the forces")); + battleCommand("lives", "b1", "attacker", "AUTO"); + verify(admin).sendMessage("§aBack to the forces"); battleCommand("hold", "b1", "off"); staff.verify(() -> BattleStaffService.hold(battle, false, "Admin")); } @@ -564,7 +568,7 @@ void staffCompletionsCoverTheNewTools() { assertEquals(List.of("b1"), BattleTabCompletion.staffCompletions(admin, "battle", new String[] {"vehicles", ""})); assertEquals(List.of("attacker", "defender"), BattleTabCompletion.staffCompletions(admin, "battle", new String[] {"lives", "b1", ""})); - assertEquals(List.of("add", "remove", "set"), + assertEquals(List.of("add", "remove", "set", "auto"), BattleTabCompletion.staffCompletions(admin, "battle", new String[] {"lives", "b1", "attacker", ""})); assertEquals(List.of(), BattleTabCompletion.staffCompletions(admin, "battle", new String[] {"restart", "b1", ""})); } diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/BattleRosterBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/BattleRosterBoundaryCoverageTest.java index fdf42450..4d8a0325 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/BattleRosterBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/warband/BattleRosterBoundaryCoverageTest.java @@ -276,7 +276,7 @@ void campaignLifePreviewsUseRealRegimentsAndUniqueRosterMembersThenApplyTheSameP var preview = net.tfminecraft.simplefactions.war.battle.military.BattleLivesService .previewCampaignSideLives(rig.war, battle, "attacker"); - assertEquals(2, preview.committedRegiments()); + assertEquals(2, preview.slots()); assertEquals(8, preview.poolLives()); assertEquals(2, preview.rosterFighters()); assertEquals(6, preview.sideLives()); @@ -295,15 +295,17 @@ void campaignLifePreviewsUseRealRegimentsAndUniqueRosterMembersThenApplyTheSameP } @Test - void incompleteCampaignLifeContextsLeaveExistingPoolsAloneAndEmptyArmiesReceiveNoLives() { + void incompleteCampaignLifeContextsGiveEmptyPreviewsAndEmptyArmiesReceiveNoLives() { Battle battle = BattleFactory.createBlank(BattleType.FIELD, "incomplete_lives"); BattleManager.addBattle(battle); battle.setWarId(rig.war.getId()); BattleSide attacker = battle.getSideById("attacker"); attacker.setLives(9); + // Without a province no militia can defend, but the forces still decide the lives: none here. net.tfminecraft.simplefactions.war.battle.military.BattleLivesService.applyCampaignLives( battle); - assertEquals(9, attacker.getLives()); + assertEquals(0, attacker.getLives()); + attacker.setLives(9); var empty = new net.tfminecraft.simplefactions.war.battle.military.BattleLivesService.SideLivesPreview( 0, 0, 0, 0, 0); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/campaign/admin/WarScheduleAdminServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/campaign/admin/WarScheduleAdminServiceTest.java index fe29d7fe..88e50187 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/campaign/admin/WarScheduleAdminServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/campaign/admin/WarScheduleAdminServiceTest.java @@ -369,10 +369,10 @@ void battleCreate_seedsPhantomsWhenDevmodeOn() { net.tfminecraft.simplefactions.war.core.WarDevMode.setEnabled(true); Cache.warDevmodePhantomCount = 10; War war = votingWar(); - try (MockedStatic pool = - mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.class)) { - pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.totalCommittedRegiments( - any(), any(Integer.class), any())).thenReturn(5); + try (MockedStatic pool = + mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattleForces.class)) { + pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattleForces.build( + any(), any(), any(), any(), any())).thenReturn(net.tfminecraft.simplefactions.war.wounds.TestForces.regiments(5)); withMockBossBar(() -> { assertTrue(WarScheduleAdminService.battleCreate(war).success()); Battle battle = BattleManager.getByWarId(war.getId()); @@ -436,10 +436,10 @@ void devModeReminderLines_emptyWhenDevmodeOn() { @Test void battleDelete_rejectsStartedBattle() { War war = votingWar(); - try (MockedStatic pool = - mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.class)) { - pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.totalCommittedRegiments( - any(), any(Integer.class), any())).thenReturn(5); + try (MockedStatic pool = + mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattleForces.class)) { + pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattleForces.build( + any(), any(), any(), any(), any())).thenReturn(net.tfminecraft.simplefactions.war.wounds.TestForces.regiments(5)); withMockBossBar(() -> { assertTrue(WarScheduleAdminService.battleCreate(war).success()); placeSpawnsAndJails(BattleManager.getByWarId(war.getId())); @@ -454,10 +454,10 @@ void battleDelete_rejectsStartedBattle() { @Test void battleStart_startsExistingCampaignBattle() { War war = votingWar(); - try (MockedStatic pool = - mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.class)) { - pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.totalCommittedRegiments( - any(), any(Integer.class), any())).thenReturn(5); + try (MockedStatic pool = + mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattleForces.class)) { + pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattleForces.build( + any(), any(), any(), any(), any())).thenReturn(net.tfminecraft.simplefactions.war.wounds.TestForces.regiments(5)); withMockBossBar(() -> { assertTrue(WarScheduleAdminService.battleCreate(war).success()); placeSpawnsAndJails(BattleManager.getByWarId(war.getId())); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/campaign/progression/CampaignCapabilityServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/campaign/progression/CampaignCapabilityServiceTest.java index bd29cc05..b187149f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/campaign/progression/CampaignCapabilityServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/campaign/progression/CampaignCapabilityServiceTest.java @@ -23,7 +23,7 @@ import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.war.core.Side; import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.battle.military.BattlePoolService; +import net.tfminecraft.simplefactions.war.battle.military.BattleForces; import net.tfminecraft.simplefactions.war.battle.military.PoolMode; import net.tfminecraft.simplefactions.war.enums.CampaignPhase; import net.tfminecraft.simplefactions.war.enums.ObjectiveHolder; @@ -156,14 +156,14 @@ void canReachTarget_doesNotRequireCommittedRegiments() { } @Test - void offensiveAndDefensiveRegiments_delegateToBattlePoolService() { + void offensiveAndDefensiveRegiments_delegateToBattleForces() { War war = baseWar(); Side attackers = war.getAttackers(); Side defenders = war.getDefenders(); - try (MockedStatic pools = mockStatic(BattlePoolService.class)) { - pools.when(() -> BattlePoolService.totalCommittedRegiments( + try (MockedStatic pools = mockStatic(BattleForces.class)) { + pools.when(() -> BattleForces.regimentSlots( eq(war), eq(BATTLE_PROVINCE), eq(attackers), eq(PoolMode.OFFENSIVE))).thenReturn(10); - pools.when(() -> BattlePoolService.totalCommittedRegiments( + pools.when(() -> BattleForces.regimentSlots( eq(war), eq(BATTLE_PROVINCE), eq(defenders), eq(PoolMode.DEFENSIVE))).thenReturn(6); assertEquals( @@ -172,9 +172,9 @@ void offensiveAndDefensiveRegiments_delegateToBattlePoolService() { assertEquals( 6, CampaignCapabilityService.defensiveRegiments(war, BATTLE_PROVINCE, CampaignSideRole.DEFENDER)); - pools.verify(() -> BattlePoolService.totalCommittedRegiments( + pools.verify(() -> BattleForces.regimentSlots( eq(war), eq(BATTLE_PROVINCE), eq(attackers), eq(PoolMode.OFFENSIVE))); - pools.verify(() -> BattlePoolService.totalCommittedRegiments( + pools.verify(() -> BattleForces.regimentSlots( eq(war), eq(BATTLE_PROVINCE), eq(defenders), eq(PoolMode.DEFENSIVE))); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveCoverageTest.java index ba7ab51d..71c06ebd 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveCoverageTest.java @@ -33,9 +33,8 @@ import net.tfminecraft.simplefactions.war.campaign.progression.postbattle.CampaignPostBattleChoiceService.PostBattleChoicePhase; import net.tfminecraft.simplefactions.war.campaign.schedule.ScheduledCampaignBattle; import net.tfminecraft.simplefactions.war.campaign.ui.CampaignPushTarget; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.core.WarCommitment; +import net.tfminecraft.simplefactions.war.wounds.WarWounds; import net.tfminecraft.simplefactions.war.enums.BattleSchedulePhase; import net.tfminecraft.simplefactions.war.enums.CampaignBattleKind; import net.tfminecraft.simplefactions.war.enums.CampaignPhase; @@ -61,7 +60,6 @@ class BattleAutoresolveCoverageTest { private Duration previousOffset; private final Map settings = new LinkedHashMap<>(); private Map oldRequests; - private Map> oldCommitments; private List oldBattles; private Random previousRandom; @@ -72,8 +70,6 @@ void setup() throws Exception { fixture = new FactionDomainFixture(); oldRequests = new HashMap<>(requests()); requests().clear(); - oldCommitments = new LinkedHashMap<>(commitments()); - commitments().clear(); oldBattles = new ArrayList<>(BattleManager.get()); BattleManager.get().clear(); previousRandom = (Random) field(BattleAutoresolveService.class, "randomOverride").get(null); @@ -123,10 +119,6 @@ void close() throws Exception { requests().clear(); requests().putAll(oldRequests); } - if (oldCommitments != null) { - commitments().clear(); - commitments().putAll(oldCommitments); - } if (oldBattles != null) { BattleManager.get().clear(); BattleManager.get().addAll(oldBattles); @@ -152,12 +144,6 @@ private static Map requests() throws Exception { return (Map) field(RequestManager.class, "requests").get(null); } - @SuppressWarnings("unchecked") - private static Map> commitments() throws Exception { - return (Map>) - field(WarCommitmentService.class, "commitmentsByWar").get(null); - } - private void setting(String name, Object value) throws Exception { Field f = field(Cache.class, name); settings.putIfAbsent(f, f.get(null)); @@ -169,6 +155,13 @@ private void time(Instant instant) { CampaignClock.add(Duration.between(Instant.now(), instant)); } + /** Battles only wound: slots stay until the war ends, but wounded ones cannot fight. */ + private static void assertWounded(Faction faction, int wounded, int available) { + Regiment guard = faction.getMilitary().getRegiment("guard"); + assertEquals(wounded, WarWounds.woundedSlots(faction, guard)); + assertEquals(available, WarWounds.availableSlots(faction, guard)); + } + private War votingWar(int id) { War war = new War(id, attacker, defender); war.setGoal(WarGoalType.WAR); @@ -194,12 +187,11 @@ private War votingWar(int id) { war.setAutoresolveProposedByAttacker(true); war.setAutoresolveProposedByDefender(true); WarManager.addWar(war); - WarCommitmentService.commitAllParticipants(war); return war; } @Test - void anAcceptedCurrentRequestAppliesRealCasualtiesAndPersistsTheBattleResult() { + void anAcceptedCurrentRequestWoundsRealRegimentsAndPersistsTheBattleResult() { War war = votingWar(0); assertEquals( BattleAutoresolveService.SendResult.SENT, @@ -211,8 +203,8 @@ void anAcceptedCurrentRequestAppliesRealCasualtiesAndPersistsTheBattleResult() { assertFalse(RequestManager.hasRequest(bob)); assertTrue(war.hasFirstBattleStarted()); assertEquals(1, war.getCampaignBattlesFought()); - assertEquals(8, attacker.getMilitary().getRegiment("guard").getCurrentSlots()); - assertEquals(3, defender.getMilitary().getRegiment("guard").getCurrentSlots()); + assertWounded(attacker, 2, 8); + assertWounded(defender, 3, 3); assertFalse(war.isAutoresolveProposedByAttacker()); assertFalse(war.isAutoresolveProposedByDefender()); verify(bob).sendMessage("§aBattle vote autoresolve accepted."); @@ -382,8 +374,8 @@ void aMissingDisplayNameUsesTheWinningSideLabelWithoutChangingTheOutcome() { assertTrue(BattleAutoresolveService.resolve(war)); verify(alice).sendMessage(contains("§eAttacker §7won")); - assertEquals(8, attacker.getMilitary().getRegiment("guard").getCurrentSlots()); - assertEquals(3, defender.getMilitary().getRegiment("guard").getCurrentSlots()); + assertWounded(attacker, 2, 8); + assertWounded(defender, 3, 3); } @Test @@ -447,15 +439,16 @@ public double nextDouble() { } @Test - void largeConfiguredLivesCannotTurnCommittedArmiesIntoZeroCasualtyForces() throws Exception { + void largeConfiguredLivesCannotTurnArmiesIntoZeroCasualtyForces() throws Exception { setting("warBattleLivesPerRegiment", Integer.MAX_VALUE); War war = votingWar(0); assertTrue(BattleAutoresolveService.resolve(war, new Random(1))); - verify(alice).sendMessage(contains("§e" + attacker.getName() + " §7won")); - assertEquals(0, attacker.getMilitary().getRegiment("guard").getCurrentSlots()); - assertEquals(0, defender.getMilitary().getRegiment("guard").getCurrentSlots()); + // Each side's pool is capped at the largest int, so the two armies meet as equals. + verify(alice).sendMessage(contains("§e" + defender.getName() + " §7won")); + assertWounded(attacker, 10, 0); + assertWounded(defender, 6, 0); assertEquals(1, war.getCampaignBattlesFought()); assertFalse(war.isAutoresolveProposedByAttacker()); assertFalse(war.isAutoresolveProposedByDefender()); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveServiceTest.java index 4bf5259c..0bcb873f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveServiceTest.java @@ -42,7 +42,8 @@ import net.tfminecraft.simplefactions.war.battle.engine.core.Battle; import net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager; import net.tfminecraft.simplefactions.war.battle.military.BattleCasualtyService; -import net.tfminecraft.simplefactions.war.battle.military.BattlePoolService; +import net.tfminecraft.simplefactions.war.battle.military.BattleForces; +import net.tfminecraft.simplefactions.war.wounds.TestForces; import net.tfminecraft.simplefactions.war.battle.template.BattleTemplate; import net.tfminecraft.simplefactions.war.campaign.progression.AttackerNavalContestService; import net.tfminecraft.simplefactions.war.campaign.progression.BelligerentRole; @@ -67,7 +68,7 @@ class BattleAutoresolveServiceTest { private MockedStatic warManager; private MockedStatic titles; private MockedStatic capability; - private MockedStatic pools; + private MockedStatic pools; private MockedStatic casualties; @BeforeEach @@ -210,7 +211,7 @@ void resolve_navalAutoLossRunsBeforeSimulation() { try (MockedStatic naval = mockStatic(AttackerNavalContestService.class); MockedStatic casualty = mockStatic(BattleCasualtyService.class); - MockedStatic pool = mockStatic(BattlePoolService.class)) { + MockedStatic pool = mockStatic(BattleForces.class)) { naval.when(() -> AttackerNavalContestService.applyIfAttackerHasNoBerthedNavy(any(), anyInt())) .thenReturn(true); @@ -220,10 +221,10 @@ void resolve_navalAutoLossRunsBeforeSimulation() { assertFalse(war.isAutoresolveProposedByAttacker()); assertEquals(0, war.getCampaignBattlesFought()); casualty.verify( - () -> BattleCasualtyService.applyBattleCasualties(any(), any(), any()), + () -> BattleCasualtyService.applyBattleCasualties(any(), any(), any(), any()), never()); pool.verify( - () -> BattlePoolService.totalCommittedRegiments(any(), anyInt(), any()), + () -> BattleForces.build(any(), any(), any(), any(), any()), never()); } } @@ -266,11 +267,11 @@ private void openHarness(War war, Side heavySide) { AtomicReference carrier = new AtomicReference<>(); AtomicReference> deaths = new AtomicReference<>(); - pools = mockStatic(BattlePoolService.class, org.mockito.Mockito.CALLS_REAL_METHODS); - pools.when(() -> BattlePoolService.totalCommittedRegiments(any(), anyInt(), any())) - .thenAnswer(invocation -> invocation.getArgument(2) == heavySide ? 10 : 6); + pools = mockStatic(BattleForces.class, org.mockito.Mockito.CALLS_REAL_METHODS); + pools.when(() -> BattleForces.build(any(), any(), any(), any(), any())) + .thenAnswer(invocation -> TestForces.regiments(invocation.getArgument(2) == heavySide ? 10 : 6)); casualties = mockStatic(BattleCasualtyService.class, org.mockito.Mockito.CALLS_REAL_METHODS); - casualties.when(() -> BattleCasualtyService.applyBattleCasualties(any(), any(), any())) + casualties.when(() -> BattleCasualtyService.applyBattleCasualties(any(), any(), any(), any())) .thenAnswer(invocation -> { carrier.set(invocation.getArgument(1)); @SuppressWarnings("unchecked") diff --git a/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleServiceTest.java index 3c85bb9c..3b902ac4 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleServiceTest.java @@ -728,15 +728,15 @@ private void mockMilitary(Faction faction) { private void withChoiceResolutionMocks(War war, Runnable action) { try (MockedStatic warManager = mockStatic(WarManager.class); - MockedStatic pool = - mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.class)) { + MockedStatic pool = + mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattleForces.class)) { warManager.when(() -> WarManager.persist(any())).then(inv -> null); warManager.when(() -> WarManager.getById(war.getId())).thenReturn(war); warManager.when(() -> WarManager.endWar(any(), any())).then(inv -> null); - pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.totalCommittedRegiments( - any(), any(Integer.class), any())).thenReturn(5); - pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.totalCommittedRegiments( - any(), any(Integer.class), any(), any())).thenReturn(5); + pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattleForces.build( + any(), any(), any(), any(), any())).thenReturn(net.tfminecraft.simplefactions.war.wounds.TestForces.regiments(5)); + pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattleForces.regimentSlots( + any(), any(), any(), any())).thenReturn(5); action.run(); } } @@ -744,17 +744,17 @@ private void withChoiceResolutionMocks(War war, Runnable action) { private void withMockBossBar(Runnable action) { BossBar bossBar = mock(BossBar.class); try (MockedStatic bukkit = mockStatic(Bukkit.class); - MockedStatic pool = - mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.class)) { + MockedStatic pool = + mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattleForces.class)) { bukkit.when(() -> Bukkit.createBossBar(anyString(), any(BarColor.class), any(BarStyle.class))) .thenReturn(bossBar); bukkit.when(() -> Bukkit.createBossBar(anyString(), any(BarColor.class), any(BarStyle.class), any())) .thenReturn(bossBar); bukkit.when(() -> Bukkit.getPlayerExact(anyString())).thenReturn(null); - pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.totalCommittedRegiments( - any(), any(Integer.class), any())).thenReturn(5); - pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.totalCommittedRegiments( - any(), any(Integer.class), any(), any())).thenReturn(5); + pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattleForces.build( + any(), any(), any(), any(), any())).thenReturn(net.tfminecraft.simplefactions.war.wounds.TestForces.regiments(5)); + pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattleForces.regimentSlots( + any(), any(), any(), any())).thenReturn(5); action.run(); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleTickServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleTickServiceTest.java index b608c729..8609fce1 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleTickServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleTickServiceTest.java @@ -257,15 +257,15 @@ private War scheduledWar() { private void withMocksForChoiceResolution(War war, Runnable runnable) { try (MockedStatic warManager = mockStatic(WarManager.class); - MockedStatic pool = - mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.class)) { + MockedStatic pool = + mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattleForces.class)) { warManager.when(() -> WarManager.persist(any())).then(inv -> null); warManager.when(() -> WarManager.getById(war.getId())).thenReturn(war); warManager.when(() -> WarManager.endWar(any(), any())).then(inv -> null); - pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.totalCommittedRegiments( - any(), any(Integer.class), any())).thenReturn(5); - pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.totalCommittedRegiments( - any(), any(Integer.class), any(), any())).thenReturn(5); + pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattleForces.build( + any(), any(), any(), any(), any())).thenReturn(net.tfminecraft.simplefactions.war.wounds.TestForces.regiments(5)); + pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattleForces.regimentSlots( + any(), any(), any(), any())).thenReturn(5); runnable.run(); } } @@ -295,17 +295,17 @@ private void withMockBossBar(Runnable action) { private void withMockBossBarAndPools(Runnable action) { BossBar bossBar = mock(BossBar.class); try (MockedStatic bukkit = mockStatic(Bukkit.class); - MockedStatic pool = - mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.class)) { + MockedStatic pool = + mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattleForces.class)) { bukkit.when(() -> Bukkit.createBossBar(anyString(), any(BarColor.class), any(BarStyle.class))) .thenReturn(bossBar); bukkit.when(() -> Bukkit.createBossBar(anyString(), any(BarColor.class), any(BarStyle.class), any())) .thenReturn(bossBar); bukkit.when(() -> Bukkit.getPlayerExact(anyString())).thenReturn(null); - pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.totalCommittedRegiments( - any(), any(Integer.class), any())).thenReturn(5); - pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.totalCommittedRegiments( - any(), any(Integer.class), any(), any())).thenReturn(5); + pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattleForces.build( + any(), any(), any(), any(), any())).thenReturn(net.tfminecraft.simplefactions.war.wounds.TestForces.regiments(5)); + pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattleForces.regimentSlots( + any(), any(), any(), any())).thenReturn(5); action.run(); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarLifecycleCoverageTest.java index 7011e178..dc10bffa 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/civilwar/CivilWarLifecycleCoverageTest.java @@ -38,9 +38,7 @@ import net.tfminecraft.simplefactions.war.civilwar.split.CivilWarCapitalAssignService; import net.tfminecraft.simplefactions.war.civilwar.split.CivilWarLandSplitService; import net.tfminecraft.simplefactions.war.civilwar.split.CivilWarLandSplitService.LandSplitPlan; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.core.WarCommitment; import org.bukkit.configuration.file.YamlConfiguration; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -55,7 +53,6 @@ class CivilWarLifecycleCoverageTest { private Map provinces; private String oldVassalageGroup; private String oldVassalageLaw; - private Map> oldCommitments; @BeforeEach void setup() throws Exception { @@ -64,8 +61,6 @@ void setup() throws Exception { files = new PersistenceFilesFixture(); Files.createDirectories(files.root.resolve("Data")); fixture = new FactionDomainFixture(); - oldCommitments = new LinkedHashMap<>(commitments()); - commitments().clear(); fixture.lawGroup("constitution", Map.of()); manager = new ProvinceManager(); provinces = new LinkedHashMap<>(); @@ -78,10 +73,6 @@ void setup() throws Exception { @AfterEach void close() throws Exception { try { - if (oldCommitments != null) { - commitments().clear(); - commitments().putAll(oldCommitments); - } if (fixture != null) fixture.close(); } finally { Cache.civilWarVassalageGroup = oldVassalageGroup; @@ -206,12 +197,6 @@ void aFailedConfiguredLawReturnsCitizenSupportersToTheirOriginalGuild() { assertEquals(List.of(1, 2), home.getProvinces()); } - @SuppressWarnings("unchecked") - private static Map> commitments() throws Exception { - Field field = WarCommitmentService.class.getDeclaredField("commitmentsByWar"); - field.setAccessible(true); - return (Map>) field.get(null); - } @Test void aSuccessfulCitizenRebellionMovesItsLeaderOutOfTheHostExactlyOnce() { diff --git a/src/test/java/net/tfminecraft/simplefactions/war/commitment/LevySnapshotCalculatorTest.java b/src/test/java/net/tfminecraft/simplefactions/war/commitment/LevySnapshotCalculatorTest.java deleted file mode 100644 index 0243e744..00000000 --- a/src/test/java/net/tfminecraft/simplefactions/war/commitment/LevySnapshotCalculatorTest.java +++ /dev/null @@ -1,190 +0,0 @@ -package net.tfminecraft.simplefactions.war.commitment; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; - -import net.tfminecraft.simplefactions.army.Military; -import net.tfminecraft.simplefactions.army.Regiment; -import net.tfminecraft.simplefactions.managers.FactionManager; -import net.tfminecraft.simplefactions.managers.RelationManager; -import net.tfminecraft.simplefactions.objects.Faction; -import net.tfminecraft.simplefactions.objects.FactionModifier; -import net.tfminecraft.simplefactions.war.core.Participant; -import net.tfminecraft.simplefactions.war.core.Side; -import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.enums.FactionModifiers; - -class LevySnapshotCalculatorTest { - @Test - void findNearestFighterHolder_returnsNearestNotTopOverlord() { - Faction v3 = faction("v3", 5); - Faction v2 = faction("v2", 5); - Faction v = faction("v", 5); - Faction m = faction("m", 5); - - try (MockedStatic relations = mockStatic(RelationManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - relations.when(() -> RelationManager.getOverlord(v3)).thenReturn("v2"); - relations.when(() -> RelationManager.getOverlord(v2)).thenReturn("v"); - relations.when(() -> RelationManager.getOverlord(v)).thenReturn("m"); - relations.when(() -> RelationManager.getOverlord(m)).thenReturn(null); - factions.when(() -> FactionManager.getByString("v2")).thenReturn(v2); - factions.when(() -> FactionManager.getByString("v")).thenReturn(v); - factions.when(() -> FactionManager.getByString("m")).thenReturn(m); - - Set fighters = Set.of("m", "v"); - assertEquals(v, LevySnapshotCalculator.findNearestFighterHolder(v3, fighters)); - assertEquals(v, LevySnapshotCalculator.findNearestFighterHolder(v2, fighters)); - } - } - - @Test - void findNearestFighterHolder_returnsNullWhenNoFighterOnChain() { - Faction v2 = faction("v2", 5); - try (MockedStatic relations = mockStatic(RelationManager.class)) { - relations.when(() -> RelationManager.getOverlord(v2)).thenReturn(null); - assertNull(LevySnapshotCalculator.findNearestFighterHolder(v2, Set.of("m"))); - } - } - - @Test - void levyContribution_appliesIntermediateModifier() { - Faction v3 = faction("v3", 10); - Faction v2 = faction("v2", 10); - Faction v = faction("v", 10); - - try (MockedStatic relations = mockStatic(RelationManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - relations.when(() -> RelationManager.getOverlord(v3)).thenReturn("v2"); - relations.when(() -> RelationManager.getOverlord(v2)).thenReturn("v"); - factions.when(() -> FactionManager.getByString("v2")).thenReturn(v2); - factions.when(() -> FactionManager.getByString("v")).thenReturn(v); - - assertEquals(5, LevySnapshotCalculator.levyContribution(v2, v)); - assertEquals(3, LevySnapshotCalculator.levyContribution(v3, v)); - } - } - - @Test - void directLevyContribution_ignoresEquipmentSlots() { - Faction source = faction("v", 10); - Regiment artillery = mock(Regiment.class); - when(artillery.isLevy()).thenReturn(false); - when(artillery.isEquipment()).thenReturn(true); - when(artillery.getId()).thenReturn("artillery"); - when(artillery.getCurrentSlots()).thenReturn(10); - Military military = source.getMilitary(); - List regiments = new java.util.ArrayList<>(military.getRegiments()); - regiments.add(artillery); - when(military.getRegiments()).thenReturn(regiments); - - assertEquals(5, LevySnapshotCalculator.directLevyContribution(source)); - } - - @Test - void collectLevyRows_assignsNearestFighterHolder() { - Faction m = faction("m", 0); - Faction v = fighter("v", 10); - Faction v2 = faction("v2", 8); - Faction v3 = faction("v3", 4); - Faction d = fighter("d", 0); - - War war = new War(1, m, d); - Participant attacker = war.getAttackers().getMainParticipants().get(0); - attacker.getSubjects().add(v); - - try (MockedStatic relations = mockStatic(RelationManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - stubSubjects(relations, Map.of( - "m", List.of(v), - "v", List.of(v2), - "v2", List.of(v3), - "v3", List.of())); - Map overlordMap = new HashMap<>(); - overlordMap.put("m", null); - overlordMap.put("v", "m"); - overlordMap.put("v2", "v"); - overlordMap.put("v3", "v2"); - stubOverlords(relations, factions, overlordMap, Map.of( - "m", m, - "v", v, - "v2", v2, - "v3", v3)); - relations.when(() -> RelationManager.sameRealm(any(Faction.class), any(Faction.class))).thenReturn(true); - - Map rows = - LevySnapshotCalculator.collectLevyRows(war.getAttackers()); - - assertEquals(2, rows.size()); - assertEquals(4, rows.get(LevySnapshotCalculator.levyKey("v", "v2")).count()); - assertEquals(1, rows.get(LevySnapshotCalculator.levyKey("v", "v3")).count()); - assertFalse(rows.containsKey(LevySnapshotCalculator.levyKey("m", "v2"))); - } - } - - private static Faction fighter(String id, int slots) { - return faction(id, slots); - } - - private static Faction faction(String id, int slots) { - Faction faction = mock(Faction.class); - when(faction.getId()).thenReturn(id); - when(faction.getMembers()).thenReturn(List.of("a", "b", "c", "d", "e", "f", "g", "h", "i", "j")); - FactionModifier levy = mock(FactionModifier.class); - when(levy.getAmount()).thenReturn(50.0); - when(faction.getModifier(FactionModifiers.LEVY)).thenReturn(levy); - - Military military = mock(Military.class); - Regiment regiment = mock(Regiment.class); - when(regiment.isLevy()).thenReturn(false); - when(regiment.getId()).thenReturn("infantry"); - when(regiment.getCurrentSlots()).thenReturn(slots); - when(military.getRegiments()).thenReturn(List.of(regiment)); - when(faction.getMilitary()).thenReturn(military); - return faction; - } - - private static void stubSubjects(MockedStatic relations, Map> subjects) { - for (Map.Entry> entry : subjects.entrySet()) { - Faction key = mock(Faction.class); - when(key.getId()).thenReturn(entry.getKey()); - relations.when(() -> RelationManager.getSubjects(key)).thenReturn(entry.getValue()); - } - relations.when(() -> RelationManager.getSubjects(org.mockito.ArgumentMatchers.any(Faction.class))) - .thenAnswer(invocation -> { - Faction faction = invocation.getArgument(0); - return subjects.getOrDefault(faction.getId(), List.of()); - }); - } - - private static void stubOverlords( - MockedStatic relations, - MockedStatic factions, - Map overlords, - Map factionById) { - relations.when(() -> RelationManager.getOverlord(org.mockito.ArgumentMatchers.any(Faction.class))) - .thenAnswer(invocation -> { - Faction faction = invocation.getArgument(0); - return overlords.get(faction.getId()); - }); - factions.when(() -> FactionManager.getByString(org.mockito.ArgumentMatchers.anyString())) - .thenAnswer(invocation -> { - String id = invocation.getArgument(0); - return factionById.get(id); - }); - } -} diff --git a/src/test/java/net/tfminecraft/simplefactions/war/core/CallToArmsServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/core/CallToArmsServiceTest.java index 2fedeb92..61c9d6ba 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/core/CallToArmsServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/core/CallToArmsServiceTest.java @@ -22,7 +22,6 @@ import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; import net.tfminecraft.simplefactions.testsupport.PersistenceFilesFixture; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; import org.bukkit.entity.Player; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -32,7 +31,6 @@ class CallToArmsServiceTest { private FactionDomainFixture fixture; private PersistenceFilesFixture files; - private Map> previousCommitments; private RelationType alliance; private int previousAllyPenalty; private Faction attacker; @@ -44,8 +42,6 @@ void setup() throws Exception { files = new PersistenceFilesFixture(); Files.createDirectories(files.root.resolve("Data")); fixture = new FactionDomainFixture(); - previousCommitments = new LinkedHashMap<>(commitments()); - commitments().clear(); previousAllyPenalty = Cache.warDeclinedAllyStabilityPenalty; Cache.warDeclinedAllyStabilityPenalty = -30; alliance = fixture.relationType("ally", Map.of("link", "ally")); @@ -59,8 +55,6 @@ void setup() throws Exception { @AfterEach void close() throws Exception { try { - commitments().clear(); - commitments().putAll(previousCommitments); Cache.warDeclinedAllyStabilityPenalty = previousAllyPenalty; fixture.close(); } finally { @@ -68,12 +62,6 @@ void close() throws Exception { } } - @SuppressWarnings("unchecked") - private static Map> commitments() throws Exception { - Field field = WarCommitmentService.class.getDeclaredField("commitmentsByWar"); - field.setAccessible(true); - return (Map>) field.get(null); - } private void ally(Faction first, Faction second) { first.setRelation(second, new Relation(alliance, RelationLoader.getDefaultAttitude())); @@ -160,7 +148,6 @@ void acceptingACurrentCallCommitsItsFactionAndPersistsItsParticipation() { assertTrue(war.isParticipating(target)); assertTrue(war.getCallsToArms().isEmpty()); - assertFalse(WarManager.getCommitmentsForWar(36).isEmpty()); verify(sender).sendMessage("target §aaccepted your call to arms: " + war.getName() + "§a."); verify(called).sendMessage("§aYour faction has joined the war: " + war.getName()); War stored = saved(36); @@ -201,7 +188,6 @@ void acceptingAfterTheWarEndsDoesNotRecreateItsSaveOrCommitTroops() { CallToArmsService.accept(called, war); assertFalse(war.isParticipating(target)); - assertTrue(WarManager.getCommitmentsForWar(37).isEmpty()); assertFalse(Files.exists(files.root.resolve("Wars/war_37.json"))); verify(called).sendMessage("§cCould not join the war."); } @@ -219,7 +205,6 @@ void aCallCannotRestoreAnOldWarObjectAfterTheManagerReloads() { assertFalse(original.isParticipating(target)); assertFalse(current.isParticipating(target)); - assertTrue(WarManager.getCommitmentsForWar(38).isEmpty()); assertTrue(saved(38).getCallsToArms().containsKey("target"), "The live war's save is untouched"); CallToArmsService.accept(called, current); assertTrue(current.isParticipating(target)); @@ -277,7 +262,6 @@ void aCallIsRevalidatedWhenTheAllyBecomesBoundToTheEnemy() { CallToArmsService.accept(called, war); assertFalse(war.isParticipating(target)); - assertTrue(WarManager.getCommitmentsForWar(41).isEmpty()); verify(called).sendMessage("§cCould not join the war."); assertTrue(saved(41).getCallsToArms().isEmpty(), "The spent call is saved as gone"); } diff --git a/src/test/java/net/tfminecraft/simplefactions/war/core/WarCommandsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/core/WarCommandsCoverageTest.java index 1bd8113b..ef919093 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/core/WarCommandsCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/core/WarCommandsCoverageTest.java @@ -40,7 +40,6 @@ import net.tfminecraft.simplefactions.war.campaign.runtime.BattleScheduleTickService; import net.tfminecraft.simplefactions.war.campaign.runtime.CampaignClock; import net.tfminecraft.simplefactions.war.campaign.vote.BattleQuorumService; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; import net.tfminecraft.simplefactions.war.enums.BattleSchedulePhase; import net.tfminecraft.simplefactions.war.enums.WarEndReason; import net.tfminecraft.simplefactions.war.enums.WarGoalType; @@ -83,8 +82,6 @@ class WarCommandsCoverageTest { private final Map previousCache = new LinkedHashMap<>(); private MockedStatic tlibs; private MockedStatic battlePersistence; - private Map> commitments; - private Map> previousCommitments; @BeforeEach void setUp() { @@ -109,15 +106,6 @@ void setUp() { CampaignClock.reset(); previousDevmode = WarDevMode.isEnabled(); WarDevMode.resetForTests(); - try { - Field field = WarCommitmentService.class.getDeclaredField("commitmentsByWar"); - field.setAccessible(true); - commitments = (Map>) field.get(null); - previousCommitments = new LinkedHashMap<>(commitments); - commitments.clear(); - } catch (ReflectiveOperationException error) { - throw new AssertionError(error); - } cache("warBattleWindowStartHour", 20); cache("warBattleWindowEndHour", 24); cache("warVoteCloseHour", 16); @@ -155,8 +143,6 @@ void close() { BattleManager.get().addAll(previousBattles); WarbandManager.get().clear(); WarbandManager.get().addAll(previousBands); - commitments.clear(); - commitments.putAll(previousCommitments); previousCache.forEach( (field, value) -> { try { @@ -222,6 +208,62 @@ private War war(int id) { return war; } + @Test + void theArmyToolsAnswerAndComplete() { + War war = war(78); + org.bukkit.configuration.file.YamlConfiguration yaml = new org.bukkit.configuration.file.YamlConfiguration(); + yaml.set("item.material", "PAPER"); + net.tfminecraft.simplefactions.army.Regiment guard = new net.tfminecraft.simplefactions.army.Regiment("guard", yaml); + guard.setCurrentSlots(4); + attacker.getMilitary().getRegiments().add(guard); + org.bukkit.configuration.file.YamlConfiguration levyYaml = new org.bukkit.configuration.file.YamlConfiguration(); + levyYaml.set("item.material", "PAPER"); + levyYaml.set("levy", true); + attacker.getMilitary().getRegiments().add(new net.tfminecraft.simplefactions.army.Regiment("levy", levyYaml)); + war.getLedger().addBattle("b", "fought", "t", 10, + new net.tfminecraft.simplefactions.war.wounds.WarLedger.SideRecord(10, + net.tfminecraft.simplefactions.war.wounds.TestForces.regiments("iron", 4)), + new net.tfminecraft.simplefactions.war.wounds.WarLedger.SideRecord(0, List.of())); + net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany company = + mock(net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany.class); + net.tfminecraft.simplefactions.guild.Guild guild = attacker.getOrCreateMainGuild(); + when(company.getGuild()).thenReturn(guild); + when(company.getName()).thenReturn("Blades"); + when(company.getWounds()).thenReturn(new net.tfminecraft.simplefactions.war.wounds.CompanyWounds()); + guild.setCompany(company); + + assertTrue(run("admin", "losses", "78")); + verify(alice).sendMessage("§6War 78: 1 recorded battles"); + assertTrue(run("admin", "army", "iron")); + verify(alice).sendMessage(contains("§eguard§7: 4 slots")); + assertTrue(run("admin", "wounds", "78", "iron", "guard", "add", "5")); + verify(alice).sendMessage("§aWar 78: added 5 wounded lives on iron guard."); + + assertTrue(complete("admin", "lo").contains("losses")); + assertTrue(complete("admin", "ar").contains("army")); + assertTrue(complete("admin", "wou").contains("wounds")); + assertEquals(List.of("iron"), complete("admin", "army", "ir")); + assertEquals(List.of(), complete("admin", "army", "iron", "")); + assertTrue(complete("admin", "losses", "").contains("78")); + assertFalse(complete("admin", "losses", "").contains("company")); + assertTrue(complete("admin", "wounds", "").containsAll(List.of("78", "company"))); + assertEquals(List.of("1"), complete("admin", "losses", "78", "")); + assertEquals(List.of(), complete("admin", "losses", "999", "")); + assertEquals(List.of("attacker", "defender"), complete("admin", "losses", "78", "1", "")); + assertEquals(List.of("set", "add", "remove"), complete("admin", "losses", "78", "1", "attacker", "")); + assertEquals(List.of(), complete("admin", "losses", "78", "1", "attacker", "set", "")); + assertEquals(List.of("iron"), complete("admin", "wounds", "78", "ir")); + assertTrue(complete("admin", "wounds", "78", "iron", "").contains("guard")); + assertFalse(complete("admin", "wounds", "78", "iron", "").contains("levy")); + assertEquals(List.of(), complete("admin", "wounds", "78", "ghost", "")); + assertEquals(List.of("add", "remove"), complete("admin", "wounds", "78", "iron", "guard", "")); + assertEquals(List.of(guild.getId()), complete("admin", "wounds", "company", guild.getId().substring(0, 1)).stream() + .filter(guild.getId()::equals).toList()); + assertEquals(List.of("set"), complete("admin", "wounds", "company", guild.getId(), "")); + assertEquals(List.of(), complete("admin", "wounds", "company", guild.getId(), "set", "")); + guild.setCompany(null); + } + @Test void irreversibleAdminCommandsWaitForConfirm() { War war = war(31); @@ -928,7 +970,10 @@ static Stream completionCases() { "truce", "history", "factions", - "forceinstallation")), + "forceinstallation", + "losses", + "army", + "wounds")), Arguments.of("admin history ", List.of("status", "upload-battles")), Arguments.of("admin history u", List.of("upload-battles")), Arguments.of("admin history status ", List.of()), diff --git a/src/test/java/net/tfminecraft/simplefactions/war/core/WarCommitmentTest.java b/src/test/java/net/tfminecraft/simplefactions/war/core/WarCommitmentTest.java deleted file mode 100644 index 6bdf3c5a..00000000 --- a/src/test/java/net/tfminecraft/simplefactions/war/core/WarCommitmentTest.java +++ /dev/null @@ -1,416 +0,0 @@ -package net.tfminecraft.simplefactions.war.core; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; - -import org.bukkit.Bukkit; - -import com.google.gson.Gson; - -import net.tfminecraft.simplefactions.army.Military; -import net.tfminecraft.simplefactions.army.Regiment; -import net.tfminecraft.simplefactions.managers.FactionManager; -import net.tfminecraft.simplefactions.managers.RelationManager; -import net.tfminecraft.simplefactions.managers.WarManager; -import net.tfminecraft.simplefactions.objects.Faction; -import net.tfminecraft.simplefactions.objects.FactionModifier; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; -import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.core.WarCommitment; -import net.tfminecraft.simplefactions.enums.FactionModifiers; - -class WarCommitmentTest { - private static final Gson GSON = new Gson(); - private List savedWars; - - @BeforeEach - void setUp() { - savedWars = new ArrayList<>(WarManager.get()); - WarManager.get().clear(); - } - - @AfterEach - void tearDown() { - for (War war : new ArrayList<>(WarManager.get())) { - WarCommitmentService.clearCommitments(war.getId()); - } - WarManager.get().clear(); - WarManager.get().addAll(savedWars); - } - - @Test - void commitment_preservesWarIdAndFields() { - Instant committedAt = Instant.parse("2026-08-19T12:00:00Z"); - WarCommitment commitment = new WarCommitment(3, "faction_a", "subject_a", "levy", 12, committedAt); - - assertEquals(3, commitment.warId()); - assertEquals("faction_a", commitment.factionId()); - assertEquals("subject_a", commitment.sourceFactionId()); - assertEquals("levy", commitment.regimentId()); - assertEquals(12, commitment.count()); - assertEquals(committedAt, commitment.committedAt()); - assertTrue(commitment.isLevyRow()); - } - - @Test - void commitment_jsonShape_preservesWarId() { - String json = - "{\"warId\":3,\"factionId\":\"faction_a\",\"sourceFactionId\":\"subject_a\",\"regimentId\":\"levy\",\"count\":12,\"committedAt\":\"2026-08-19T12:00:00Z\"}"; - CommitmentJson restored = GSON.fromJson(json, CommitmentJson.class); - - assertNotNull(restored); - assertEquals(3, restored.warId); - assertEquals("faction_a", restored.factionId); - assertEquals("subject_a", restored.sourceFactionId); - assertEquals("levy", restored.regimentId); - assertEquals(12, restored.count); - assertEquals("2026-08-19T12:00:00Z", restored.committedAt); - } - - private static final class CommitmentJson { - int warId; - String factionId; - String sourceFactionId; - String regimentId; - int count; - String committedAt; - } - - @Test - void getWarId_matchesGetId() { - Faction attacker = mock(Faction.class); - Faction defender = mock(Faction.class); - when(attacker.getId()).thenReturn("attacker"); - when(defender.getId()).thenReturn("defender"); - when(attacker.getName()).thenReturn("Attacker"); - when(defender.getName()).thenReturn("Defender"); - - War war = new War(7, attacker, defender); - assertEquals(war.getId(), war.getWarId()); - } - - @Test - void commitFaction_capturesRegimentSlots() { - War war = baseWar(5); - Faction faction = fighter("attacker", Map.of("militia", 3, "professional", 7)); - - List commitments = WarCommitmentService.commitFaction(war, faction); - - assertEquals(2, commitments.size()); - assertTrue(commitments.stream().noneMatch(c -> "levy".equals(c.regimentId()))); - assertTrue(commitments.stream().anyMatch(c -> "militia".equals(c.regimentId()) && c.count() == 3)); - assertTrue(commitments.stream().anyMatch(c -> "professional".equals(c.regimentId()) && c.count() == 7)); - for (WarCommitment commitment : commitments) { - assertNull(commitment.sourceFactionId()); - } - } - - @Test - void commitFaction_skipsEquipmentSlots() { - War war = baseWar(12); - Faction faction = fighter("attacker", Map.of("professional", 2, "artillery", 5)); - - List commitments = WarCommitmentService.commitFaction(war, faction); - - assertEquals(1, commitments.size()); - assertEquals("professional", commitments.get(0).regimentId()); - assertEquals(2, commitments.get(0).count()); - } - - @Test - void commitFaction_idempotent() { - War war = baseWar(9); - Faction faction = fighter("attacker", Map.of("infantry", 5)); - - List first = WarCommitmentService.commitFaction(war, faction); - List second = WarCommitmentService.commitFaction(war, faction); - - assertEquals(first, second); - assertEquals(1, WarManager.getCommitmentsForWar(9).stream().filter(c -> !c.isLevyRow()).count()); - } - - @Test - void nestedVassal_holderIsNearestFighter() { - Faction m = fighter("m", Map.of("professional", 1)); - Faction v = fighter("v", Map.of("professional", 1)); - Faction v2 = levySource("v2", 8); - Faction v3 = levySource("v3", 4); - Faction d = fighter("d", Map.of("professional", 1)); - - War war = baseWar(20); - war.getAttackers().getMainParticipants().get(0).getSubjects().add(v); - - try (MockedStatic relations = mockStatic(RelationManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - stubLevyGraph(relations, factions, m, v, v2, v3); - - WarCommitmentService.snapshotLevyForSide(war, war.getAttackers()); - - assertLevyRow(war.getId(), "v", "v2", 4); - assertLevyRow(war.getId(), "v", "v3", 1); - assertNoLevyRow(war.getId(), "m", "v2"); - assertNoLevyRow(war.getId(), "m", "v3"); - } - } - - @Test - void commitAllParticipants_includesSubjects() { - Faction m = fighter("m", Map.of("professional", 2)); - Faction v = fighter("v", Map.of("militia", 1)); - Faction v2 = levySource("v2", 6); - Faction d = fighter("d", Map.of("professional", 3)); - - War war = baseWar(21); - war.getAttackers().getMainParticipants().get(0).getSubjects().add(v); - - try (MockedStatic relations = mockStatic(RelationManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - stubLevyGraph(relations, factions, m, v, v2, null); - - WarCommitmentService.commitAllParticipants(war); - - assertTrue(WarManager.getCommitmentsForWar(21).stream() - .anyMatch(c -> "v".equals(c.factionId()) && "militia".equals(c.regimentId()))); - assertLevyRow(21, "v", "v2", 3); - assertTrue(WarManager.getCommitmentsForWar(21).stream() - .noneMatch(c -> c.isLevyRow() && "v2".equals(c.factionId()))); - } - } - - @Test - void allyJoin_addsLevySnapshot() { - Faction m = fighter("m", Map.of("professional", 1)); - Faction ally = fighter("ally", Map.of("professional", 1)); - Faction s1 = levySource("s1", 6); - Faction d = fighter("d", Map.of("professional", 1)); - - War war = baseWar(22); - war.getAttackers().getMainParticipants().get(0).getAllies().put(ally, false); - - try (MockedStatic relations = mockStatic(RelationManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - stubLevyGraph(relations, factions, m, ally, s1, null); - relations.when(() -> RelationManager.getSubjects(ally)).thenReturn(List.of(s1)); - relations.when(() -> RelationManager.getSubjects(s1)).thenReturn(List.of()); - relations.when(() -> RelationManager.getOverlord(s1)).thenReturn("ally"); - relations.when(() -> RelationManager.sameRealm(s1, ally)).thenReturn(true); - factions.when(() -> FactionManager.getByString("ally")).thenReturn(ally); - - WarCommitmentService.commitAllParticipants(war); - int levyRowsBefore = countLevyRows(22); - - war.getAttackers().getMainParticipants().get(0).getAllies().put(ally, true); - WarCommitmentService.commitFaction(war, ally); - WarCommitmentService.snapshotLevyForFighter(war, ally); - - assertLevyRow(22, "ally", "s1", 3); - assertEquals(levyRowsBefore + 1, countLevyRows(22)); - } - } - - @Test - void bottomVassalBreak_removesSubtree() { - Faction m = fighter("m", Map.of("professional", 1)); - Faction v = fighter("v", Map.of("professional", 1)); - Faction v2 = levySource("v2", 8); - Faction v3 = levySource("v3", 4); - Faction d = fighter("d", Map.of("professional", 1)); - - War war = baseWar(23); - WarManager.get().add(war); - war.getAttackers().getMainParticipants().get(0).getSubjects().add(v); - - try (MockedStatic relations = mockStatic(RelationManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - stubLevyGraph(relations, factions, m, v, v2, v3); - WarCommitmentService.snapshotLevyForSide(war, war.getAttackers()); - - relations.when(() -> RelationManager.getSubjects(v2)).thenReturn(List.of(v3)); - relations.when(() -> RelationManager.getSubjects(v3)).thenReturn(List.of()); - - WarCommitmentService.removeLevySubtree(v2); - - assertNoLevyRow(23, "v", "v2"); - assertNoLevyRow(23, "v", "v3"); - } - } - - @Test - void newVassal_noAdd() { - Faction m = fighter("m", Map.of("professional", 1)); - Faction d = fighter("d", Map.of("professional", 1)); - War war = baseWar(24); - - WarCommitmentService.commitAllParticipants(war); - int before = WarManager.getCommitmentsForWar(24).size(); - - Faction s3 = levySource("s3", 10); - try (MockedStatic relations = mockStatic(RelationManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - relations.when(() -> RelationManager.getSubjects(m)).thenReturn(List.of(s3)); - relations.when(() -> RelationManager.getSubjects(s3)).thenReturn(List.of()); - relations.when(() -> RelationManager.getOverlord(s3)).thenReturn("m"); - relations.when(() -> RelationManager.sameRealm(s3, m)).thenReturn(true); - factions.when(() -> FactionManager.getByString("m")).thenReturn(m); - - WarCommitmentService.snapshotLevyForSide(war, war.getAttackers()); - assertEquals(before, WarManager.getCommitmentsForWar(24).size()); - } - } - - @Test - void transferSubject_removeOnly() { - Faction m = fighter("m", Map.of("professional", 1)); - Faction v = fighter("v", Map.of("professional", 1)); - Faction v2 = levySource("v2", 8); - Faction receiver = fighter("receiver", Map.of("professional", 1)); - Faction d = fighter("d", Map.of("professional", 1)); - - War war = baseWar(25); - WarManager.get().add(war); - war.getAttackers().getMainParticipants().get(0).getSubjects().add(v); - - try (MockedStatic relations = mockStatic(RelationManager.class); - MockedStatic factions = mockStatic(FactionManager.class)) { - stubLevyGraph(relations, factions, m, v, v2, null); - WarCommitmentService.snapshotLevyForSide(war, war.getAttackers()); - assertLevyRow(25, "v", "v2", 4); - - relations.when(() -> RelationManager.getSubjects(v2)).thenReturn(List.of()); - WarCommitmentService.removeLevySubtree(v2); - - assertNoLevyRow(25, "v", "v2"); - WarCommitmentService.snapshotLevyForSide(war, war.getAttackers()); - assertNoLevyRow(25, "receiver", "v2"); - } - } - - @Test - void endWar_clearsCommitments() { - War war = baseWar(11); - WarManager.get().add(war); - Faction faction = fighter("attacker", Map.of("infantry", 5)); - - WarCommitmentService.commitFaction(war, faction); - assertEquals(1, WarManager.getCommitmentsForWar(11).size()); - - try (MockedStatic bukkit = mockStatic(Bukkit.class)) { - bukkit.when(() -> Bukkit.getPlayerExact(org.mockito.ArgumentMatchers.anyString())).thenReturn(null); - WarManager.endWar(war); - } - assertTrue(WarManager.getCommitmentsForWar(11).isEmpty()); - } - - private static War baseWar(int id) { - Faction attacker = mock(Faction.class); - Faction defender = mock(Faction.class); - when(attacker.getId()).thenReturn("attacker"); - when(defender.getId()).thenReturn("defender"); - when(attacker.getName()).thenReturn("Attacker"); - when(defender.getName()).thenReturn("Defender"); - when(attacker.getMembers()).thenReturn(List.of("A")); - when(defender.getMembers()).thenReturn(List.of("B")); - stubOwnMilitary(attacker, Map.of("professional", 0)); - stubOwnMilitary(defender, Map.of("professional", 0)); - return new War(id, attacker, defender); - } - - private static Faction fighter(String id, Map slots) { - Faction faction = mock(Faction.class); - when(faction.getId()).thenReturn(id); - when(faction.getMembers()).thenReturn(List.of("a", "b", "c", "d", "e", "f", "g", "h", "i", "j")); - stubLevyModifier(faction); - stubOwnMilitary(faction, slots); - return faction; - } - - private static Faction levySource(String id, int slots) { - return fighter(id, Map.of("infantry", slots)); - } - - private static void stubOwnMilitary(Faction faction, Map slots) { - Military military = mock(Military.class); - List regiments = new ArrayList<>(); - for (Map.Entry entry : slots.entrySet()) { - Regiment regiment = mock(Regiment.class); - when(regiment.isLevy()).thenReturn(false); - when(regiment.isEquipment()).thenReturn("artillery".equalsIgnoreCase(entry.getKey())); - when(regiment.getId()).thenReturn(entry.getKey()); - when(regiment.getCurrentSlots()).thenReturn(entry.getValue()); - regiments.add(regiment); - } - when(military.getRegiments()).thenReturn(regiments); - when(faction.getMilitary()).thenReturn(military); - } - - private static void stubLevyModifier(Faction faction) { - FactionModifier levy = mock(FactionModifier.class); - when(levy.getAmount()).thenReturn(50.0); - when(faction.getModifier(FactionModifiers.LEVY)).thenReturn(levy); - } - - private static void stubLevyGraph( - MockedStatic relations, - MockedStatic factions, - Faction m, - Faction v, - Faction v2, - Faction v3) { - relations.when(() -> RelationManager.getSubjects(m)).thenReturn(v2 == null && v3 == null ? List.of(v) : List.of(v)); - relations.when(() -> RelationManager.getSubjects(v)).thenReturn(v2 == null ? List.of() : List.of(v2)); - if (v2 != null) { - relations.when(() -> RelationManager.getSubjects(v2)).thenReturn(v3 == null ? List.of() : List.of(v3)); - } - if (v3 != null) { - relations.when(() -> RelationManager.getSubjects(v3)).thenReturn(List.of()); - } - relations.when(() -> RelationManager.getOverlord(v)).thenReturn("m"); - relations.when(() -> RelationManager.getOverlord(m)).thenReturn(null); - if (v2 != null) { - relations.when(() -> RelationManager.getOverlord(v2)).thenReturn("v"); - relations.when(() -> RelationManager.sameRealm(v2, v)).thenReturn(true); - factions.when(() -> FactionManager.getByString("v")).thenReturn(v); - } - if (v3 != null) { - relations.when(() -> RelationManager.getOverlord(v3)).thenReturn("v2"); - relations.when(() -> RelationManager.sameRealm(v3, v)).thenReturn(true); - factions.when(() -> FactionManager.getByString("v2")).thenReturn(v2); - } - factions.when(() -> FactionManager.getByString("m")).thenReturn(m); - } - - private static void assertLevyRow(int warId, String holderId, String sourceId, int count) { - assertTrue(WarManager.getCommitmentsForWar(warId).stream() - .anyMatch(c -> c.isLevyRow() - && holderId.equals(c.factionId()) - && sourceId.equals(c.sourceFactionId()) - && count == c.count()), - "Expected levy row " + holderId + " <- " + sourceId + " = " + count); - } - - private static void assertNoLevyRow(int warId, String holderId, String sourceId) { - assertTrue(WarManager.getCommitmentsForWar(warId).stream() - .noneMatch(c -> c.isLevyRow() - && holderId.equals(c.factionId()) - && sourceId.equals(c.sourceFactionId()))); - } - - private static int countLevyRows(int warId) { - return (int) WarManager.getCommitmentsForWar(warId).stream().filter(WarCommitment::isLevyRow).count(); - } -} diff --git a/src/test/java/net/tfminecraft/simplefactions/war/core/WarDataRoundTripTest.java b/src/test/java/net/tfminecraft/simplefactions/war/core/WarDataRoundTripTest.java index a7c5b181..e79fef9a 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/core/WarDataRoundTripTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/core/WarDataRoundTripTest.java @@ -11,8 +11,10 @@ import com.google.gson.Gson; import net.tfminecraft.simplefactions.database.CivilWarMemberMoveData; -import net.tfminecraft.simplefactions.database.CommitmentData; import net.tfminecraft.simplefactions.database.WarData; +import net.tfminecraft.simplefactions.war.wounds.ForceKind; +import net.tfminecraft.simplefactions.war.wounds.TestForces; +import net.tfminecraft.simplefactions.war.wounds.WarLedger; import net.tfminecraft.simplefactions.war.campaign.progression.CampaignSideService.CampaignSideRole; import net.tfminecraft.simplefactions.war.campaign.ui.CampaignPushTarget; import net.tfminecraft.simplefactions.war.campaign.progression.postbattle.CampaignPostBattleChoiceService.PostBattleChoicePhase; @@ -51,33 +53,37 @@ void warData_gsonRoundTrip_preservesV3Fields() { } @Test - void warData_gsonRoundTrip_preservesCommitments() { + void warData_gsonRoundTrip_preservesTheBattleLedger() { + WarLedger ledger = new WarLedger(); + ledger.addBattle("battle-1", "fought", "2026-08-21T10:00:00Z", 159, + new WarLedger.SideRecord(12, List.of( + TestForces.faction("faction_a", TestForces.militia(2, 1), TestForces.paid("professional", 3)), + TestForces.mercenary("guild_m", 2))), + new WarLedger.SideRecord(4, TestForces.regiments("faction_b", 2))); + ledger.addAdjustment(new WarLedger.Adjustment("faction_a", "professional", -3, "Staff", "bug", "2026-08-21T11:00:00Z")); + ledger.addNote(new WarLedger.Note("2026-08-21T11:00:00Z", "Staff", "fixed")); WarData original = new WarData(); original.id = 9; - CommitmentData ownRow = new CommitmentData(); - ownRow.factionId = "faction_a"; - ownRow.regimentId = "professional"; - ownRow.count = 7; - ownRow.committedAt = "2026-08-21T10:00:00Z"; + original.ledger = ledger.toData(); - CommitmentData levyRow = new CommitmentData(); - levyRow.factionId = "faction_a"; - levyRow.sourceFactionId = "subject_a"; - levyRow.regimentId = "levy"; - levyRow.count = 4; - levyRow.committedAt = "2026-08-21T10:00:00Z"; + WarData restored = GSON.fromJson(GSON.toJson(original), WarData.class); + WarLedger reloaded = WarLedger.fromData(restored.ledger); - original.commitments = new java.util.ArrayList<>(List.of(ownRow, levyRow)); + assertEquals(ledger.battles(), reloaded.battles()); + assertEquals(ledger.adjustments(), reloaded.adjustments()); + assertEquals(ledger.notes(), reloaded.notes()); + assertEquals(ledger.factionWoundLives(), reloaded.factionWoundLives()); + assertEquals(ForceKind.MERCENARY, reloaded.battle(1).attacker().forces().get(1).kind()); + } - String json = GSON.toJson(original); - WarData restored = GSON.fromJson(json, WarData.class); + @Test + void warData_withoutALedger_loadsAnEmptyOne() { + WarData restored = GSON.fromJson("{\"id\":9,\"commitments\":[{\"factionId\":\"a\"}]}", WarData.class); + + WarLedger ledger = WarLedger.fromData(restored.ledger); - assertNotNull(restored.commitments); - assertEquals(2, restored.commitments.size()); - assertEquals("professional", restored.commitments.get(0).regimentId); - assertEquals(Integer.valueOf(7), Integer.valueOf(restored.commitments.get(0).count)); - assertEquals("subject_a", restored.commitments.get(1).sourceFactionId); - assertEquals(Integer.valueOf(4), Integer.valueOf(restored.commitments.get(1).count)); + assertTrue(ledger.battles().isEmpty()); + assertTrue(ledger.factionWoundLives().isEmpty()); } @Test diff --git a/src/test/java/net/tfminecraft/simplefactions/war/core/WarDebugFormatterTest.java b/src/test/java/net/tfminecraft/simplefactions/war/core/WarDebugFormatterTest.java index e76a4219..f17376c3 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/core/WarDebugFormatterTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/core/WarDebugFormatterTest.java @@ -15,14 +15,13 @@ import java.util.Set; import java.util.UUID; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import net.tfminecraft.simplefactions.objects.Faction; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.core.WarCommitment; import net.tfminecraft.simplefactions.war.core.WarDebugFormatter; +import net.tfminecraft.simplefactions.war.wounds.TestForces; +import net.tfminecraft.simplefactions.war.wounds.WarLedger; import net.tfminecraft.simplefactions.war.enums.BattleSchedulePhase; import net.tfminecraft.simplefactions.war.enums.CampaignBattleKind; import net.tfminecraft.simplefactions.war.enums.CampaignPhase; @@ -33,13 +32,8 @@ class WarDebugFormatterTest { - @AfterEach - void tearDown() { - WarCommitmentService.clearCommitments(3); - } - @Test - void formatStatusLines_includesCommitmentRows() { + void formatStatusLines_includesTheLedger() { Faction attacker = mock(Faction.class); Faction defender = mock(Faction.class); when(attacker.getId()).thenReturn("faction_a"); @@ -48,34 +42,14 @@ void formatStatusLines_includesCommitmentRows() { when(defender.getName()).thenReturn("Defender"); War war = new War(3, attacker, defender); - seedCommitment(new WarCommitment( - 3, "faction_a", null, "militia", 4, Instant.parse("2026-08-21T10:00:00Z"))); - seedCommitment(new WarCommitment( - 3, "faction_a", "subject_a", WarCommitment.LEVY_REGIMENT_ID, 6, Instant.parse("2026-08-21T10:00:00Z"))); + war.getLedger().addBattle("b", "fought", "2026-08-21T10:00:00Z", 1, + new WarLedger.SideRecord(10, TestForces.regiments("faction_a", 4)), + new WarLedger.SideRecord(0, List.of())); String json = WarDebugFormatter.formatStatusLines(war).get(0); - assertTrue(json.contains("\"commitments\":2")); - assertTrue(json.contains("\"commitmentRows\"")); - assertTrue(json.contains("\"factionId\":\"faction_a\"")); - assertTrue(json.contains("\"regimentId\":\"militia\"")); - assertTrue(json.contains("\"count\":4")); - assertTrue(json.contains("\"sourceFactionId\":\"subject_a\"")); - assertTrue(json.contains("\"regimentId\":\"levy\"")); - assertTrue(json.contains("\"count\":6")); - } - - private static void seedCommitment(WarCommitment row) { - try { - java.lang.reflect.Field field = WarCommitmentService.class.getDeclaredField("commitmentsByWar"); - field.setAccessible(true); - @SuppressWarnings("unchecked") - Map> store = - (Map>) field.get(null); - store.computeIfAbsent(row.warId(), ignored -> new ArrayList<>()).add(row); - } catch (ReflectiveOperationException e) { - throw new RuntimeException(e); - } + assertTrue(json.contains("\"ledgerBattles\":1")); + assertTrue(json.contains("\"factionWoundLives\":{\"faction_a\":{\"professional\":10}}"), json); } @Test diff --git a/src/test/java/net/tfminecraft/simplefactions/war/core/WarDevModeTest.java b/src/test/java/net/tfminecraft/simplefactions/war/core/WarDevModeTest.java index 3661f3de..8fd687b8 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/core/WarDevModeTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/core/WarDevModeTest.java @@ -1,5 +1,7 @@ package net.tfminecraft.simplefactions.war.core; +import net.tfminecraft.simplefactions.war.battle.military.BattleForces; +import net.tfminecraft.simplefactions.war.wounds.TestForces; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -254,8 +256,8 @@ void setEnabled_on_refillsCampaignSideWarbandsAfterRestart() { MockedStatic wars = mockStatic(WarManager.class); MockedStatic factions = mockStatic(net.tfminecraft.simplefactions.managers.FactionManager.class); - MockedStatic pool = - mockStatic(net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.class)) { + MockedStatic pool = + mockStatic(BattleForces.class)) { mockBossBar(bukkit); Battle battle = BattleFactory.createBlank(BattleType.FIELD, "campaign_w1"); battle.setWarId(1); @@ -267,8 +269,8 @@ void setEnabled_on_refillsCampaignSideWarbandsAfterRestart() { .thenReturn(attacker); factions.when(() -> net.tfminecraft.simplefactions.managers.FactionManager.getByString("def")) .thenReturn(defender); - pool.when(() -> net.tfminecraft.simplefactions.war.battle.military.BattlePoolService.totalCommittedRegiments( - eq(war), eq(20), any())).thenReturn(5); + pool.when(() -> BattleForces.build(eq(war), any(), any(), any(), any())) + .thenReturn(TestForces.regiments(5)); CampaignBattleRosterService.enrollWarbands(war, battle); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/core/WarPersistenceCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/war/core/WarPersistenceCoverageTest.java index 46860b50..d90140a8 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/core/WarPersistenceCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/core/WarPersistenceCoverageTest.java @@ -33,31 +33,17 @@ class WarPersistenceCoverageTest { private PersistenceFilesFixture files; - private java.lang.reflect.Field commitmentStore; - private Object previousCommitments; private FactionDomainFixture fixture; @BeforeEach void setup() throws Exception { files = new PersistenceFilesFixture(); fixture = new FactionDomainFixture(); - commitmentStore = - net.tfminecraft.simplefactions.war.commitment.WarCommitmentService.class.getDeclaredField( - "commitmentsByWar"); - commitmentStore.setAccessible(true); - previousCommitments = new LinkedHashMap<>((Map) commitmentStore.get(null)); - ((Map) commitmentStore.get(null)).clear(); } @AfterEach void close() throws Exception { try { - if (commitmentStore != null) { - @SuppressWarnings("unchecked") - Map store = (Map) commitmentStore.get(null); - store.clear(); - store.putAll((Map) previousCommitments); - } if (fixture != null) fixture.close(); } finally { if (files != null) files.close(); @@ -65,27 +51,23 @@ void close() throws Exception { } @Test - void theFirstAllocatedWarPreservesItsRegimentCommitmentsAcrossDiskReload() throws Exception { + void theFirstAllocatedWarPreservesItsBattleLedgerAcrossDiskReload() throws Exception { fixture.regiment("guard", false, 3, 0); Faction attacker = fixture.saved("attacker", "Alice"); Faction defender = fixture.saved("defender", "Bob"); War war = new War(net.tfminecraft.simplefactions.managers.WarManager.newId(), attacker, defender); - var expected = - net.tfminecraft.simplefactions.war.commitment.WarCommitmentService.commitFaction( - war, attacker); - assertFalse(expected.isEmpty()); + war.getLedger().addBattle("b", "fought", "2026-08-21T10:00:00Z", 1, + new net.tfminecraft.simplefactions.war.wounds.WarLedger.SideRecord( + 7, net.tfminecraft.simplefactions.war.wounds.TestForces.regiments("attacker", 3)), + new net.tfminecraft.simplefactions.war.wounds.WarLedger.SideRecord(0, List.of())); var path = files.write("Wars/war_" + war.getId() + ".json", "{}"); JsonUtil.writeJson(path.toFile(), WarMapper.toData(war)); - net.tfminecraft.simplefactions.war.commitment.WarCommitmentService.clearCommitments( - war.getId()); List loaded = new Database().loadWars(); assertEquals(1, loaded.size()); assertEquals(war.getId(), loaded.getFirst().getId()); - assertEquals( - expected, - net.tfminecraft.simplefactions.war.commitment.WarCommitmentService.getCommitmentsForWar( - war.getId())); + assertEquals(war.getLedger().battles(), loaded.getFirst().getLedger().battles()); + assertFalse(loaded.getFirst().getLedger().factionWoundLives().isEmpty()); } @Test diff --git a/src/test/java/net/tfminecraft/simplefactions/war/history/WarHistoryEventsTest.java b/src/test/java/net/tfminecraft/simplefactions/war/history/WarHistoryEventsTest.java index a82b2263..5eccf245 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/history/WarHistoryEventsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/history/WarHistoryEventsTest.java @@ -58,8 +58,6 @@ import net.tfminecraft.simplefactions.war.core.CallToArmsService; import net.tfminecraft.simplefactions.war.core.Participant; import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.core.WarCommitment; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; import net.tfminecraft.simplefactions.war.enums.CampaignBattleKind; import net.tfminecraft.simplefactions.war.enums.CampaignPhase; import net.tfminecraft.simplefactions.war.enums.WarEndReason; @@ -72,7 +70,6 @@ class WarHistoryEventsTest { private FactionDomainFixture fixture; private PersistenceFilesFixture files; - private Map> previousCommitments; private RelationType alliance; private WarHistoryOutbox outbox; private Logger logger; @@ -90,8 +87,6 @@ void setup() throws Exception { BattleStatsArchive.resetForTests(); when(org.bukkit.Bukkit.createBossBar(anyString(), any(org.bukkit.boss.BarColor.class), any(org.bukkit.boss.BarStyle.class))).thenAnswer(call -> mock(org.bukkit.boss.BossBar.class)); - previousCommitments = new LinkedHashMap<>(commitments()); - commitments().clear(); previousTruceDays = Cache.warTruceDays; alliance = fixture.relationType("ally", Map.of("link", "ally")); attacker = fixture.saved("attacker", "Alice"); @@ -112,8 +107,6 @@ void close() throws Exception { WarHistory.resetForTests(); BattleStatsArchive.resetForTests(); CampaignClock.reset(); - commitments().clear(); - commitments().putAll(previousCommitments); Cache.warTruceDays = previousTruceDays; fixture.close(); } finally { @@ -121,12 +114,6 @@ void close() throws Exception { } } - @SuppressWarnings("unchecked") - private static Map> commitments() throws Exception { - Field field = WarCommitmentService.class.getDeclaredField("commitmentsByWar"); - field.setAccessible(true); - return (Map>) field.get(null); - } private void ally(Faction first, Faction second) { first.setRelation(second, new Relation(alliance, RelationLoader.getDefaultAttitude())); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/resolution/PillageMissingBankRegressionTest.java b/src/test/java/net/tfminecraft/simplefactions/war/resolution/PillageMissingBankRegressionTest.java index 85c2600f..55c82ecf 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/resolution/PillageMissingBankRegressionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/resolution/PillageMissingBankRegressionTest.java @@ -30,9 +30,7 @@ import net.tfminecraft.simplefactions.war.battle.engine.core.BattleSide; import net.tfminecraft.simplefactions.war.battle.enums.BattleType; import net.tfminecraft.simplefactions.war.battle.persistence.BattlePersistenceService; -import net.tfminecraft.simplefactions.war.commitment.WarCommitmentService; import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.core.WarCommitment; import net.tfminecraft.simplefactions.war.enums.WarEndReason; import net.tfminecraft.simplefactions.war.enums.WarGoalType; import net.tfminecraft.simplefactions.war.enums.WarType; @@ -57,7 +55,6 @@ class PillageMissingBankRegressionTest { private ProvinceManager provinces; private Province targetProvince; private final Map previousQueues = new LinkedHashMap<>(); - private List previousCommitments; private List previousBattles; private Map previousEditors; private int previousLootDays, previousHitDays, previousEmptyGrace; @@ -78,8 +75,6 @@ void setup() throws Exception { previousQueues.put(field, field.get(null)); field.set(null, name.equals("loans") ? new ArrayList<>() : new HashMap<>()); } - previousCommitments = new ArrayList<>(WarCommitmentService.getCommitmentsForWar(WAR_ID)); - WarCommitmentService.clearCommitments(WAR_ID); previousBattles = new ArrayList<>(BattleManager.get()); BattleManager.get().clear(); previousEditors = new HashMap<>(BattleManager.currentBattle); @@ -115,7 +110,6 @@ void close() throws Exception { BattleManager.currentBattle.clear(); BattleManager.currentBattle.putAll(previousEditors); } - WarCommitmentService.restoreCommitments(WAR_ID, previousCommitments); for (var entry : previousQueues.entrySet()) entry.getKey().set(null, entry.getValue()); Cache.pillageLootDays = previousLootDays; Cache.pillageTradeHitDays = previousHitDays; @@ -155,13 +149,11 @@ void persistedBankChoiceCannotInterruptWinningPillageTeardown(boolean bankRemove original.setTargetSettlementId("Victim"); original.setObjectiveProvinceId(2); WarManager.addWar(original); - WarCommitmentService.commitAllParticipants(original); database.saveWar(original); assertTrue(Files.exists(disk.root.resolve("Wars/war_" + WAR_ID + ".json"))); FactionManager.factions.clear(); WarManager.get().clear(); - WarCommitmentService.clearCommitments(WAR_ID); database.loadFactions(); WarManager.start(); War restored = WarManager.getById(WAR_ID); @@ -174,7 +166,7 @@ void persistedBankChoiceCannotInterruptWinningPillageTeardown(boolean bankRemove assertNotNull(loadedMerchant); if (bankRemoved) assertNull(loadedAttacker.getBank()); else assertEquals(100.0, loadedAttacker.getBank().getWealth()); - assertFalse(WarCommitmentService.getCommitmentsForWar(WAR_ID).isEmpty()); + assertTrue(restored.getLedger().battles().isEmpty()); assertEquals(3, loadedAttacker.getMilitary().getRegiment("guard").getCurrentSlots()); assertEquals(3, loadedDefender.getMilitary().getRegiment("guard").getCurrentSlots()); targetProvince.setData(loadedMerchant.getId(), new ProvinceDataEntry(loadedMerchant, 10, 0)); @@ -200,7 +192,7 @@ void persistedBankChoiceCannotInterruptWinningPillageTeardown(boolean bankRemove assertFalse(restored.isActive()); assertEquals(WarEndReason.ATTACKER_VICTORY, restored.getEndReason()); assertNull(WarManager.getById(WAR_ID)); - assertTrue(WarCommitmentService.getCommitmentsForWar(WAR_ID).isEmpty()); + assertTrue(restored.getLedger().isSettled()); assertFalse(battle.hasStarted()); assertNull(BattleManager.getByString(battle.getId())); assertFalse(Files.exists(disk.root.resolve("Wars/war_" + WAR_ID + ".json"))); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/wounds/CompanyWoundsTest.java b/src/test/java/net/tfminecraft/simplefactions/war/wounds/CompanyWoundsTest.java new file mode 100644 index 00000000..ade7f694 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/war/wounds/CompanyWoundsTest.java @@ -0,0 +1,105 @@ +package net.tfminecraft.simplefactions.war.wounds; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.time.Duration; +import java.time.Instant; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.Cache; + +class CompanyWoundsTest { + private static final Instant T0 = Instant.parse("2026-11-11T12:00:00Z"); + private double percent; + private double hours; + private int deaths; + + @BeforeEach + void setUp() { + percent = Cache.warMercenaryHealPercent; + hours = Cache.warMercenaryHealHours; + deaths = Cache.warBattleDeathsPerRegimentLoss; + Cache.warMercenaryHealPercent = 25; + Cache.warMercenaryHealHours = 6; + Cache.warBattleDeathsPerRegimentLoss = 5; + } + + @AfterEach + void tearDown() { + Cache.warMercenaryHealPercent = percent; + Cache.warMercenaryHealHours = hours; + Cache.warBattleDeathsPerRegimentLoss = deaths; + } + + @Test + void woundsHealAPercentOfTheCompanyEveryPeriod() { + CompanyWounds wounds = new CompanyWounds(); + wounds.add(20, T0, 4); + + assertEquals(4, wounds.woundedSlots(T0, 4)); + assertEquals(0, wounds.healthySlots(T0, 4)); + // 25% of 4 slots is 1 slot, 5 lives, every 6 hours. + assertEquals(20, wounds.lives(T0.plus(Duration.ofHours(5)), 4)); + assertEquals(15, wounds.lives(T0.plus(Duration.ofHours(6)), 4)); + assertEquals(5, wounds.lives(T0.plus(Duration.ofHours(19)), 4)); + assertEquals(T0.plus(Duration.ofHours(18)), wounds.healFrom()); + assertEquals(0, wounds.lives(T0.plus(Duration.ofHours(24)), 4)); + assertNull(wounds.healFrom()); + assertEquals(4, wounds.healthySlots(T0.plus(Duration.ofHours(24)), 4)); + } + + @Test + void woundsStayWithinTheCompanyAndCanBeTakenOff() { + CompanyWounds wounds = new CompanyWounds(); + wounds.add(100, T0, 3); + assertEquals(15, wounds.storedLives()); + assertEquals(T0, wounds.healFrom()); + wounds.add(-4, T0.plus(Duration.ofHours(1)), 3); + assertEquals(11, wounds.storedLives()); + assertEquals(T0, wounds.healFrom(), "healing keeps counting from the first wound"); + wounds.add(-50, T0, 3); + assertEquals(0, wounds.storedLives()); + assertNull(wounds.healFrom()); + wounds.set(7, T0, 0); + assertEquals(0, wounds.storedLives()); + assertEquals(0, wounds.woundedSlots(T0, 3)); + } + + @Test + void freshWoundsRestartTheHealingPeriod() { + CompanyWounds wounds = new CompanyWounds(); + wounds.add(5, T0, 4); + wounds.add(10, T0.plus(Duration.ofHours(5)), 4); + + assertEquals(T0.plus(Duration.ofHours(5)), wounds.healFrom()); + // Nothing heals an hour after the new wounds; a full period after them, one slot does. + assertEquals(15, wounds.lives(T0.plus(Duration.ofHours(6)), 4)); + assertEquals(10, wounds.lives(T0.plus(Duration.ofHours(11)), 4)); + } + + @Test + void nothingHealsWithoutAClockOrAPercent() { + CompanyWounds wounds = new CompanyWounds(10, T0); + assertEquals(10, wounds.lives(null, 4)); + Cache.warMercenaryHealPercent = 0; + assertEquals(10, wounds.lives(T0.plus(Duration.ofDays(30)), 4)); + Cache.warBattleDeathsPerRegimentLoss = 0; + assertEquals(2, wounds.woundedSlots(T0, 4), "an unset rate counts 5 lives a slot"); + assertNull(new CompanyWounds(0, T0).healFrom()); + assertEquals(0, new CompanyWounds(-3, null).storedLives()); + CompanyWounds lost = new CompanyWounds(4, null); + lost.add(1, T0, 4); + assertEquals(T0, lost.healFrom(), "a wound with no start time starts healing now"); + } + + @Test + void aTinyCompanyStillHealsALifeAPeriod() { + Cache.warMercenaryHealPercent = 1; + CompanyWounds wounds = new CompanyWounds(3, T0); + assertEquals(1, wounds.lives(T0.plus(Duration.ofHours(12)), 1)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/war/wounds/TestForces.java b/src/test/java/net/tfminecraft/simplefactions/war/wounds/TestForces.java new file mode 100644 index 00000000..7d89f69f --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/war/wounds/TestForces.java @@ -0,0 +1,48 @@ +package net.tfminecraft.simplefactions.war.wounds; + +import java.util.List; + +/** Ready-made battle forces for tests that only need a side to bring some slots. */ +public final class TestForces { + public static final int LIVES_PER_SLOT = 5; + + private TestForces() { + } + + /** One faction's paid professional slots, or no force at all for 0. */ + public static List regiments(int slots) { + return regiments("test", slots); + } + + public static List regiments(String factionId, int slots) { + if (slots <= 0) { + return List.of(); + } + return List.of(faction(factionId, paid("professional", slots))); + } + + public static BattleForce faction(String factionId, ForcePart... parts) { + return new BattleForce(ForceKind.REGIMENTS, factionId, LIVES_PER_SLOT, List.of(parts)); + } + + public static BattleForce levy(String factionId, ForcePart... parts) { + return new BattleForce(ForceKind.LEVY, factionId, LIVES_PER_SLOT, List.of(parts)); + } + + public static BattleForce mercenary(String guildId, int slots) { + return new BattleForce(ForceKind.MERCENARY, guildId, LIVES_PER_SLOT, + List.of(new ForcePart("mercenary", false, 0, slots))); + } + + public static ForcePart paid(String regimentId, int slots) { + return new ForcePart(regimentId, false, 0, slots); + } + + public static ForcePart law(String regimentId, int slots) { + return new ForcePart(regimentId, false, slots, 0); + } + + public static ForcePart militia(int lawSlots, int paidSlots) { + return new ForcePart("militia", true, lawSlots, paidSlots); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/war/wounds/WarArmyAdminTest.java b/src/test/java/net/tfminecraft/simplefactions/war/wounds/WarArmyAdminTest.java new file mode 100644 index 00000000..077dc785 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/war/wounds/WarArmyAdminTest.java @@ -0,0 +1,168 @@ +package net.tfminecraft.simplefactions.war.wounds; + +import static net.tfminecraft.simplefactions.war.wounds.TestForces.faction; +import static net.tfminecraft.simplefactions.war.wounds.TestForces.mercenary; +import static net.tfminecraft.simplefactions.war.wounds.TestForces.paid; +import static net.tfminecraft.simplefactions.war.wounds.WoundFixture.regiment; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.handler.GuildHandler; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.enums.WarEndReason; + +class WarArmyAdminTest { + private WoundFixture fixture; + private Faction atk; + private War war; + private MercenaryCompany company; + + @BeforeEach + void setUp() { + fixture = new WoundFixture(); + Regiment levy = regiment("levy", 3, 0); + when(levy.isLevy()).thenReturn(true); + atk = fixture.faction("atk", regiment("professional", 8, 2), levy); + war = fixture.war(1, atk, fixture.faction("def")); + company = mock(MercenaryCompany.class); + Guild guild = mock(Guild.class); + when(guild.getId()).thenReturn("guild"); + when(guild.getCompany()).thenReturn(company); + when(company.getGuild()).thenReturn(guild); + when(company.getName()).thenReturn("Blades"); + when(company.getSlots()).thenReturn(4); + when(company.getWounds()).thenReturn(new CompanyWounds()); + fixture.guilds.put("guild", guild); + GuildHandler guilds = mock(GuildHandler.class); + Guild plain = mock(Guild.class); + when(guilds.getGuilds()).thenReturn(List.of(guild, plain)); + when(atk.getGuildHandler()).thenReturn(guilds); + WarWounds.recordBattle(war, "b1", "fought", 7, + new WarLedger.SideRecord(20, List.of(faction("atk", paid("professional", 6)), mercenary("guild", 2))), + new WarLedger.SideRecord(3, List.of())); + } + + @AfterEach + void tearDown() { + fixture.close(); + } + + @Test + void lossesListsEveryBattleAndWhatTheWarsEndWillTake() { + war.getLedger().addAdjustment(new WarLedger.Adjustment("atk", "professional", 5, "Mod", null, "t")); + + assertEquals(List.of("§6War 1: 1 recorded battles", + "§e#1 §7fought, province 7: attacker 20 deaths of 40 lives (10 mercenary); defender 3 deaths of 0 lives", + "§8Adjusted atk professional by 5 lives (Mod)", + "§6At war end:", + "§7- atk professional: 20 lives = 4 slots, loses 4"), + WarArmyAdmin.losses(args("losses", "1"), "Mod")); + fixture.war(2, atk, fixture.faction("x")); + assertEquals(List.of("§6War 2: 0 recorded battles", "§7At war end: no regiment slots are lost."), + WarArmyAdmin.losses(args("losses", "2"), "Mod")); + } + + @Test + void lossesFixesABattlesDeathsWithAReason() { + List lines = WarArmyAdmin.losses(args("losses", "1", "1", "ATTACKER", "SET", "10", "a", "glitch"), "Mod"); + + assertEquals("§aWar 1: battle 1 attacker deaths 20 -> 10.", lines.get(0)); + assertEquals("battle 1 attacker deaths 20 -> 10 (a glitch)", war.getLedger().notes().get(0).text()); + } + + @Test + void lossesExplainsBadInput() { + assertEquals(List.of(WarArmyAdmin.LOSSES_USAGE), WarArmyAdmin.losses(args("losses"), "Mod")); + assertEquals(List.of("§cNo active war with id 9"), WarArmyAdmin.losses(args("losses", "9"), "Mod")); + assertEquals(List.of("§cNo active war with id x"), WarArmyAdmin.losses(args("losses", "x"), "Mod")); + assertEquals(List.of(WarArmyAdmin.LOSSES_USAGE), WarArmyAdmin.losses(args("losses", "1", "1"), "Mod")); + assertEquals(List.of(WarArmyAdmin.LOSSES_USAGE), + WarArmyAdmin.losses(args("losses", "1", "one", "attacker", "set", "1"), "Mod")); + assertEquals(List.of(WarArmyAdmin.LOSSES_USAGE), + WarArmyAdmin.losses(args("losses", "1", "1", "attacker", "set", "lots"), "Mod")); + assertEquals(List.of(WarArmyAdmin.LOSSES_USAGE), + WarArmyAdmin.losses(args("losses", "1", "1", "middle", "set", "1"), "Mod")); + assertEquals(List.of("§cThe amount must be 0 or more."), + WarArmyAdmin.losses(args("losses", "1", "1", "defender", "set", "-1"), "Mod")); + war.end(WarEndReason.ADMIN_END); + assertEquals(List.of("§cNo active war with id 1"), WarArmyAdmin.losses(args("losses", "1"), "Mod")); + } + + @Test + void armyShowsSlotsWoundsPerWarAndCompanies() { + War settled = fixture.war(3, atk, fixture.faction("y")); + settled.getLedger().addAdjustment(new WarLedger.Adjustment("atk", "professional", 50, "Mod", null, "t")); + settled.getLedger().markSettled(); + + assertEquals(List.of("§6ATK §7(atk) army", + "§eprofessional§7: 8 slots (2 law, 6 paid), 3 wounded, §a5 can fight", + "§8 war 1: 15 wounded lives", + "§bCompany Blades §7(guild guild): 5 wounded lives, 1 of 4 slots wounded"), + WarArmyAdmin.army(args("army", "ATK"))); + assertEquals(List.of(WarArmyAdmin.ARMY_USAGE), WarArmyAdmin.army(args("army"))); + assertEquals(List.of("§cUnknown faction: nope"), WarArmyAdmin.army(args("army", "nope"))); + Faction bare = mock(Faction.class); + fixture.factions.put("bare", bare); + assertEquals(List.of("§cUnknown faction: bare"), WarArmyAdmin.army(args("army", "bare"))); + } + + @Test + void woundsAdjustsARegimentOrACompany() { + assertEquals("§aWar 1: added 10 wounded lives on atk professional.", + WarArmyAdmin.wounds(args("wounds", "1", "atk", "professional", "add", "10", "fix"), "Mod").get(0)); + assertEquals("§aWar 1: removed 5 wounded lives on atk professional.", + WarArmyAdmin.wounds(args("wounds", "1", "atk", "PROFESSIONAL", "REMOVE", "5"), "Mod").get(0)); + assertEquals(List.of("§bCompany Blades §7(guild guild): 5 wounded lives, 1 of 4 slots wounded"), + WarArmyAdmin.wounds(args("wounds", "company", "guild"), "Mod")); + assertEquals("§aCompany Blades: wounded lives 5 -> 0 (0 of 4 slots wounded).", + WarArmyAdmin.wounds(args("wounds", "company", "guild", "set", "0"), "Mod").get(0)); + } + + @Test + void woundsExplainsBadInput() { + String usage = WarArmyAdmin.WOUNDS_USAGE; + assertEquals(List.of("§cNo mercenary company in guild none"), WarArmyAdmin.wounds(args("wounds", "company", "none"), "Mod")); + assertEquals(List.of(usage), WarArmyAdmin.wounds(args("wounds", "company", "guild", "set"), "Mod")); + assertEquals(List.of(usage), WarArmyAdmin.wounds(args("wounds", "company", "guild", "add", "1"), "Mod")); + assertEquals(List.of(usage), WarArmyAdmin.wounds(args("wounds", "company", "guild", "set", "-1"), "Mod")); + assertEquals(List.of(usage), WarArmyAdmin.wounds(args("wounds", "1", "atk"), "Mod")); + assertEquals(List.of("§cNo active war with id 9"), + WarArmyAdmin.wounds(args("wounds", "9", "atk", "professional", "add", "1"), "Mod")); + assertEquals(List.of("§cUnknown faction regiment: nope professional"), + WarArmyAdmin.wounds(args("wounds", "1", "nope", "professional", "add", "1"), "Mod")); + assertEquals(List.of("§cUnknown faction regiment: atk ghost"), + WarArmyAdmin.wounds(args("wounds", "1", "atk", "ghost", "add", "1"), "Mod")); + assertEquals(List.of("§cUnknown faction regiment: atk levy"), + WarArmyAdmin.wounds(args("wounds", "1", "atk", "levy", "add", "1"), "Mod")); + Regiment artillery = regiment("artillery", 1, 0); + when(artillery.isEquipment()).thenReturn(true); + atk.getMilitary().getRegiments().add(artillery); + assertEquals(List.of("§cUnknown faction regiment: atk artillery"), + WarArmyAdmin.wounds(args("wounds", "1", "atk", "artillery", "add", "1"), "Mod")); + Faction bare = mock(Faction.class); + fixture.factions.put("bare", bare); + assertEquals(List.of("§cUnknown faction regiment: bare professional"), + WarArmyAdmin.wounds(args("wounds", "1", "bare", "professional", "add", "1"), "Mod")); + assertEquals(List.of(usage), WarArmyAdmin.wounds(args("wounds", "1", "atk", "professional", "set", "1"), "Mod")); + assertEquals(List.of(usage), WarArmyAdmin.wounds(args("wounds", "1", "atk", "professional", "add", "x"), "Mod")); + assertEquals(List.of(usage), WarArmyAdmin.wounds(args("wounds", "1", "atk", "professional", "add", "-2"), "Mod")); + } + + private static String[] args(String... rest) { + String[] args = new String[rest.length + 1]; + args[0] = "admin"; + System.arraycopy(rest, 0, args, 1, rest.length); + return args; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/war/wounds/WarLedgerTest.java b/src/test/java/net/tfminecraft/simplefactions/war/wounds/WarLedgerTest.java new file mode 100644 index 00000000..9bad54aa --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/war/wounds/WarLedgerTest.java @@ -0,0 +1,91 @@ +package net.tfminecraft.simplefactions.war.wounds; + +import static net.tfminecraft.simplefactions.war.wounds.TestForces.faction; +import static net.tfminecraft.simplefactions.war.wounds.TestForces.mercenary; +import static net.tfminecraft.simplefactions.war.wounds.TestForces.militia; +import static net.tfminecraft.simplefactions.war.wounds.TestForces.paid; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.database.JsonUtil; + +class WarLedgerTest { + @Test + void factionWoundsAreThePaidShareOfEveryBattleAndAdjustment() { + WarLedger ledger = new WarLedger(); + ledger.addBattle("b1", "fought", "t1", 7, + new WarLedger.SideRecord(30, List.of(faction("ATK", militia(6, 4)), mercenary("guild", 2))), + new WarLedger.SideRecord(10, List.of(faction("def", paid("professional", 5))))); + + // 30 attacker deaths: 25 on the faction (15 law, 10 paid), 5 on the mercenaries. + assertEquals(10, ledger.factionWoundLives("atk", "MILITIA")); + assertEquals(10, ledger.factionWoundLives("def", "professional")); + assertEquals(Map.of("atk", Map.of("militia", 10), "def", Map.of("professional", 10)), ledger.factionWoundLives()); + assertSame(ledger.factionWoundLives(), ledger.factionWoundLives(), "the totals are kept until something changes"); + + ledger.addAdjustment(new WarLedger.Adjustment("def", "professional", -15, "staff", null, "t2")); + ledger.addAdjustment(new WarLedger.Adjustment("atk", "garrison", 4, "staff", "bug", "t2")); + assertEquals(0, ledger.factionWoundLives("def", "professional")); + assertEquals(Map.of("atk", Map.of("militia", 10, "garrison", 4)), ledger.factionWoundLives()); + assertEquals(0, ledger.factionWoundLives(null, "x")); + assertEquals(0, ledger.factionWoundLives("x", null)); + assertEquals(0, ledger.factionWoundLives("nobody", "x")); + } + + @Test + void editingDeathsChangesOnlyThatSideAndKeepsTheBattle() { + WarLedger ledger = new WarLedger(); + ledger.addBattle(null, "autoresolve", "t1", null, + new WarLedger.SideRecord(10, List.of(faction("atk", paid("p", 4)))), + new WarLedger.SideRecord(5, List.of(faction("def", paid("p", 4))))); + ledger.addBattle("b2", "fought", "t2", 3, new WarLedger.SideRecord(0, null), new WarLedger.SideRecord(-4, null)); + + WarLedger.BattleEntry before = ledger.setDeaths(1, WarLedger.DEFENDER, 15); + + assertEquals(5, before.defender().deaths()); + assertEquals(15, ledger.battle(1).defender().deaths()); + assertEquals(10, ledger.battle(1).attacker().deaths()); + assertEquals(15, ledger.factionWoundLives("def", "p")); + ledger.setDeaths(1, WarLedger.ATTACKER, 0); + assertEquals(0, ledger.factionWoundLives("atk", "p")); + assertNull(ledger.setDeaths(9, WarLedger.ATTACKER, 1)); + assertNull(ledger.battle(9)); + assertEquals(2, ledger.battle(2).number()); + assertEquals(0, ledger.battle(2).defender().deaths()); + assertTrue(ledger.battle(2).attacker().forces().isEmpty()); + } + + @Test + void theLedgerSurvivesSavingAsJson() { + WarLedger ledger = new WarLedger(); + ledger.addBattle("b1", "fought", "t1", 7, + new WarLedger.SideRecord(30, List.of(faction("atk", militia(6, 4)), mercenary("guild", 2))), + new WarLedger.SideRecord(0, List.of())); + ledger.addAdjustment(new WarLedger.Adjustment("atk", "militia", 3, "staff", "why", "t2")); + ledger.addNote(new WarLedger.Note("t2", "staff", "added")); + + String json = JsonUtil.GSON.toJson(ledger.toData()); + WarLedger loaded = WarLedger.fromData(JsonUtil.GSON.fromJson(json, WarLedger.Data.class)); + + assertEquals(ledger.battles(), loaded.battles()); + assertEquals(ledger.adjustments(), loaded.adjustments()); + assertEquals(ledger.notes(), loaded.notes()); + assertEquals(13, loaded.factionWoundLives("atk", "militia")); + assertFalse(loaded.isSettled()); + loaded.markSettled(); + assertTrue(WarLedger.fromData(loaded.toData()).isSettled()); + assertTrue(WarLedger.fromData(null).battles().isEmpty()); + assertTrue(WarLedger.fromData(new WarLedger.Data(null, null, null, false)).notes().isEmpty()); + WarLedger partial = WarLedger.fromData(new WarLedger.Data( + List.of(new WarLedger.BattleEntry(1, null, "staff", "t", null, null, null)), null, null, false)); + assertTrue(partial.factionWoundLives().isEmpty(), "a battle saved without sides wounds nobody"); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/war/wounds/WarWoundsTest.java b/src/test/java/net/tfminecraft/simplefactions/war/wounds/WarWoundsTest.java new file mode 100644 index 00000000..cff7e1d6 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/war/wounds/WarWoundsTest.java @@ -0,0 +1,270 @@ +package net.tfminecraft.simplefactions.war.wounds; + +import static net.tfminecraft.simplefactions.war.wounds.TestForces.faction; +import static net.tfminecraft.simplefactions.war.wounds.TestForces.mercenary; +import static net.tfminecraft.simplefactions.war.wounds.TestForces.militia; +import static net.tfminecraft.simplefactions.war.wounds.TestForces.paid; +import static net.tfminecraft.simplefactions.war.wounds.WoundFixture.regiment; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.war.battle.military.BattleCasualtyTally; +import net.tfminecraft.simplefactions.war.campaign.runtime.CampaignClock; +import net.tfminecraft.simplefactions.war.core.War; + +class WarWoundsTest { + private WoundFixture fixture; + + @BeforeEach + void setUp() { + fixture = new WoundFixture(); + } + + @AfterEach + void tearDown() { + fixture.close(); + } + + @Test + void woundedSlotsAddUpOverActiveWarsAndNeverExceedPaidSlots() { + Regiment professional = regiment("professional", 10, 4); + Faction atk = fixture.faction("atk", professional); + War first = fixture.war(1, atk, fixture.faction("def")); + War second = fixture.war(2, atk, fixture.faction("other")); + first.getLedger().addAdjustment(adjustment("atk", "professional", 12)); + second.getLedger().addAdjustment(adjustment("ATK", "PROFESSIONAL", 9)); + + assertEquals(first.getId(), first.getWarId()); + assertEquals(21, WarWounds.woundLives(atk, professional)); + assertEquals(4, WarWounds.woundedSlots(atk, professional)); + assertEquals(2, WarWounds.availablePaidSlots(atk, professional)); + assertEquals(6, WarWounds.availableSlots(atk, professional)); + + second.getLedger().addAdjustment(adjustment("atk", "professional", 50)); + assertEquals(6, WarWounds.woundedSlots(atk, professional), "never more than the 6 paid slots"); + second.getLedger().markSettled(); + assertEquals(2, WarWounds.woundedSlots(atk, professional), "a settled war's wounds are already applied"); + } + + @Test + void onlySoldierRegimentsCarryWounds() { + Regiment levy = regiment("levy", 5, 0); + when(levy.isLevy()).thenReturn(true); + Regiment artillery = regiment("artillery", 5, 0); + when(artillery.isEquipment()).thenReturn(true); + Regiment company = regiment("mercenary", 5, 0); + when(company.isMercenary()).thenReturn(true); + Faction atk = fixture.faction("atk", levy, artillery, company); + War war = fixture.war(3, atk, fixture.faction("def")); + for (String id : List.of("levy", "artillery", "mercenary")) { + war.getLedger().addAdjustment(adjustment("atk", id, 25)); + } + + assertEquals(0, WarWounds.woundLives(atk, levy)); + assertEquals(0, WarWounds.woundedSlots(atk, artillery)); + assertEquals(0, WarWounds.woundedSlots(atk, company)); + assertEquals(0, WarWounds.woundLives(null, levy)); + assertEquals(0, WarWounds.woundLives(atk, null)); + assertEquals(0, WarWounds.woundedSlots(null, levy)); + assertEquals(0, WarWounds.woundedSlots(atk, null)); + assertEquals(0, WarWounds.availablePaidSlots(atk, null)); + assertEquals(0, WarWounds.availableSlots(atk, null)); + } + + @Test + void recordingABattleTalliesNewlyWoundedSlotsAndWoundsTheCompany() { + Faction atk = fixture.faction("atk", regiment("militia", 10, 6)); + Faction def = fixture.faction("def", regiment("professional", 5, 0)); + War war = fixture.war(4, atk, def); + MercenaryCompany company = company("guild", 4, fixture.faction("host")); + + BattleCasualtyTally tally = WarWounds.recordBattle(war, "b1", "fought", 7, + new WarLedger.SideRecord(30, List.of(faction("atk", militia(6, 4)), mercenary("guild", 2))), + new WarLedger.SideRecord(14, List.of(faction("def", paid("professional", 5)), + faction("gone", paid("professional", 1))))); + + assertEquals(List.of("atk attacker militia 2", "def defender professional 2"), lines(tally)); + assertEquals(5, company.getWounds().storedLives()); + assertEquals(1, war.getLedger().battles().size()); + assertEquals("b1", war.getLedger().battle(1).battleId()); + + // A second battle only tallies what it adds: 10 more paid lives on militia is 2 more slots. + BattleCasualtyTally second = WarWounds.recordBattle(war, "b2", "fought", 7, + new WarLedger.SideRecord(25, List.of(faction("atk", militia(6, 4)))), + new WarLedger.SideRecord(1, List.of(faction("def", paid("professional", 5))))); + assertEquals(List.of("atk attacker militia 2"), lines(second)); + } + + @Test + void editingDeathsMovesWoundsBothWaysAndTellsStaffWhatChanged() { + Faction atk = fixture.faction("atk", regiment("professional", 8, 0)); + War war = fixture.war(5, atk, fixture.faction("def")); + MercenaryCompany company = company("guild", 4, null); + WarWounds.recordBattle(war, "b1", "fought", 7, + new WarLedger.SideRecord(20, List.of(faction("atk", paid("professional", 6)), mercenary("guild", 2))), + new WarLedger.SideRecord(0, List.of())); + assertEquals(5, company.getWounds().storedLives()); + + WarWounds.Result raised = WarWounds.editDeaths(war, 1, WarLedger.ATTACKER, "add", 20, "staff", "glitch"); + assertTrue(raised.ok()); + assertEquals(List.of("§aWar 5: battle 1 attacker deaths 20 -> 40.", "§7atk professional: 3 -> 6 wounded slots", + "§7Company guild: +5 wounded lives"), raised.lines()); + assertEquals(10, company.getWounds().storedLives()); + assertEquals("battle 1 attacker deaths 20 -> 40 (glitch)", war.getLedger().notes().get(0).text()); + + WarWounds.Result lowered = WarWounds.editDeaths(war, 1, WarLedger.ATTACKER, "remove", 100, "staff", null); + assertEquals("§aWar 5: battle 1 attacker deaths 40 -> 0.", lowered.lines().get(0)); + assertEquals(0, company.getWounds().storedLives()); + assertEquals(0, war.getLedger().factionWoundLives("atk", "professional")); + + WarWounds.Result same = WarWounds.editDeaths(war, 1, WarLedger.DEFENDER, "set", 0, "staff", null); + assertEquals(List.of("§aWar 5: battle 1 defender deaths 0 -> 0.", "§7No wounded slots changed."), same.lines()); + } + + @Test + void editsAreRefusedWhenTheyCannotApply() { + War war = fixture.war(6, fixture.faction("atk"), fixture.faction("def")); + WarWounds.recordBattle(war, "b1", "fought", 7, new WarLedger.SideRecord(0, List.of()), + new WarLedger.SideRecord(0, List.of())); + + assertFalse(WarWounds.editDeaths(war, 2, WarLedger.ATTACKER, "set", 1, "s", null).ok()); + assertEquals(List.of("§cThe amount must be 0 or more."), + WarWounds.editDeaths(war, 1, WarLedger.ATTACKER, "set", -1, "s", null).lines()); + assertEquals(List.of("§cUse set, add or remove."), + WarWounds.editDeaths(war, 1, WarLedger.ATTACKER, "double", 1, "s", null).lines()); + war.getLedger().markSettled(); + assertEquals(List.of("§cThat war's wounds are already settled."), + WarWounds.editDeaths(war, 1, WarLedger.ATTACKER, "set", 1, "s", null).lines()); + Faction atk = fixture.factions.get("atk"); + assertFalse(WarWounds.adjust(war, atk, regiment("p", 1, 0), 5, "s", null).ok()); + } + + @Test + void staffCanWoundOrHealARegimentDirectly() { + Regiment professional = regiment("professional", 8, 0); + Faction atk = fixture.faction("atk", professional); + War war = fixture.war(7, atk, fixture.faction("def")); + + WarWounds.Result added = WarWounds.adjust(war, atk, professional, 10, "staff", "bug"); + assertEquals(List.of("§aWar 7: added 10 wounded lives on atk professional.", "§7atk professional: 0 -> 2 wounded slots"), + added.lines()); + WarWounds.Result removed = WarWounds.adjust(war, atk, professional, -10, "staff", null); + assertEquals("§aWar 7: removed 10 wounded lives on atk professional.", removed.lines().get(0)); + assertEquals(0, WarWounds.woundedSlots(atk, professional)); + } + + @Test + void settlementTakesWoundedPaidSlotsOnceAndNeverLawSlots() { + Regiment militia = regiment("militia", 10, 6); + Regiment professional = regiment("professional", 3, 0); + Faction atk = fixture.faction("atk", militia, professional); + Faction untouched = fixture.faction("calm", regiment("professional", 3, 0)); + War war = fixture.war(8, atk, fixture.faction("def")); + war.getLedger().addAdjustment(adjustment("atk", "militia", 100)); + war.getLedger().addAdjustment(adjustment("atk", "professional", 9)); + war.getLedger().addAdjustment(adjustment("atk", "missing", 10)); + war.getLedger().addAdjustment(adjustment("calm", "professional", 4)); + war.getLedger().addAdjustment(adjustment("ghost", "professional", 10)); + + assertEquals(List.of("atk militia: 100 lives = 20 slots, loses 4", "atk professional: 9 lives = 1 slots, loses 1", + "atk missing: 10 lives = 2 slots, loses 0", "calm professional: 4 lives = 0 slots, loses 0", + "ghost professional: 10 lives = 2 slots, loses 0"), WarWounds.settlementPreview(war)); + + List lost = WarWounds.settle(war); + + assertEquals(List.of("atk militia -4", "atk professional -1"), lost); + assertEquals(6, militia.getCurrentSlots(), "the 6 law slots stay"); + assertEquals(2, professional.getCurrentSlots()); + assertEquals(3, untouched.getMilitary().getRegiment("professional").getCurrentSlots()); + assertTrue(war.getLedger().isSettled()); + assertTrue(WarWounds.settle(war).isEmpty(), "a war is settled once"); + assertEquals(6, militia.getCurrentSlots()); + } + + @Test + void settlementSkipsFactionsWithoutAMilitary() { + Faction bare = mock(Faction.class); + when(bare.getId()).thenReturn("bare"); + fixture.factions.put("bare", bare); + War war = fixture.war(9, fixture.faction("atk"), fixture.faction("def")); + war.getLedger().addAdjustment(adjustment("bare", "professional", 10)); + + assertTrue(WarWounds.settle(war).isEmpty()); + assertEquals(List.of("bare professional: 10 lives = 2 slots, loses 0"), WarWounds.settlementPreview(war)); + } + + @Test + void reportedSlotsStopAtWhatTheRegimentCanLose() { + fixture.faction("atk", regiment("professional", 2, 0)); + assertEquals(2, WarWounds.slots("atk", "professional", 50)); + assertEquals(10, WarWounds.slots("atk", "nothing", 50)); + assertEquals(10, WarWounds.slots("nobody", "professional", 50)); + Faction bare = mock(Faction.class); + fixture.factions.put("bare", bare); + assertEquals(10, WarWounds.slots("bare", "professional", 50)); + } + + @Test + void staffCanSetACompanysWounds() { + Faction host = fixture.faction("host"); + MercenaryCompany company = company("guild", 4, host); + assertEquals(company, WarWounds.company("guild")); + assertNull(WarWounds.company("nope")); + assertNull(WarWounds.company(null)); + Guild empty = mock(Guild.class); + fixture.guilds.put("empty", empty); + assertNull(WarWounds.company("empty")); + + WarWounds.Result result = WarWounds.setCompanyWounds(company, 12, "staff"); + + assertEquals(List.of("§aCompany Company guild: wounded lives 0 -> 12 (2 of 4 slots wounded)."), result.lines()); + assertEquals(12, company.getWounds().lives(CampaignClock.now(), 4)); + } + + @Test + void anUnsetDeathRateCountsFive() { + int saved = net.tfminecraft.simplefactions.Cache.warBattleDeathsPerRegimentLoss; + net.tfminecraft.simplefactions.Cache.warBattleDeathsPerRegimentLoss = 0; + assertEquals(5, WarWounds.deathsPerSlot()); + net.tfminecraft.simplefactions.Cache.warBattleDeathsPerRegimentLoss = saved; + } + + private MercenaryCompany company(String guildId, int slots, Faction host) { + MercenaryCompany company = mock(MercenaryCompany.class); + Guild guild = mock(Guild.class); + when(guild.getId()).thenReturn(guildId); + when(guild.getCompany()).thenReturn(company); + when(guild.getFaction()).thenReturn(host); + when(company.getGuild()).thenReturn(guild); + when(company.getName()).thenReturn("Company " + guildId); + when(company.getSlots()).thenReturn(slots); + CompanyWounds wounds = new CompanyWounds(); + when(company.getWounds()).thenReturn(wounds); + fixture.guilds.put(guildId, guild); + return company; + } + + private static WarLedger.Adjustment adjustment(String faction, String regiment, int lives) { + return new WarLedger.Adjustment(faction, regiment, lives, "staff", null, "t"); + } + + private static List lines(BattleCasualtyTally tally) { + return tally.losses().stream() + .map(loss -> loss.faction().getId() + " " + loss.side() + " " + loss.regimentId() + " " + loss.count()) + .toList(); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/war/wounds/WoundAllocatorTest.java b/src/test/java/net/tfminecraft/simplefactions/war/wounds/WoundAllocatorTest.java new file mode 100644 index 00000000..5bd0e046 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/war/wounds/WoundAllocatorTest.java @@ -0,0 +1,84 @@ +package net.tfminecraft.simplefactions.war.wounds; + +import static net.tfminecraft.simplefactions.war.wounds.TestForces.faction; +import static net.tfminecraft.simplefactions.war.wounds.TestForces.law; +import static net.tfminecraft.simplefactions.war.wounds.TestForces.levy; +import static net.tfminecraft.simplefactions.war.wounds.TestForces.mercenary; +import static net.tfminecraft.simplefactions.war.wounds.TestForces.militia; +import static net.tfminecraft.simplefactions.war.wounds.TestForces.paid; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +class WoundAllocatorTest { + @Test + void forcesShareDeathsByTheLivesTheyBrought() { + // 80 faction lives and 20 mercenary lives share 60 deaths 48 / 12. + List wounds = WoundAllocator.allocate(60, List.of(faction("atk", paid("professional", 16)), + mercenary("guild", 4))); + + assertEquals(List.of(new Wound(ForceKind.REGIMENTS, "atk", "professional", 0, 48), + new Wound(ForceKind.MERCENARY, "guild", "mercenary", 0, 12)), wounds); + } + + @Test + void militiaFallsFirstInsideAForceThenTheRestInProportion() { + // The overlord brings 6 militia + 10 professional (80 lives), the levy 3 militia + 1 professional (20). + List wounds = WoundAllocator.allocate(60, List.of( + faction("overlord", militia(6, 0), paid("professional", 10)), + levy("subject", militia(3, 0), paid("professional", 1)))); + + assertEquals(List.of(new Wound(ForceKind.REGIMENTS, "overlord", "militia", 30, 0), + new Wound(ForceKind.REGIMENTS, "overlord", "professional", 0, 18), + new Wound(ForceKind.LEVY, "subject", "militia", 12, 0)), wounds); + } + + @Test + void lawAndPaidSlotsShareARegimentsWoundsAndOnlyPaidOnesStay() { + // 6 law + 4 paid militia, 30 deaths: 18 on law slots (gone after the battle), 12 on paid. + List wounds = WoundAllocator.allocate(30, List.of(faction("f", militia(6, 4)))); + + assertEquals(List.of(new Wound(ForceKind.REGIMENTS, "f", "militia", 18, 12)), wounds); + assertEquals(30, wounds.get(0).lives()); + } + + @Test + void nobodyLosesMoreThanItBroughtAndNothingMeansNothing() { + List wounds = WoundAllocator.allocate(500, List.of(faction("f", law("garrison", 2), paid("professional", 1)))); + + assertEquals(List.of(new Wound(ForceKind.REGIMENTS, "f", "garrison", 10, 0), + new Wound(ForceKind.REGIMENTS, "f", "professional", 0, 5)), wounds); + assertTrue(WoundAllocator.allocate(0, List.of(faction("f", paid("p", 1)))).isEmpty()); + assertTrue(WoundAllocator.allocate(5, List.of()).isEmpty()); + assertTrue(WoundAllocator.allocate(5, null).isEmpty()); + assertTrue(WoundAllocator.allocate(5, List.of(faction("f"))).isEmpty()); + } + + @Test + void largestRemainderSplitIsStableAndCapped() { + assertArrayEquals(new long[] { 2, 1, 1 }, WoundAllocator.share(4, new long[] { 5, 3, 3 })); + assertArrayEquals(new long[] { 1, 1, 0 }, WoundAllocator.share(2, new long[] { 1, 1, 1 })); + assertArrayEquals(new long[] { 0, 0 }, WoundAllocator.share(3, new long[] { 0, 0 })); + assertArrayEquals(new long[] { 0, 0 }, WoundAllocator.share(-1, new long[] { 1, 1 })); + assertArrayEquals(new long[] { 1, 2 }, WoundAllocator.share(9, new long[] { 1, 2 })); + } + + @Test + void forcesAddUpTheirSlotsAndLives() { + BattleForce force = faction("f", militia(2, 1), paid("p", 3)); + assertEquals(6, force.slots()); + assertEquals(30, force.lives()); + assertEquals(0, new BattleForce(ForceKind.LEVY, "x", -3, null).lives()); + assertEquals(Integer.MAX_VALUE, new BattleForce(ForceKind.LEVY, "x", Integer.MAX_VALUE, + List.of(paid("p", 3))).lives()); + assertEquals(Integer.MAX_VALUE, BattleForce.lives(List.of( + new BattleForce(ForceKind.LEVY, "x", Integer.MAX_VALUE, List.of(paid("p", 1))), + new BattleForce(ForceKind.LEVY, "y", Integer.MAX_VALUE, List.of(paid("p", 1)))))); + assertEquals(6, BattleForce.regimentSlots(List.of(force, mercenary("g", 4)))); + assertEquals(0, new ForcePart("p", false, -2, -1).slots()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/war/wounds/WoundFixture.java b/src/test/java/net/tfminecraft/simplefactions/war/wounds/WoundFixture.java new file mode 100644 index 00000000..49743a53 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/war/wounds/WoundFixture.java @@ -0,0 +1,104 @@ +package net.tfminecraft.simplefactions.war.wounds; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.mockito.MockedStatic; + +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.army.Military; +import net.tfminecraft.simplefactions.army.Regiment; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.war.core.War; + +/** + * Mock factions whose regiments really change size, registered with a mocked FactionManager, + * plus a WarManager that keeps wars in memory and does not save them. + */ +public final class WoundFixture implements AutoCloseable { + public final Map factions = new HashMap<>(); + public final Map guilds = new HashMap<>(); + private final MockedStatic factionManager; + private final MockedStatic warManager; + private final List savedWars; + private final int savedLives; + private final int savedDeaths; + + public WoundFixture() { + savedLives = Cache.warBattleLivesPerRegiment; + savedDeaths = Cache.warBattleDeathsPerRegimentLoss; + Cache.warBattleLivesPerRegiment = 5; + Cache.warBattleDeathsPerRegimentLoss = 5; + savedWars = new ArrayList<>(WarManager.get()); + WarManager.get().clear(); + factionManager = mockStatic(FactionManager.class); + factionManager.when(() -> FactionManager.getByString(anyString())) + .thenAnswer(call -> factions.get(call.getArgument(0, String.class).toLowerCase(Locale.ROOT))); + factionManager.when(() -> FactionManager.getGuildByString(anyString())) + .thenAnswer(call -> guilds.get(call.getArgument(0, String.class))); + // Wars stay in memory: persist does nothing, everything else is real. + warManager = mockStatic(WarManager.class, call -> call.getMethod().getName().equals("persist") + ? null : call.callRealMethod()); + } + + public Faction faction(String id, Regiment... regiments) { + Faction faction = mock(Faction.class); + when(faction.getId()).thenReturn(id); + when(faction.getName()).thenReturn(id.toUpperCase(Locale.ROOT)); + Military military = mock(Military.class); + List list = new ArrayList<>(List.of(regiments)); + when(military.getRegiments()).thenReturn(list); + when(military.getRegiment(anyString())).thenAnswer(call -> list.stream() + .filter(regiment -> regiment.getId().equalsIgnoreCase(call.getArgument(0, String.class))) + .findFirst().orElse(null)); + when(faction.getMilitary()).thenReturn(military); + factions.put(id.toLowerCase(Locale.ROOT), faction); + return faction; + } + + /** A regiment whose slots change when the code sets them; {@code free} law slots stay fixed. */ + public static Regiment regiment(String id, int current, int free) { + AtomicInteger slots = new AtomicInteger(current); + Regiment regiment = mock(Regiment.class); + when(regiment.getId()).thenReturn(id); + when(regiment.getName()).thenReturn("Name " + id); + when(regiment.getFreeSlots()).thenReturn(free); + when(regiment.getCurrentSlots()).thenAnswer(call -> slots.get()); + when(regiment.getPaidSlots()).thenAnswer(call -> Math.max(0, slots.get() - free)); + doAnswer(call -> { + slots.set(call.getArgument(0, Integer.class)); + return null; + }).when(regiment).setCurrentSlots(anyInt()); + return regiment; + } + + public War war(int id, Faction attacker, Faction defender) { + War war = new War(id, attacker, defender); + WarManager.get().add(war); + return war; + } + + @Override + public void close() { + warManager.close(); + factionManager.close(); + WarManager.get().clear(); + WarManager.get().addAll(savedWars); + Cache.warBattleLivesPerRegiment = savedLives; + Cache.warBattleDeathsPerRegimentLoss = savedDeaths; + } +}