From da05dc65c0f821d4db15206bd41e7abe7a97bb55 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:35:11 +0000 Subject: [PATCH] Autoresolve campaign battles by simulation Autoresolve no longer starts a live battle. Each side's committed regiments become lives, a luck roll scales them, and Lanchester's square law picks the winner and the winner's losses. The loser loses a set share of its lives. Losses go through the normal casualty and outcome path, with the offensive pinned so fuel is charged correctly. A stalled battle vote postpones once (war.battle_voting.max_postponements) and then autoresolves. Legacy AUTORESOLVE_PENDING wars are resolved on the next tick. Battle start hours are now 21 to 23; the window end hour is not a start time. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../net/tfminecraft/simplefactions/Cache.java | 3 + .../simplefactions/loaders/ConfigLoader.java | 12 + .../campaign/CampaignBattleLaunchService.java | 31 +- .../CampaignBattleOutcomeService.java | 2 + .../admin/WarScheduleAdminService.java | 2 + .../admin/WarScheduleFeedbackFormatter.java | 4 + .../runtime/BattleAutoresolveService.java | 256 ++++++++++++- .../runtime/BattleScheduleService.java | 10 + .../runtime/BattleScheduleTickService.java | 6 +- .../campaign/runtime/BattleWindowService.java | 9 +- .../war/campaign/vote/VoteResults.java | 1 + src/main/resources/war.yml | 8 + .../ConfigLoaderBattleScheduleTest.java | 29 ++ .../CampaignBattleLaunchServiceTest.java | 18 +- .../CampaignBattleOutcomeServiceTest.java | 11 + .../WarScheduleFeedbackFormatterTest.java | 19 + .../runtime/BattleAutoresolveServiceTest.java | 355 ++++++++++++++++++ .../runtime/BattleScheduleServiceTest.java | 61 +++ .../BattleScheduleTickServiceTest.java | 17 + .../runtime/BattleWindowServiceTest.java | 19 +- .../campaign/vote/BattleVoteServiceTest.java | 10 + 21 files changed, 831 insertions(+), 52 deletions(-) create mode 100644 src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveServiceTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/Cache.java b/src/main/java/net/tfminecraft/simplefactions/Cache.java index 46d61229..2e13bd63 100644 --- a/src/main/java/net/tfminecraft/simplefactions/Cache.java +++ b/src/main/java/net/tfminecraft/simplefactions/Cache.java @@ -106,6 +106,9 @@ public static boolean requireProvinces(org.bukkit.command.CommandSender sender) public static boolean warBattleVotingPassIfEither; public static int warBattleVotingDevMinPlayers; public static boolean warBattleVotingDevMinPlayersEnabled; + public static int warBattleVotingMaxPostponements = 1; + public static double warAutoresolveLuck = 0.15; + public static double warAutoresolveLoserLossFraction = 0.5; public static int warBattleLivesPerRegiment; public static int warBattleMinSideLives; public static int warBattleDeathsPerRegimentLoss; diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java index 89e8832a..a5d4b755 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java @@ -213,6 +213,18 @@ public void loadWar(File warFile) { } else { Cache.warBattleVotingDevMinPlayers = Cache.warBattleVotingMinPlayers; } + Cache.warBattleVotingMaxPostponements = Math.max( + 0, + config.getInt("war.battle_voting.max_postponements", 1)); + double autoresolveLuck = config.getDouble("war.autoresolve.luck", 0.15); + if (autoresolveLuck < 0) { + autoresolveLuck = 0; + } else if (autoresolveLuck > 1) { + autoresolveLuck = 1; + } + Cache.warAutoresolveLuck = autoresolveLuck; + double loserLossFraction = config.getDouble("war.autoresolve.loser_loss_fraction", 0.5); + Cache.warAutoresolveLoserLossFraction = loserLossFraction < 0 ? 0 : loserLossFraction; Cache.warDevmodePhantomCount = config.getInt("war.devmode.phantom_count", 10); validateBattleScheduleConfig(); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleLaunchService.java b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleLaunchService.java index 1609e12f..a6ae9f34 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleLaunchService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleLaunchService.java @@ -25,6 +25,7 @@ import net.tfminecraft.simplefactions.war.campaign.progression.AttackerNavalContestService; import net.tfminecraft.simplefactions.war.campaign.progression.CampaignCapabilityService; import net.tfminecraft.simplefactions.war.campaign.progression.postbattle.CampaignOffensiveForfeitService; +import net.tfminecraft.simplefactions.war.campaign.runtime.BattleAutoresolveService; import net.tfminecraft.simplefactions.war.campaign.runtime.BattleScheduleService; import net.tfminecraft.simplefactions.war.campaign.runtime.BattleSideMembers; import net.tfminecraft.simplefactions.war.campaign.runtime.BattleWindowService; @@ -69,37 +70,13 @@ public static Battle prepareScheduledBattle(War war) { return battle; } + /** Simulated result. Does not create or start a live battle. */ public static Battle launchAutoresolveBattle(War war) { if (war == null || !war.isActive()) { return null; } - Battle existing = BattleManager.getByWarId(war.getId()); - Integer provinceId = BattleScheduleService.resolveScheduledProvinceId(war); - if (provinceId != null - && AttackerNavalContestService.applyIfAttackerHasNoBerthedNavy(war, provinceId)) { - return null; - } - - if (existing != null) { - String startError = startPreparedBattle(war, existing); - if (startError != null) { - logWarning("Could not start autoresolve battle: " + startError); - } - return existing; - } - - if (provinceId == null) { - return null; - } - - Battle battle = createCampaignBattle(war, provinceId, true); - if (battle != null) { - String startError = startPreparedBattle(war, battle); - if (startError != null) { - logWarning("Could not start autoresolve battle: " + startError); - } - } - return battle; + BattleAutoresolveService.resolve(war); + return null; } public static boolean tryStartScheduledBattle(War war, Instant now) { 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 3a911e8f..859751cf 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 @@ -126,6 +126,8 @@ public static CampaignBattleApplyResult applyCampaignBattleOutcome( return new CampaignBattleApplyResult(false, false, Optional.empty()); } + war.setPostponementsThisCycle(0); + CampaignPushTarget preBattlePushTarget = war.getPushTarget(); ObjectiveHolder preBattleObjectiveHeldBy = war.getObjectiveHeldBy(); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/admin/WarScheduleAdminService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/admin/WarScheduleAdminService.java index 92047c9e..7f164474 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/admin/WarScheduleAdminService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/admin/WarScheduleAdminService.java @@ -147,6 +147,8 @@ public static WarScheduleAdminResult closeVote(War war, Instant now) { case AUTORESOLVE_PENDING -> WarScheduleAdminResult.ok("Vote close set AUTORESOLVE_PENDING."); + case AUTORESOLVED -> WarScheduleAdminResult.ok("Vote close autoresolved the battle."); + case BLOCKED_DEFENDER_CHOICE -> WarScheduleAdminResult.error( "Vote close blocked: defender choice unresolved."); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/admin/WarScheduleFeedbackFormatter.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/admin/WarScheduleFeedbackFormatter.java index a99ab0f8..d3aa5e1a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/admin/WarScheduleFeedbackFormatter.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/admin/WarScheduleFeedbackFormatter.java @@ -64,6 +64,10 @@ private static String formatCloseVote(War war) { return "§7Phase: §e" + formatPhase(war); } if (phase == BattleSchedulePhase.VOTING && war.getScheduledBattleAt() == null) { + if (war.getPostponementsThisCycle() == 0) { + return "§7Autoresolved §7· Battle day: §e" + formatBattleDay(war) + + " §7· Phase: §e" + formatPhase(war); + } return "§7Postponed §7· Battle day: §e" + formatBattleDay(war) + " §7· Phase: §e" + formatPhase(war); } 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 062a8e08..5b6914ec 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 @@ -1,19 +1,38 @@ package net.tfminecraft.simplefactions.war.campaign.runtime; import java.time.Instant; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; import org.bukkit.Bukkit; import org.bukkit.entity.Player; +import net.tfminecraft.simplefactions.Cache; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.managers.RequestManager; import net.tfminecraft.simplefactions.managers.WarManager; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.objects.request.AutoresolveRequest; +import net.tfminecraft.simplefactions.war.battle.campaign.BattleNamingService; +import net.tfminecraft.simplefactions.war.battle.campaign.CampaignBattleOutcomeService; +import net.tfminecraft.simplefactions.war.battle.engine.core.Battle; +import net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager; +import net.tfminecraft.simplefactions.war.battle.enums.BattleType; +import net.tfminecraft.simplefactions.war.battle.military.BattleCasualtyService; +import net.tfminecraft.simplefactions.war.battle.persistence.BattlePersistenceService; +import net.tfminecraft.simplefactions.war.battle.military.BattlePoolService; +import net.tfminecraft.simplefactions.war.battle.template.BattleTemplate; +import net.tfminecraft.simplefactions.war.campaign.progression.AttackerNavalContestService; +import net.tfminecraft.simplefactions.war.campaign.progression.BelligerentRole; +import net.tfminecraft.simplefactions.war.campaign.progression.CampaignCapabilityService; +import net.tfminecraft.simplefactions.war.campaign.progression.CampaignCoalitionService; +import net.tfminecraft.simplefactions.war.campaign.progression.CampaignCoalitionService.CampaignCoalition; +import net.tfminecraft.simplefactions.war.campaign.schedule.CampaignScheduleService; +import net.tfminecraft.simplefactions.war.campaign.schedule.ScheduledCampaignBattle; +import net.tfminecraft.simplefactions.war.core.Side; import net.tfminecraft.simplefactions.war.core.War; -import net.tfminecraft.simplefactions.war.battle.campaign.CampaignBattleLaunchService; import net.tfminecraft.simplefactions.war.enums.BattleSchedulePhase; -import net.tfminecraft.simplefactions.war.campaign.progression.BelligerentRole; public final class BattleAutoresolveService { public enum SendResult { @@ -22,8 +41,26 @@ public enum SendResult { OPPOSING_LEADER_OFFLINE } + /** + * Lanchester result. Deaths are lives lost; regiment losses use + * {@link BattleCasualtyService#regimentLossesForDeaths}. + * Offensive is the coalition on the attack for this battle, not always the war attacker. + */ + public record Prediction( + boolean offensiveWins, + int offensiveDeaths, + int defensiveDeaths, + int offensiveRegimentLosses, + int defensiveRegimentLosses) {} + + private static Random randomOverride; + private BattleAutoresolveService() {} + static void setRandomForTests(Random random) { + randomOverride = random; + } + public static boolean canProposeAutoresolveNow(War war, Instant now) { if (war == null || !war.isActive() || now == null) { return false; @@ -91,8 +128,10 @@ public static void acceptRequest(Player acceptor) { return; } - BattleScheduleService.enterAutoresolvePending(war); - net.tfminecraft.simplefactions.war.battle.campaign.CampaignBattleLaunchService.launchAutoresolveBattle(war); + if (!resolve(war)) { + acceptor.sendMessage("§cAutoresolve could not find a battle to resolve."); + return; + } WarManager.persist(war); Faction proposerFaction = req.getFaction(); @@ -105,6 +144,215 @@ public static void acceptRequest(Player acceptor) { acceptor.sendMessage("§aBattle vote autoresolve accepted."); } + public static boolean resolve(War war) { + return resolve(war, randomOverride != null ? randomOverride : ThreadLocalRandom.current()); + } + + public static boolean resolve(War war, Random random) { + if (war == null || !war.isActive()) { + return false; + } + Battle prepared = BattleManager.getByWarId(war.getId()); + if (prepared != null && prepared.hasStarted()) { + return false; + } + Integer provinceId = BattleScheduleService.resolveScheduledProvinceId(war); + if (provinceId == null) { + return false; + } + if (AttackerNavalContestService.applyIfAttackerHasNoBerthedNavy(war, provinceId)) { + clearAutoresolveFlags(war); + war.setPostponementsThisCycle(0); + return true; + } + + CampaignCoalition offensive = CampaignCapabilityService.battleOffensiveCoalition(war); + if (offensive == null) { + offensive = CampaignCoalition.AGGRESSOR; + } + CampaignCoalition defensive = offensive.opposing(); + Side offensiveSide = CampaignCoalitionService.toSide(war, offensive); + Side defensiveSide = CampaignCoalitionService.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. + int offensiveLives = livesPerRegiment * committedRegiments(war, provinceId, offensiveSide); + int defensiveLives = livesPerRegiment * committedRegiments(war, provinceId, defensiveSide); + Prediction prediction = predict( + offensiveLives, + defensiveLives, + Cache.warAutoresolveLuck, + Cache.warAutoresolveLoserLossFraction, + random); + + CampaignCoalition winner = prediction.offensiveWins() ? offensive : defensive; + clearAutoresolveFlags(war); + war.setPostponementsThisCycle(0); + // The offensive is pinned so fuel is charged to the side that attacked, even if the + // casualties change which coalition would be on the offensive next. + CampaignBattleOutcomeService.applyCampaignBattleOutcome( + war, + CampaignCoalitionService.coalitionToBelligerentRole(winner), + provinceId, + casualtyCarrier(war, provinceId, offensive), + Map.of( + BattleTemplate.ATTACKER_SIDE, prediction.offensiveDeaths(), + BattleTemplate.DEFENDER_SIDE, prediction.defensiveDeaths()), + offensive); + if (prepared != null) { + BattlePersistenceService.deleteCampaignBattle(prepared); + } + broadcastResult(war, provinceId, winner, prediction); + return true; + } + + public static Prediction predict( + int offensiveLives, + int defensiveLives, + double luck, + double loserLossFraction, + Random random) { + int offensiveReal = Math.max(0, offensiveLives); + int defensiveReal = Math.max(0, defensiveLives); + double luckSpan = clampLuck(luck); + double fraction = loserLossFraction < 0 ? 0 : loserLossFraction; + if (offensiveReal == 0 || defensiveReal == 0) { + boolean offensiveWins = offensiveReal > 0; + return new Prediction(offensiveWins, 0, 0, 0, 0); + } + + double offensiveEffective = offensiveReal * luckFactor(random, luckSpan); + double defensiveEffective = defensiveReal * luckFactor(random, luckSpan); + boolean offensiveWins = offensiveEffective > defensiveEffective; + double winnerEffective = offensiveWins ? offensiveEffective : defensiveEffective; + double loserEffective = offensiveWins ? defensiveEffective : offensiveEffective; + int winnerReal = offensiveWins ? offensiveReal : defensiveReal; + int loserReal = offensiveWins ? defensiveReal : offensiveReal; + int winnerDeaths = lanchesterWinnerDeaths(winnerEffective, loserEffective, winnerReal); + int loserDeaths = (int) Math.round(loserReal * fraction); + if (loserDeaths < 0) { + loserDeaths = 0; + } + if (loserDeaths > loserReal) { + loserDeaths = loserReal; + } + int offensiveDeaths = offensiveWins ? winnerDeaths : loserDeaths; + int defensiveDeaths = offensiveWins ? loserDeaths : winnerDeaths; + return new Prediction( + offensiveWins, + offensiveDeaths, + defensiveDeaths, + BattleCasualtyService.regimentLossesForDeaths(offensiveDeaths), + BattleCasualtyService.regimentLossesForDeaths(defensiveDeaths)); + } + + private static int lanchesterWinnerDeaths(double winnerEffective, double loserEffective, int winnerRealLives) { + double gap = winnerEffective * winnerEffective - loserEffective * loserEffective; + if (gap < 0) { + gap = 0; + } + int deaths = (int) Math.round(winnerEffective - Math.sqrt(gap)); + if (deaths < 0) { + deaths = 0; + } + if (deaths > winnerRealLives) { + deaths = winnerRealLives; + } + return deaths; + } + + private static double clampLuck(double luck) { + if (luck < 0) { + return 0; + } + if (luck > 1) { + return 1; + } + return luck; + } + + /** Uniform factor in {@code [1 - luck, 1 + luck)}. A luck of 0 is exactly 1. */ + private static double luckFactor(Random random, double luck) { + if (luck <= 0) { + return 1.0; + } + double roll = random == null ? 0.5 : random.nextDouble(); + return (1.0 - luck) + roll * (2.0 * luck); + } + + private static int committedRegiments(War war, int provinceId, Side side) { + if (side == null) { + return 0; + } + 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, CampaignCoalition offensive) { + ScheduledCampaignBattle slot = CampaignScheduleService.slotAtActiveIndex(war).orElse(null); + BattleType type = slot != null ? slot.battleType() : BattleType.FIELD; + Battle carrier = new Battle("autoresolve-" + war.getId()); + carrier.setWarId(war.getId()); + carrier.setProvinceId(provinceId); + carrier.setBattleType(type); + carrier.setOffensiveCoalition(offensive); + return carrier; + } + + private static void clearAutoresolveFlags(War war) { + war.setAutoresolveProposedByAttacker(false); + war.setAutoresolveProposedByDefender(false); + } + + private static void broadcastResult( + War war, + int provinceId, + CampaignCoalition winner, + Prediction prediction) { + String message = "§7Autoresolved " + battleName(war, provinceId) + + ": §e" + winnerLabel(war, winner) + + " §7won. Losses: attacker " + prediction.offensiveRegimentLosses() + + ", defender " + prediction.defensiveRegimentLosses() + + " regiments."; + sendToSide(war.getAttackers(), message); + sendToSide(war.getDefenders(), message); + } + + private static String battleName(War war, int provinceId) { + ScheduledCampaignBattle slot = CampaignScheduleService.slotAtActiveIndex(war).orElse(null); + if (slot == null) { + return BattleNamingService.buildDisplayName( + BattleType.FIELD, + BattleNamingService.resolveLocationDisplayName(provinceId), + 1); + } + return BattleNamingService.resolveScheduledDisplayName( + war, + CampaignScheduleService.activeLeg(war), + CampaignScheduleService.getActiveScheduleIndex(war), + slot, + provinceId); + } + + private static String winnerLabel(War war, CampaignCoalition winner) { + Side side = CampaignCoalitionService.toSide(war, winner); + if (side != null && side.getLeader() != null) { + String name = side.getLeader().getName(); + if (name != null && !name.isBlank()) { + return name; + } + } + return winner == CampaignCoalition.AGGRESSOR ? "Attacker" : "Defender"; + } + + private static void sendToSide(Side side, String message) { + for (String memberName : BattleSideMembers.collectEligibleMemberNames(side)) { + Player player = Bukkit.getPlayerExact(memberName); + if (player != null && player.isOnline()) { + player.sendMessage(message); + } + } + } + private static Faction opposingLeaderFaction(War war, BelligerentRole proposerSide) { if (war == null || proposerSide == null) { return null; diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleService.java index 5a8480ca..eadb2112 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleService.java @@ -155,6 +155,14 @@ public static BattleScheduleCloseResult closeVote( return BattleScheduleCloseResult.SCHEDULED; } + int maxPostponements = Math.max(0, Cache.warBattleVotingMaxPostponements); + if (war.getPostponementsThisCycle() < maxPostponements) { + postpone(war); + return BattleScheduleCloseResult.POSTPONED; + } + if (BattleAutoresolveService.resolve(war)) { + return BattleScheduleCloseResult.AUTORESOLVED; + } postpone(war); return BattleScheduleCloseResult.POSTPONED; } @@ -279,6 +287,7 @@ public static boolean scheduleBattleAtProvince(War war, int provinceId, Instant war.setScheduledBattleAt(scheduledAt); war.setScheduledBattleProvinceId(provinceId); war.setBattleSchedulePhase(BattleSchedulePhase.SCHEDULED); + war.setPostponementsThisCycle(0); CampaignBattleLaunchService.prepareScheduledBattle(war); return war.getScheduledBattleAt() != null; } @@ -290,6 +299,7 @@ public static boolean markScheduledAtProvince(War war, int provinceId) { war.clearSignupRemindersSent(); war.setScheduledBattleProvinceId(provinceId); war.setBattleSchedulePhase(BattleSchedulePhase.SCHEDULED); + war.setPostponementsThisCycle(0); return CampaignBattleLaunchService.prepareScheduledBattle(war) != null; } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleTickService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleTickService.java index 8979ffe2..0d73b63d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleTickService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleScheduleTickService.java @@ -88,6 +88,9 @@ static boolean processWar(War war, Instant now) { if (war == null || !war.isActive()) { return false; } + if (war.getBattleSchedulePhase() == BattleSchedulePhase.AUTORESOLVE_PENDING) { + return BattleAutoresolveService.resolve(war); + } if (war.getBattleSchedulePhase() != BattleSchedulePhase.VOTING) { return false; } @@ -105,7 +108,8 @@ static boolean processWar(War war, Instant now) { CloseVoteOptions.scheduled()); if (result == BattleScheduleCloseResult.SCHEDULED || result == BattleScheduleCloseResult.POSTPONED - || result == BattleScheduleCloseResult.AUTORESOLVE_PENDING) { + || result == BattleScheduleCloseResult.AUTORESOLVE_PENDING + || result == BattleScheduleCloseResult.AUTORESOLVED) { changed = true; } return changed; diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleWindowService.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleWindowService.java index 4a5e932b..cf70e835 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleWindowService.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleWindowService.java @@ -17,18 +17,23 @@ public final class BattleWindowService { private BattleWindowService() {} + /** + * Hours a battle may start. {@code window_end_hour} closes the window and is not a start time + * (21, 22, 23 when the window ends at 24). Whether the window is currently open still uses the + * end hour as its end. + */ public static List listValidHours() { int start = Cache.warBattleWindowStartHour; int end = Cache.warBattleWindowEndHour; List hours = new ArrayList<>(); - for (int hour = start; hour <= end; hour++) { + for (int hour = start; hour < end; hour++) { hours.add(hour); } return Collections.unmodifiableList(hours); } public static boolean isValidHour(int hour) { - return hour >= Cache.warBattleWindowStartHour && hour <= Cache.warBattleWindowEndHour; + return hour >= Cache.warBattleWindowStartHour && hour < Cache.warBattleWindowEndHour; } public static Instant atScheduleHour(LocalDate battleDay, int hour) { diff --git a/src/main/java/net/tfminecraft/simplefactions/war/campaign/vote/VoteResults.java b/src/main/java/net/tfminecraft/simplefactions/war/campaign/vote/VoteResults.java index 8ae13622..b7a39936 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/campaign/vote/VoteResults.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/campaign/vote/VoteResults.java @@ -29,6 +29,7 @@ public enum BattleScheduleCloseResult { SCHEDULED, POSTPONED, AUTORESOLVE_PENDING, + AUTORESOLVED, SKIPPED, BLOCKED_DEFENDER_CHOICE } diff --git a/src/main/resources/war.yml b/src/main/resources/war.yml index 5e4d1286..bde4479b 100644 --- a/src/main/resources/war.yml +++ b/src/main/resources/war.yml @@ -75,6 +75,14 @@ war: min_players: 4 require_smallest_side_full: true pass_if_either: true + # A stalled vote postpones this many times, then the battle is autoresolved. + max_postponements: 1 + # Simulated battle when both leaders agree, or when a vote has stalled too often. + autoresolve: + # Each side's strength is multiplied by a uniform factor in [1 - luck, 1 + luck]. + luck: 0.15 + # Share of the losing side's lives removed. The beaten army retreats rather than being wiped out. + loser_loss_fraction: 0.5 devmode: phantom_count: 10 battle_military: diff --git a/src/test/java/net/tfminecraft/simplefactions/loaders/ConfigLoaderBattleScheduleTest.java b/src/test/java/net/tfminecraft/simplefactions/loaders/ConfigLoaderBattleScheduleTest.java index a5f00dc3..5c8b513f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/loaders/ConfigLoaderBattleScheduleTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/loaders/ConfigLoaderBattleScheduleTest.java @@ -59,6 +59,35 @@ void loadConfig_validBattleScheduleHours() throws IOException { assertEquals(4, Cache.warBattleVotingMinPlayers); assertEquals(1, Cache.warBattleVotingDevMinPlayers); assertTrue(Cache.warBattleVotingDevMinPlayersEnabled); + assertEquals(1, Cache.warBattleVotingMaxPostponements); + assertEquals(0.15, Cache.warAutoresolveLuck, 0.0001); + assertEquals(0.5, Cache.warAutoresolveLoserLossFraction, 0.0001); + } + + @Test + void loadConfig_autoresolveAndMaxPostponements() throws IOException { + Path file = writeConfig(""" + war: + battle_schedule: + vote_close_hour: 16 + raid_window_start_hour: 19 + raid_window_end_hour: 20 + window_start_hour: 21 + window_end_hour: 24 + defender_choice_deadline_hour: 12 + battle_voting: + min_players: 4 + max_postponements: 3 + autoresolve: + luck: 0.2 + loser_loss_fraction: 0.25 + """); + + new ConfigLoader().loadWar(file.toFile()); + + assertEquals(3, Cache.warBattleVotingMaxPostponements); + assertEquals(0.2, Cache.warAutoresolveLuck, 0.0001); + assertEquals(0.25, Cache.warAutoresolveLoserLossFraction, 0.0001); } @Test diff --git a/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleLaunchServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleLaunchServiceTest.java index f4c16e22..ef6b1f1f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleLaunchServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/battle/campaign/CampaignBattleLaunchServiceTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; 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.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -253,17 +254,20 @@ void prepareScheduledBattle_isIdempotent() { } @Test - void launchAutoresolveBattle_startsImmediately() { + void launchAutoresolveBattle_doesNotStartALiveBattle() { War war = baseWar(); war.setBattleSchedulePhase(BattleSchedulePhase.AUTORESOLVE_PENDING); - withMockBossBar(() -> { - Battle battle = CampaignBattleLaunchService.launchAutoresolveBattle(war); + try (MockedStatic autoresolve = + mockStatic(net.tfminecraft.simplefactions.war.campaign.runtime.BattleAutoresolveService.class)) { + autoresolve.when(() -> net.tfminecraft.simplefactions.war.campaign.runtime.BattleAutoresolveService + .resolve(war)).thenReturn(true); - assertNotNull(battle); - assertFalse(battle.hasStarted()); - assertEquals("campaign_w1_p20", battle.getId()); - }); + assertNull(CampaignBattleLaunchService.launchAutoresolveBattle(war)); + assertTrue(BattleManager.get().isEmpty()); + autoresolve.verify(() -> net.tfminecraft.simplefactions.war.campaign.runtime.BattleAutoresolveService + .resolve(war)); + } } @Test 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 d232a857..8245a631 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 @@ -424,6 +424,17 @@ void handleBattleEnded_defenderWin_flipsInitiativeAndOpensChoice() { assertTrue(CampaignPostBattleChoiceService.needsWinnerChoice(war)); } + @Test + void applyCampaignBattleOutcome_resetsPostponements() { + War war = baseWar(); + war.setPostponementsThisCycle(2); + warManagerMock.when(() -> WarManager.getById(1)).thenReturn(war); + + CampaignBattleOutcomeService.applyCampaignBattleOutcome(war, BelligerentRole.ATTACKER, 20); + + assertEquals(0, war.getPostponementsThisCycle()); + } + private War baseWar() { War war = new War(1, attacker, defender); war.setGoal(WarGoalType.SUBJUGATE); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/campaign/admin/WarScheduleFeedbackFormatterTest.java b/src/test/java/net/tfminecraft/simplefactions/war/campaign/admin/WarScheduleFeedbackFormatterTest.java index 04401d97..b13e4cae 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/campaign/admin/WarScheduleFeedbackFormatterTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/campaign/admin/WarScheduleFeedbackFormatterTest.java @@ -185,6 +185,25 @@ void appendCampaignBattleLine_whenBattleExists() { }); } + @Test + void closevote_autoresolved_doesNotSayPostponed() { + War war = votingWar(); + war.setPostponementsThisCycle(0); + + String combined = String.join(" ", WarScheduleFeedbackFormatter.format("closevote", war)); + assertTrue(combined.contains("Autoresolved")); + assertFalse(combined.contains("Postponed")); + } + + @Test + void closevote_postponed_namesTheNextBattleDay() { + War war = votingWar(); + war.setPostponementsThisCycle(1); + + String combined = String.join(" ", WarScheduleFeedbackFormatter.format("closevote", war)); + assertTrue(combined.contains("Postponed")); + } + private War votingWar() { War war = new War(1, attacker, defender); war.setGoal(WarGoalType.SUBJUGATE); 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 new file mode 100644 index 00000000..a0ec27fc --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleAutoresolveServiceTest.java @@ -0,0 +1,355 @@ +package net.tfminecraft.simplefactions.war.campaign.runtime; + +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.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atLeastOnce; +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 java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.atomic.AtomicReference; + +import org.bukkit.Bukkit; +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarStyle; +import org.bukkit.boss.BossBar; +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.ArgumentCaptor; +import org.mockito.MockedStatic; + +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.enums.Terrain; +import net.tfminecraft.simplefactions.managers.ProvinceManager; +import net.tfminecraft.simplefactions.managers.TitleManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.map.provinces.Province; +import net.tfminecraft.simplefactions.objects.Faction; +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.BattleCasualtyService; +import net.tfminecraft.simplefactions.war.battle.military.BattlePoolService; +import net.tfminecraft.simplefactions.war.battle.template.BattleTemplate; +import net.tfminecraft.simplefactions.war.campaign.progression.AttackerNavalContestService; +import net.tfminecraft.simplefactions.war.campaign.progression.BelligerentRole; +import net.tfminecraft.simplefactions.war.campaign.progression.CampaignCapabilityService; +import net.tfminecraft.simplefactions.war.campaign.progression.CampaignCoalitionService.CampaignCoalition; +import net.tfminecraft.simplefactions.war.campaign.progression.postbattle.CampaignPostBattleChoiceService.PostBattleChoicePhase; +import net.tfminecraft.simplefactions.war.campaign.runtime.BattleAutoresolveService.Prediction; +import net.tfminecraft.simplefactions.war.campaign.ui.CampaignPushTarget; +import net.tfminecraft.simplefactions.war.core.Side; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.enums.BattleSchedulePhase; +import net.tfminecraft.simplefactions.war.enums.CampaignPhase; +import net.tfminecraft.simplefactions.war.enums.WarGoalType; +import net.tfminecraft.simplefactions.war.enums.WarType; + +class BattleAutoresolveServiceTest { + private Faction attacker; + private Faction defender; + private Player player; + private SimpleFactions pluginBackup; + private MockedStatic bukkit; + private MockedStatic warManager; + private MockedStatic titles; + private MockedStatic capability; + private MockedStatic pools; + private MockedStatic casualties; + + @BeforeEach + void setUp() { + BattleManager.resetForTests(); + BattleAutoresolveService.setRandomForTests(null); + Cache.warBattleLivesPerRegiment = 5; + Cache.warBattleDeathsPerRegimentLoss = 5; + Cache.warAutoresolveLuck = 0; + Cache.warAutoresolveLoserLossFraction = 0.5; + Cache.warFirstBattleAtBorder = true; + Cache.warBattleVotingMaxPostponements = 1; + + attacker = mock(Faction.class); + defender = mock(Faction.class); + when(attacker.getId()).thenReturn("atk"); + when(defender.getId()).thenReturn("def"); + when(attacker.getName()).thenReturn("Attackers"); + when(defender.getName()).thenReturn("Defenders"); + when(attacker.getMembers()).thenReturn(List.of("Alice")); + when(defender.getMembers()).thenReturn(List.of("Alice")); + + player = mock(Player.class); + when(player.isOnline()).thenReturn(true); + } + + @AfterEach + void tearDown() { + close(casualties); + close(pools); + close(capability); + close(titles); + close(warManager); + close(bukkit); + SimpleFactions.plugin = pluginBackup; + BattleAutoresolveService.setRandomForTests(null); + } + + @Test + void predict_fiftyVsThirty_noLuck_matchesLanchesterExample() { + Prediction prediction = BattleAutoresolveService.predict(50, 30, 0, 0.5, explodingRandom()); + + assertTrue(prediction.offensiveWins()); + assertEquals(10, prediction.offensiveDeaths()); + assertEquals(15, prediction.defensiveDeaths()); + assertEquals(2, prediction.offensiveRegimentLosses()); + assertEquals(3, prediction.defensiveRegimentLosses()); + } + + @Test + void predict_zeroStrength_otherSideWinsWithNoLosses() { + Prediction defendersWin = BattleAutoresolveService.predict(0, 40, 0.15, 0.5, explodingRandom()); + assertFalse(defendersWin.offensiveWins()); + assertEquals(0, defendersWin.offensiveDeaths()); + assertEquals(0, defendersWin.defensiveDeaths()); + + Prediction attackersWin = BattleAutoresolveService.predict(40, 0, 0.15, 0.5, explodingRandom()); + assertTrue(attackersWin.offensiveWins()); + assertEquals(0, attackersWin.offensiveRegimentLosses()); + assertEquals(0, attackersWin.defensiveRegimentLosses()); + } + + @Test + void predict_bothZero_tieGoesToDefender() { + Prediction prediction = BattleAutoresolveService.predict(0, 0, 0, 0.5, explodingRandom()); + assertFalse(prediction.offensiveWins()); + assertEquals(0, prediction.offensiveDeaths()); + assertEquals(0, prediction.defensiveDeaths()); + } + + @Test + void predict_tieGoesToDefenderAndCapsWinnerDeaths() { + Prediction tied = BattleAutoresolveService.predict(50, 50, 0, 0.5, explodingRandom()); + assertFalse(tied.offensiveWins()); + assertEquals(25, tied.offensiveDeaths()); + assertEquals(50, tied.defensiveDeaths()); + + Prediction capped = BattleAutoresolveService.predict(50, 50, 0.15, 0.5, rolls(1.0, 1.0)); + assertFalse(capped.offensiveWins()); + assertEquals(25, capped.offensiveDeaths()); + assertEquals(50, capped.defensiveDeaths()); + assertEquals(5, capped.offensiveRegimentLosses()); + assertEquals(10, capped.defensiveRegimentLosses()); + } + + @Test + void predict_luckCanFlipTheWinner() { + Prediction prediction = BattleAutoresolveService.predict(30, 30, 0.15, 0.5, rolls(0.0, 1.0)); + + assertFalse(prediction.offensiveWins()); + assertEquals(15, prediction.offensiveDeaths()); + assertEquals(11, prediction.defensiveDeaths()); + } + + @Test + void resolve_appliesLossesThroughCampaignOutcome() { + War war = votingWar(); + openHarness(war, war.getAttackers()); + + assertTrue(BattleAutoresolveService.resolve(war, explodingRandom())); + + Map deaths = capturedDeaths(); + assertEquals(10, deaths.get(BattleTemplate.ATTACKER_SIDE)); + assertEquals(15, deaths.get(BattleTemplate.DEFENDER_SIDE)); + Battle carrier = capturedCarrier(); + assertEquals(CampaignCoalition.AGGRESSOR, carrier.getOffensiveCoalition()); + assertFalse(carrier.hasStarted()); + assertNull(BattleManager.getByString(carrier.getId())); + assertEquals(1, war.getCampaignBattlesFought()); + assertEquals(BattleSchedulePhase.VOTING, war.getBattleSchedulePhase()); + assertEquals(0, war.getPostponementsThisCycle()); + assertFalse(war.isAutoresolveProposedByAttacker()); + assertMessageContains("Autoresolved", "Attackers", "attacker 2", "defender 3"); + } + + @Test + void resolve_counterPush_debitsTheOffensiveCoalition() { + War war = votingWar(); + war.setInitiativeHolder(BelligerentRole.DEFENDER); + war.setInitiativeHolderCoalition(CampaignCoalition.DEFENDER); + openHarness(war, war.getDefenders()); + + assertTrue(BattleAutoresolveService.resolve(war, explodingRandom())); + + Battle carrier = capturedCarrier(); + assertEquals(CampaignCoalition.DEFENDER, carrier.getOffensiveCoalition()); + assertEquals(war.getDefenders(), CampaignBattleSides.warSideFor(war, carrier, BattleTemplate.ATTACKER_SIDE)); + assertEquals(10, capturedDeaths().get(BattleTemplate.ATTACKER_SIDE)); + assertEquals(15, capturedDeaths().get(BattleTemplate.DEFENDER_SIDE)); + assertEquals(CampaignCoalition.DEFENDER, war.getLastBattleOffensiveCoalition()); + assertEquals(CampaignCoalition.DEFENDER, war.getPostBattleWinnerCoalition()); + assertMessageContains("Defenders", "attacker 2", "defender 3"); + } + + @Test + void resolve_navalAutoLossRunsBeforeSimulation() { + War war = votingWar(); + war.setPostponementsThisCycle(2); + war.setAutoresolveProposedByAttacker(true); + + try (MockedStatic naval = mockStatic(AttackerNavalContestService.class); + MockedStatic casualty = mockStatic(BattleCasualtyService.class); + MockedStatic pool = mockStatic(BattlePoolService.class)) { + naval.when(() -> AttackerNavalContestService.applyIfAttackerHasNoBerthedNavy(any(), anyInt())) + .thenReturn(true); + + assertTrue(BattleAutoresolveService.resolve(war, explodingRandom())); + + assertEquals(0, war.getPostponementsThisCycle()); + assertFalse(war.isAutoresolveProposedByAttacker()); + assertEquals(0, war.getCampaignBattlesFought()); + casualty.verify( + () -> BattleCasualtyService.applyBattleCasualties(any(), any(), any()), + never()); + pool.verify( + () -> BattlePoolService.totalCommittedRegiments(any(), anyInt(), any()), + never()); + } + } + + @Test + void resolve_returnsFalseWhenNoProvince() { + War war = votingWar(); + war.setPostBattleChoicePhase(PostBattleChoicePhase.WINNER_PUSH_HOLD); + war.setPostBattleChoiceResolved(false); + + assertFalse(BattleAutoresolveService.resolve(war, explodingRandom())); + assertEquals(BattleSchedulePhase.VOTING, war.getBattleSchedulePhase()); + } + + private void openHarness(War war, Side heavySide) { + ProvinceManager provinces = new ProvinceManager(); + provinces.start(Map.of(20, new Province(20, Terrain.PLAINS.name(), 50, 200, 200))); + pluginBackup = SimpleFactions.plugin; + SimpleFactions plugin = mock(SimpleFactions.class); + when(plugin.getProvinceManager()).thenReturn(provinces); + SimpleFactions.plugin = plugin; + + titles = mockStatic(TitleManager.class); + titles.when(() -> TitleManager.getByProvince(20)).thenReturn(defender); + + BossBar bossBar = mock(BossBar.class); + bukkit = mockStatic(Bukkit.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("Alice")).thenReturn(player); + + warManager = mockStatic(WarManager.class); + warManager.when(() -> WarManager.persist(any())).then(invocation -> null); + warManager.when(() -> WarManager.getById(war.getId())).thenReturn(war); + + capability = mockStatic(CampaignCapabilityService.class, org.mockito.Mockito.CALLS_REAL_METHODS); + capability.when(() -> CampaignCapabilityService.canMountOffensiveAfterPush(any(), any())).thenReturn(true); + + 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); + casualties = mockStatic(BattleCasualtyService.class, org.mockito.Mockito.CALLS_REAL_METHODS); + casualties.when(() -> BattleCasualtyService.applyBattleCasualties(any(), any(), any())) + .thenAnswer(invocation -> { + carrier.set(invocation.getArgument(1)); + @SuppressWarnings("unchecked") + Map sideDeaths = invocation.getArgument(2); + deaths.set(sideDeaths); + return null; + }); + this.carrier = carrier; + this.deaths = deaths; + } + + private AtomicReference carrier; + private AtomicReference> deaths; + + private Battle capturedCarrier() { + return carrier.get(); + } + + private Map capturedDeaths() { + return deaths.get(); + } + + private void assertMessageContains(String... parts) { + ArgumentCaptor messages = ArgumentCaptor.forClass(String.class); + verify(player, atLeastOnce()).sendMessage(messages.capture()); + String combined = String.join("\n", messages.getAllValues()); + for (String part : parts) { + assertTrue(combined.contains(part), combined); + } + } + + private War votingWar() { + War war = new War(1, attacker, defender); + war.setGoal(WarGoalType.SUBJUGATE); + war.setWarType(WarType.SUBJUGATE); + war.setObjectiveProvinceId(30); + war.setCampaignStartProvinceId(20); + war.setCampaignProvinces(List.of(5, 10, 20, 30)); + war.setCursorIndex(2); + war.setInitiativeAttacker(4); + war.setInitiativeDefender(4); + war.setInitiativeHolder(BelligerentRole.ATTACKER); + war.setInitiativeHolderCoalition(CampaignCoalition.AGGRESSOR); + war.setCampaignPhase(CampaignPhase.INVASION); + war.setPushTarget(CampaignPushTarget.TOWARD_OBJECTIVE); + war.setPostBattleChoicePhase(PostBattleChoicePhase.NONE); + war.setPostBattleChoiceResolved(true); + war.setBattleSchedulePhase(BattleSchedulePhase.VOTING); + war.setPostponementsThisCycle(2); + war.setAutoresolveProposedByAttacker(true); + war.setAutoresolveProposedByDefender(true); + return war; + } + + private static Random explodingRandom() { + return new Random() { + @Override + public double nextDouble() { + throw new AssertionError("luck 0 must not roll"); + } + }; + } + + private static Random rolls(double first, double second) { + return new Random() { + private int index; + + @Override + public double nextDouble() { + double value = index == 0 ? first : second; + index++; + return value; + } + }; + } + + private static void close(MockedStatic mocked) { + if (mocked != null) { + mocked.close(); + } + } +} 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 34efc3b3..394ddd37 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 @@ -182,6 +182,7 @@ void isBattleWindowOpen_trueDuringBattleHoursOnBattleDay() { war, BattleWindowService.atScheduleHour(BATTLE_DAY, 21))); assertTrue(BattleScheduleService.isBattleWindowOpen( war, BattleWindowService.atScheduleHour(BATTLE_DAY, 23))); + assertFalse(BattleWindowService.isValidHour(24)); } @Test @@ -192,6 +193,66 @@ void isBattleWindowOpen_falseOffBattleDay() { war, BattleWindowService.atScheduleHour(BATTLE_DAY, 21))); } + @Test + void closeVote_resetsPostponementsWhenScheduled() { + War war = votingWar(); + war.setPostponementsThisCycle(2); + addCrossSideVotes(war, 21); + + withMockBossBar(() -> { + BattleScheduleCloseResult result = BattleScheduleService.closeVote( + war, + voteCloseInstant(), + uuidToFaction(), + name -> null); + + assertEquals(BattleScheduleCloseResult.SCHEDULED, result); + assertEquals(0, war.getPostponementsThisCycle()); + }); + } + + @Test + void closeVote_autoresolvesWhenPostponementsExhausted() { + Cache.warBattleVotingMaxPostponements = 1; + War war = votingWar(); + war.setPostponementsThisCycle(1); + + try (MockedStatic autoresolve = mockStatic(BattleAutoresolveService.class)) { + autoresolve.when(() -> BattleAutoresolveService.resolve(war)).thenReturn(true); + + BattleScheduleCloseResult result = BattleScheduleService.closeVote( + war, + voteCloseInstant(), + uuidToFaction(), + name -> null); + + assertEquals(BattleScheduleCloseResult.AUTORESOLVED, result); + assertEquals(BATTLE_DAY, war.getBattleDay()); + autoresolve.verify(() -> BattleAutoresolveService.resolve(war)); + } + } + + @Test + void closeVote_postponesAgainWhenAutoresolveHasNoProvince() { + Cache.warBattleVotingMaxPostponements = 1; + War war = votingWar(); + war.setPostponementsThisCycle(1); + + try (MockedStatic autoresolve = mockStatic(BattleAutoresolveService.class)) { + autoresolve.when(() -> BattleAutoresolveService.resolve(war)).thenReturn(false); + + BattleScheduleCloseResult result = BattleScheduleService.closeVote( + war, + voteCloseInstant(), + uuidToFaction(), + name -> null); + + assertEquals(BattleScheduleCloseResult.POSTPONED, result); + assertEquals(BATTLE_DAY.plusDays(1), war.getBattleDay()); + assertEquals(2, war.getPostponementsThisCycle()); + } + } + @Test void enterAutoresolvePending_setsPhaseAndClearsFlags() { War war = votingWar(); 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 300010fc..9b5bade0 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 @@ -160,6 +160,23 @@ void processWar_closesVoteAtSixteenWhenQuorumMet() { }); } + @Test + void processWar_resolvesLingeringAutoresolvePending() { + War war = votingWar(); + war.setBattleSchedulePhase(BattleSchedulePhase.AUTORESOLVE_PENDING); + + try (MockedStatic autoresolve = mockStatic(BattleAutoresolveService.class)) { + autoresolve.when(() -> BattleAutoresolveService.resolve(war)).thenAnswer(invocation -> { + war.setBattleSchedulePhase(BattleSchedulePhase.VOTING); + return true; + }); + + assertTrue(BattleScheduleTickService.processWar(war, voteCloseInstant())); + assertEquals(BattleSchedulePhase.VOTING, war.getBattleSchedulePhase()); + autoresolve.verify(() -> BattleAutoresolveService.resolve(war)); + } + } + @Test void processWar_skipsNonVotingPhase() { War war = votingWar(); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleWindowServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleWindowServiceTest.java index e4ba2e0a..fb260ae9 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleWindowServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/campaign/runtime/BattleWindowServiceTest.java @@ -22,14 +22,15 @@ void setUp() { } @Test - void listValidHours_defaultWindow() { - assertEquals(List.of(21, 22, 23, 24), BattleWindowService.listValidHours()); + void listValidHours_excludesWindowEnd() { + assertEquals(List.of(21, 22, 23), BattleWindowService.listValidHours()); } @Test - void isValidHour_acceptsWindowBounds() { + void isValidHour_excludesWindowEnd() { assertTrue(BattleWindowService.isValidHour(21)); - assertTrue(BattleWindowService.isValidHour(24)); + assertTrue(BattleWindowService.isValidHour(23)); + assertFalse(BattleWindowService.isValidHour(24)); assertFalse(BattleWindowService.isValidHour(20)); assertFalse(BattleWindowService.isValidHour(25)); } @@ -42,18 +43,14 @@ void computeScheduledBattleAt_hour21_usesBattleDayInScheduleZone() { } @Test - void computeScheduledBattleAt_hour24_usesNextDayMidnightInScheduleZone() { - LocalDate battleDay = LocalDate.of(2026, 8, 21); - Instant instant = BattleWindowService.computeScheduledBattleAt(battleDay, 24); - assertEquals( - battleDay.plusDays(1).atStartOfDay(BattleWindowService.SCHEDULE_ZONE).toInstant(), - instant); + void computeScheduledBattleAt_hour24_isNotAStartTime() { + assertNull(BattleWindowService.computeScheduledBattleAt(LocalDate.of(2026, 8, 21), 24)); } @Test void resolveScheduleHour_mapsMidnightNextDayToHour24() { LocalDate battleDay = LocalDate.of(2026, 8, 21); - Instant instant = BattleWindowService.computeScheduledBattleAt(battleDay, 24); + Instant instant = BattleWindowService.atScheduleHour(battleDay, 24); assertEquals(24, BattleWindowService.resolveScheduleHour(battleDay, instant)); } diff --git a/src/test/java/net/tfminecraft/simplefactions/war/campaign/vote/BattleVoteServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/war/campaign/vote/BattleVoteServiceTest.java index a5998f1b..96ebce03 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/campaign/vote/BattleVoteServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/campaign/vote/BattleVoteServiceTest.java @@ -64,6 +64,16 @@ void tearDown() { CampaignClock.reset(); } + @Test + void toggleVote_rejectsWindowEndHour() { + assertEquals( + BattleVoteToggleResult.REJECTED_INVALID_HOUR, + BattleVoteService.toggleVote(war, attackerVoter, 24, attacker, true)); + assertEquals( + BattleVoteToggleResult.ADDED, + BattleVoteService.toggleVote(war, attackerVoter, 23, attacker, true)); + } + @Test void toggleVote_allowsBeforeVoteClose() { votingWar();