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; + } +}