diff --git a/pom.xml b/pom.xml index 3c96b0cb..f97e70d9 100644 --- a/pom.xml +++ b/pom.xml @@ -16,7 +16,7 @@ 2.0.1 2.0.0 2.0.1 - 2.0.0 + 2.0.4 diff --git a/src/main/java/net/tfminecraft/simplefactions/Cache.java b/src/main/java/net/tfminecraft/simplefactions/Cache.java index 2e13bd63..bdee61b4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/Cache.java +++ b/src/main/java/net/tfminecraft/simplefactions/Cache.java @@ -107,6 +107,7 @@ public static boolean requireProvinces(org.bukkit.command.CommandSender sender) public static int warBattleVotingDevMinPlayers; public static boolean warBattleVotingDevMinPlayersEnabled; public static int warBattleVotingMaxPostponements = 1; + public static int warPostponeFreezeHours = 24; public static double warAutoresolveLuck = 0.15; public static double warAutoresolveLoserLossFraction = 0.5; public static int warBattleLivesPerRegiment; diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index 1cb3e66c..f34a7028 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -220,6 +220,7 @@ public class SimpleFactions extends JavaPlugin{ vehicleMaintenancePaySessionManager, vehicleMaintenancePayService); private boolean vehicleIntegrationRegistered = false; + private boolean constructionFreezeRegistered; private final PlayerEconomyManager playerEconomyManager = new PlayerEconomyManager(); private final VehicleUpkeepService vehicleUpkeepService = new VehicleUpkeepService( vehicleRegistry, @@ -582,10 +583,28 @@ public void onPluginEnable(PluginEnableEvent event) { if ("VFBuilders".equalsIgnoreCase(event.getPlugin().getName()) || "VehicleFramework".equalsIgnoreCase(event.getPlugin().getName())) { registerVehicleIntegration(); + registerConstructionFreeze(); } } }, this); registerVehicleIntegration(); + registerConstructionFreeze(); + } + + private void registerConstructionFreeze() { + if (constructionFreezeRegistered || !getServer().getPluginManager().isPluginEnabled("VFBuilders")) { + return; + } + constructionFreezeRegistered = true; + try { + Class.forName("net.tfminecraft.vfbuilders.api.ConstructionFreeze"); + } catch (ClassNotFoundException | LinkageError e) { + // Loading VfBuildersConstructionFreeze would fail on a VFBuilders without this API. + getLogger().warning("VFBuilders has no ConstructionFreeze API; update it so vehicle " + + "projects freeze with postponed battles"); + return; + } + net.tfminecraft.simplefactions.war.freeze.VfBuildersConstructionFreeze.register(this); } private void registerVehicleIntegration() { diff --git a/src/main/java/net/tfminecraft/simplefactions/database/WarData.java b/src/main/java/net/tfminecraft/simplefactions/database/WarData.java index 6f7c7c6d..2e5ff4da 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/WarData.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/WarData.java @@ -73,6 +73,7 @@ public class WarData { public boolean autoresolveProposedByAttacker; public boolean autoresolveProposedByDefender; public Integer postponementsThisCycle; + public String preparationFrozenUntil; public boolean defenderChoiceResolved; public String initiativeHolderCoalition; public String pushTarget; diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index 50b6537b..6b1a776e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -314,13 +314,19 @@ public List getPillageHits() { } public void tick() { - tickUpgradeQueue(); + tick(false); + } + + /** @param factionFrozen the host faction's battle was postponed; war-related upgrades hold */ + public void tick(boolean factionFrozen) { + tickUpgradeQueue(factionFrozen); if(company != null) company.tick(); } - private void tickUpgradeQueue() { + private void tickUpgradeQueue(boolean factionFrozen) { if(upgradeQueue.size() == 0) return; UpgradeExpansion e = upgradeQueue.get(0); + if(factionFrozen && e.getUpgrade().isWarRelated()) return; e.tick(); if(e.getTimeLeft() != 0) return; upgradeQueue.remove(0); diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/upgrade/Upgrade.java b/src/main/java/net/tfminecraft/simplefactions/guild/upgrade/Upgrade.java index f828122d..6de0ce82 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/upgrade/Upgrade.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/upgrade/Upgrade.java @@ -29,6 +29,7 @@ public class Upgrade { private int maxLevel; private double upkeep; private int expansionTime; + private boolean warRelated; private List allowedTypes = new ArrayList<>(); private Map modifiers = new HashMap<>(); @@ -42,6 +43,7 @@ public Upgrade(String key, ConfigurationSection config) { level = 0; maxLevel = config.getInt("max-level", Integer.MAX_VALUE); expansionTime = config.getInt("expansion-time", 21600); + warRelated = config.getBoolean("war-related", false); for(String s : config.getStringList("allowed-types")) { GuildType type = GuildLoader.getByString(s); if(type != null) allowedTypes.add(type); @@ -78,6 +80,7 @@ public Upgrade(Upgrade b, int level) { allowedTypes = b.allowedTypes; modifiers = b.modifiers; expansionTime = b.expansionTime; + warRelated = b.warRelated; maxLevel = b.maxLevel; this.description = b.description; this.level = Math.min(level, maxLevel); @@ -96,6 +99,8 @@ public boolean isAllowed(GuildType type) { public int getLevel() { return level; } public void setLevel(int level) { this.level = Math.min(level, maxLevel); } public int getExpansionTime() { return expansionTime; } + /** War-related upgrades stop progressing while a postponed battle freezes the guild's faction. */ + public boolean isWarRelated() { return warRelated; } public int getMaxLevel() { return maxLevel; } /** Guild upgrades leave max-level unset, so they are unbounded. */ public boolean hasMaxLevel() { return maxLevel != Integer.MAX_VALUE; } diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java index a5d4b755..1e476cf7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java @@ -216,6 +216,9 @@ public void loadWar(File warFile) { Cache.warBattleVotingMaxPostponements = Math.max( 0, config.getInt("war.battle_voting.max_postponements", 1)); + Cache.warPostponeFreezeHours = Math.max( + 0, + config.getInt("war.battle_voting.postpone_freeze_hours", 24)); double autoresolveLuck = config.getDouble("war.autoresolve.luck", 0.15); if (autoresolveLuck < 0) { autoresolveLuck = 0; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyCreator.java index 2c316470..6958393f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/CompanyCreator.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.war.freeze.PreparationFreeze; import java.util.ArrayList; import java.util.List; @@ -245,6 +246,9 @@ public ItemStack createSlotQueueItem(MilitaryExpansion expansion, int index, Gui List lore = new ArrayList<>(); if (index == 0) { lore.add("§7Time Left: §e" + TimeFormatter.formatTime(expansion.getTimeLeft())); + String frozen = PreparationFreeze.frozenLore( + PreparationFreeze.frozenUntil(guild.getCompany(), java.time.Instant.now())); + if (frozen != null) lore.add(frozen); } else { lore.add(StringFormatter.formatHex("#857e59Queued...")); } @@ -341,6 +345,9 @@ public ItemStack createUpgradeQueueItem(UpgradeExpansion expansion, int index, G List lore = new ArrayList<>(); if (index == 0) { lore.add("§7Time Left: §e" + TimeFormatter.formatTime(expansion.getTimeLeft())); + String frozen = PreparationFreeze.frozenLore( + PreparationFreeze.frozenUntil(guild.getCompany(), java.time.Instant.now())); + if (frozen != null) lore.add(frozen); } else { lore.add(StringFormatter.formatHex("#857e59Queued...")); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java index 23d1ad25..e5b0a06c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.war.freeze.PreparationFreeze; import net.tfminecraft.simplefactions.util.LegacyModelData; import java.util.ArrayList; @@ -777,6 +778,10 @@ public ItemStack createUpgradeQueueItem(UpgradeExpansion expansion, int index, G List lore = new ArrayList<>(); if(index == 0) { lore.add("§7Time Left: §e" + TimeFormatter.formatTime(expansion.getTimeLeft())); + if(expansion.getUpgrade().isWarRelated()) { + String frozen = PreparationFreeze.frozenLore(PreparationFreeze.frozenUntil(guild.getFaction(), java.time.Instant.now())); + if(frozen != null) lore.add(frozen); + } } else { lore.add(StringFormatter.formatHex("#857e59Queued...")); } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationCreator.java index eb509014..531cde42 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationCreator.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.managers.inventory; +import net.tfminecraft.simplefactions.war.freeze.PreparationFreeze; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -85,6 +86,8 @@ public ItemStack createConstructionIcon(InstallationConstruction construction, F lore.add("§7Province: §e" + construction.getProvince()); lore.add("§7Coords: §e" + construction.getCenterX() + ", " + construction.getCenterZ()); lore.add("§7Time left: §e" + TimeFormatter.formatTime(construction.getTimeLeft())); + String frozen = PreparationFreeze.frozenLore(PreparationFreeze.frozenUntil(faction, java.time.Instant.now())); + if (frozen != null) lore.add(frozen); lore.add("§cClick to cancel"); meta.setLore(lore); meta.getPersistentDataContainer() 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 ab1d941a..b0fbc8ec 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.freeze.PreparationFreeze; import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -56,6 +57,8 @@ public ItemStack createQueueItem(MilitaryExpansion e, int x, Faction f) { List lore = new ArrayList(); if(x == 0) { lore.add("§7Time Left: §e"+TimeFormatter.formatTime(e.getTimeLeft())); + String frozen = PreparationFreeze.frozenLore(PreparationFreeze.frozenUntil(f, java.time.Instant.now())); + if(frozen != null) lore.add(frozen); } else { lore.add(StringFormatter.formatHex("#857e59Queued...")); } 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 0a9acc4c..3ac25e45 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/company/MercenaryCompany.java @@ -32,6 +32,7 @@ import net.tfminecraft.simplefactions.mercenary.contract.ContractTerminationService; import net.tfminecraft.simplefactions.mercenary.contract.MercenaryContract; import net.tfminecraft.simplefactions.mercenary.contract.TerminationReason; +import net.tfminecraft.simplefactions.war.freeze.PreparationFreeze; /** * A mercenary company owned by a guild. The company holds its own regiment, so @@ -530,6 +531,19 @@ public double getNetPosition() { public void tick() { if (enforceCharacterGate()) return; + if (!PreparationFreeze.isFrozen(this)) { + tickGrowth(); + } + if (formationRemaining > 0) return; + long now = System.currentTimeMillis(); + if (!contractHandler.tickExpiry().isEmpty() + || !contractHandler.tickAmendments(now).isEmpty()) { + chime(SFGUI.CONTRACT_LIST_VIEW); + } + } + + /** Formation, slot and upgrade timers; a postponed battle holds all three. */ + private void tickGrowth() { if (formationRemaining > 0) { formationRemaining--; if (formationRemaining == 0 && regiment != null) { @@ -541,11 +555,6 @@ public void tick() { } tickSlotQueue(); tickUpgradeQueue(); - long now = System.currentTimeMillis(); - if (!contractHandler.tickExpiry().isEmpty() - || !contractHandler.tickAmendments(now).isEmpty()) { - chime(SFGUI.CONTRACT_LIST_VIEW); - } } /** diff --git a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractHandler.java b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractHandler.java index 5664251d..e689fc7b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/mercenary/contract/ContractHandler.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.mercenary.contract; +import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -13,6 +14,7 @@ import net.tfminecraft.simplefactions.utils.Formatter; import net.tfminecraft.simplefactions.mercenary.MercenaryResult; import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.war.freeze.PreparationFreeze; /** * The contracts a company holds, in the shape of @@ -120,6 +122,13 @@ public Offer offer(Faction hirer, ContractKind kind, ContractTerms terms, long n if (hirer == null) { return new Offer(MercenaryResult.deny("That faction no longer exists."), null); } + Instant at = Instant.ofEpochMilli(now); + Instant frozenUntil = PreparationFreeze.frozenUntil(hirer, at); + if (frozenUntil != null) { + return new Offer(MercenaryResult.deny(hirer.getName() + + " had a battle postponed and cannot hire mercenaries for " + + PreparationFreeze.formatRemaining(frozenUntil, at) + "."), null); + } MercenaryResult valid = ContractValidator.validate(terms, company, now); if (!valid.ok()) return new Offer(valid, null); @@ -155,6 +164,10 @@ public MercenaryResult accept(String contractId, Faction hirer, String signer) { if (hirer.getGovernment() == null || !hirer.getGovernment().isCouncilMember(signer)) { return MercenaryResult.deny("Only a member of your government may sign a contract."); } + String frozen = PreparationFreeze.hiringBlockedMessage(hirer, Instant.now()); + if (frozen != null) { + return MercenaryResult.deny(frozen); + } MercenaryResult loyal = MercenaryLoyalty.canServe(company, hirer); if (!loyal.ok()) { expire(contract); @@ -284,6 +297,12 @@ public MercenaryResult acceptSlots(String contractId, Faction hirer, String sign return MercenaryResult.deny("A battle is underway. Accept the slot change when it ends."); } int slots = contract.getPendingSlots(now); + if (slots > contract.getSlots()) { + String frozen = PreparationFreeze.hiringBlockedMessage(hirer, Instant.ofEpochMilli(now)); + if (frozen != null) { + return MercenaryResult.deny(frozen); + } + } if (slots > SlotReservations.maxForAmendment(company, contract, now)) { contract.clearPendingSlots(); tell(company.getLeader(), "§cThe slot change on your contract with " diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java index 6e84b98d..3e07e377 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java @@ -73,6 +73,7 @@ import net.tfminecraft.simplefactions.laws.LawGroup; import net.tfminecraft.simplefactions.installation.InstallationTransferService; import net.tfminecraft.simplefactions.installation.handler.InstallationHandler; +import net.tfminecraft.simplefactions.war.freeze.PreparationFreeze; import net.tfminecraft.simplefactions.settlement.handler.SettlementHandler; import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; @@ -428,10 +429,13 @@ public void tick() { double tax = getTotalForeignTaxRate(); if(getTaxRate() + tax > 100) setTaxRate(100-tax); - military.tick(); - installationHandler.tick(); + boolean frozen = PreparationFreeze.isFrozen(this); + if (!frozen) { + military.tick(); + installationHandler.tick(); + } for(Guild guild : guildHandler.getGuilds()) { - guild.tick(); + guild.tick(frozen); } /* for(FactionModifier m : getModifiers()) { 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 eadb2112..aacfa6ae 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 @@ -15,6 +15,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.freeze.PreparationFreeze; import net.tfminecraft.simplefactions.war.battle.campaign.CampaignBattleLaunchService; import net.tfminecraft.simplefactions.war.enums.BattleSchedulePhase; import net.tfminecraft.simplefactions.war.campaign.progression.BelligerentRole; @@ -157,13 +158,13 @@ public static BattleScheduleCloseResult closeVote( int maxPostponements = Math.max(0, Cache.warBattleVotingMaxPostponements); if (war.getPostponementsThisCycle() < maxPostponements) { - postpone(war); + postpone(war, now); return BattleScheduleCloseResult.POSTPONED; } if (BattleAutoresolveService.resolve(war)) { return BattleScheduleCloseResult.AUTORESOLVED; } - postpone(war); + postpone(war, now); return BattleScheduleCloseResult.POSTPONED; } @@ -233,6 +234,10 @@ public static int castSpoofVotes( } public static void postpone(War war) { + postpone(war, Instant.now()); + } + + public static void postpone(War war, Instant now) { if (war == null || war.getBattleDay() == null) { return; } @@ -246,15 +251,21 @@ public static void postpone(War war) { clearScheduledTargets(war); war.setPostponementsThisCycle(war.getPostponementsThisCycle() + 1); war.setDefenderChoiceResolved(false); + PreparationFreeze.applyPostponement(war, now); } public static void skipBattleDay(War war) { + skipBattleDay(war, Instant.now()); + } + + public static void skipBattleDay(War war, Instant now) { if (war == null || war.getBattleDay() == null) { return; } war.setBattleDay(war.getBattleDay().plusDays(1)); BattleInstallationPickService.clearForNewBattleDay(war); CampaignRaidService.clearForNewBattleDay(war); + PreparationFreeze.applyPostponement(war, now); } public static Integer resolveBattleProvinceId(War war) { 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 9d998184..51b278b5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/core/War.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/core/War.java @@ -96,6 +96,7 @@ public class War { private boolean autoresolveProposedByAttacker; private boolean autoresolveProposedByDefender; private int postponementsThisCycle; + private Instant preparationFrozenUntil; private boolean defenderChoiceResolved; private CampaignCoalition initiativeHolderCoalition = CampaignCoalition.AGGRESSOR; private CampaignPushTarget pushTarget = CampaignPushTarget.TOWARD_OBJECTIVE; @@ -746,6 +747,19 @@ public void setPostponementsThisCycle(int postponementsThisCycle) { this.postponementsThisCycle = postponementsThisCycle; } + /** End of the build freeze a postponed battle puts on both sides; null when none was set. */ + public Instant getPreparationFrozenUntil() { + return preparationFrozenUntil; + } + + public void setPreparationFrozenUntil(Instant preparationFrozenUntil) { + this.preparationFrozenUntil = preparationFrozenUntil; + } + + public boolean isPreparationFrozen(Instant now) { + return isActive() && preparationFrozenUntil != null && now != null && now.isBefore(preparationFrozenUntil); + } + public boolean isDefenderChoiceResolved() { return defenderChoiceResolved; } 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 2ded0b88..454571d4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/core/WarMapper.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/core/WarMapper.java @@ -166,6 +166,9 @@ public static WarData toData(War war) { data.autoresolveProposedByAttacker = war.isAutoresolveProposedByAttacker(); data.autoresolveProposedByDefender = war.isAutoresolveProposedByDefender(); data.postponementsThisCycle = war.getPostponementsThisCycle(); + if (war.getPreparationFrozenUntil() != null) { + data.preparationFrozenUntil = war.getPreparationFrozenUntil().toString(); + } data.defenderChoiceResolved = war.isDefenderChoiceResolved(); data.forceQuorumNextClose = war.isForceQuorumNextClose(); if (war.getStartedAt() != null) { @@ -278,6 +281,9 @@ public static War fromData(WarData data) { war.setAutoresolveProposedByAttacker(data.autoresolveProposedByAttacker); war.setAutoresolveProposedByDefender(data.autoresolveProposedByDefender); war.setPostponementsThisCycle(data.postponementsThisCycle != null ? data.postponementsThisCycle : 0); + if (data.preparationFrozenUntil != null && !data.preparationFrozenUntil.isBlank()) { + war.setPreparationFrozenUntil(Instant.parse(data.preparationFrozenUntil)); + } war.setDefenderChoiceResolved(data.defenderChoiceResolved); if (data.postBattleChoiceResolved != null) { war.setPostBattleChoiceResolved(data.postBattleChoiceResolved); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/freeze/PreparationFreeze.java b/src/main/java/net/tfminecraft/simplefactions/war/freeze/PreparationFreeze.java new file mode 100644 index 00000000..957ae6b9 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/war/freeze/PreparationFreeze.java @@ -0,0 +1,182 @@ +package net.tfminecraft.simplefactions.war.freeze; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.tfminecraft.simplefactions.Cache; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompanies; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.mercenary.contract.MercenaryContract; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.war.campaign.runtime.BattleSideMembers; +import net.tfminecraft.simplefactions.war.core.War; + +/** + * A postponed battle must not buy either side preparation time. Each postponement + * freezes every build timer of the factions in that war, and of mercenary companies + * they host or hire, for {@link Cache#warPostponeFreezeHours}. Raid repair locks are + * pushed back by the same amount and no new mercenaries can be hired meanwhile. + * Upkeep and wages keep running. + */ +public final class PreparationFreeze { + + private static Supplier> activeWars = WarManager::getActive; + + private PreparationFreeze() { + } + + /** Tests swap the war source; pass null to restore {@link WarManager#getActive()}. */ + static void setActiveWars(Supplier> source) { + activeWars = source != null ? source : WarManager::getActive; + } + + /** + * Called whenever a battle day moves back. Stacks: a second postponement adds + * another full period on top of what is left. + */ + public static void applyPostponement(War war, Instant now) { + if (war == null || now == null || Cache.warPostponeFreezeHours <= 0) { + return; + } + long hours = Cache.warPostponeFreezeHours; + Instant current = war.getPreparationFrozenUntil(); + Instant base = current != null && current.isAfter(now) ? current : now; + war.setPreparationFrozenUntil(base.plus(hours, ChronoUnit.HOURS)); + for (Map.Entry lock : war.getRaidRepairLockUntil().entrySet()) { + if (lock.getValue() != null && lock.getValue().isAfter(now)) { + lock.setValue(lock.getValue().plus(hours, ChronoUnit.HOURS)); + } + } + notifyMembers(war, hours); + } + + /** @return when the latest freeze on this faction ends, or null when it is not frozen */ + public static Instant frozenUntil(Faction faction, Instant now) { + if (faction == null || faction.getId() == null) { + return null; + } + Instant latest = null; + for (War war : activeWars.get()) { + if (war == null || !war.isPreparationFrozen(now)) { + continue; + } + if (!participantIds(war).contains(faction.getId().toLowerCase())) { + continue; + } + latest = later(latest, war.getPreparationFrozenUntil()); + } + return latest; + } + + public static boolean isFrozen(Faction faction) { + return frozenUntil(faction, Instant.now()) != null; + } + + /** A company freezes with its host faction and with every faction it is serving. */ + public static Instant frozenUntil(MercenaryCompany company, Instant now) { + if (company == null || !anyWarFrozen(now)) { + return null; + } + Guild guild = company.getGuild(); + Instant latest = guild != null ? frozenUntil(guild.getFaction(), now) : null; + for (MercenaryContract contract : company.getContractHandler().getActive()) { + latest = later(latest, frozenUntil(contract.getHirer(), now)); + } + return latest; + } + + public static boolean isFrozen(MercenaryCompany company) { + return frozenUntil(company, Instant.now()) != null; + } + + /** Freeze covering a player's own projects, through their faction or their company. */ + public static Instant frozenUntil(String playerName, Instant now) { + if (playerName == null || !anyWarFrozen(now)) { + return null; + } + Instant latest = frozenUntil(FactionManager.getByMember(playerName), now); + return later(latest, frozenUntil(MercenaryCompanies.findByMember(playerName), now)); + } + + /** Refusal for a hire by this faction, or null when hiring is open. */ + public static String hiringBlockedMessage(Faction hirer, Instant now) { + Instant until = frozenUntil(hirer, now); + if (until == null) { + return null; + } + return "§cA battle of yours was postponed. No mercenaries can be hired for " + + formatRemaining(until, now) + "."; + } + + /** Queue lore line for a held timer, or null when the timer is running. */ + public static String frozenLore(Instant until) { + if (until == null) { + return null; + } + return "§cFrozen: battle postponed (" + formatRemaining(until, Instant.now()) + " left)"; + } + + public static String formatRemaining(Instant until, Instant now) { + long millis = until.toEpochMilli() - now.toEpochMilli(); + long minutes = Math.max(1, (millis + 59_999) / 60_000); + long hours = minutes / 60; + long mins = minutes % 60; + if (hours > 0) return hours + "h " + mins + "m"; + return mins + "m"; + } + + /** Cheap check first: every build timer asks this each second. */ + private static boolean anyWarFrozen(Instant now) { + for (War war : activeWars.get()) { + if (war != null && war.isPreparationFrozen(now)) { + return true; + } + } + return false; + } + + private static Set participantIds(War war) { + Set ids = new LinkedHashSet<>(); + for (Faction faction : BattleSideMembers.collectParticipatingFactions(war.getAttackers())) { + if (faction.getId() != null) ids.add(faction.getId().toLowerCase()); + } + for (Faction faction : BattleSideMembers.collectParticipatingFactions(war.getDefenders())) { + if (faction.getId() != null) ids.add(faction.getId().toLowerCase()); + } + return ids; + } + + private static Instant later(Instant a, Instant b) { + if (a == null) return b; + if (b == null) return a; + return b.isAfter(a) ? b : a; + } + + private static void notifyMembers(War war, long hours) { + if (Bukkit.getServer() == null) { + return; + } + Set members = new LinkedHashSet<>(); + members.addAll(BattleSideMembers.collectEligibleMemberNames(war.getAttackers())); + members.addAll(BattleSideMembers.collectEligibleMemberNames(war.getDefenders())); + String message = "§eThe battle was postponed. For the next " + hours + "h your regiments, installations, " + + "war upgrades, mercenary companies and vehicle projects are frozen, and no mercenaries can be hired."; + for (String member : members) { + Player player = Bukkit.getPlayerExact(member); + if (player != null) { + player.sendMessage(message); + } + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java b/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java new file mode 100644 index 00000000..daaec6c7 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java @@ -0,0 +1,36 @@ +package net.tfminecraft.simplefactions.war.freeze; + +import java.time.Instant; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.ServicePriority; + +import net.tfminecraft.vfbuilders.api.ConstructionFreeze; + +/** Holds VFBuilders projects started by players whose battle was postponed. */ +public final class VfBuildersConstructionFreeze implements ConstructionFreeze { + + /** + * Only call once VFBuilders is enabled and its ConstructionFreeze API is on the + * classpath; this class cannot load without it. + */ + public static void register(Plugin plugin) { + Bukkit.getServicesManager().register( + ConstructionFreeze.class, new VfBuildersConstructionFreeze(), plugin, ServicePriority.Normal); + plugin.getLogger().info("VFBuilders projects freeze with postponed battles"); + } + + @Override + public String freezeReason(UUID constructorUuid) { + OfflinePlayer player = Bukkit.getOfflinePlayer(constructorUuid); + Instant now = Instant.now(); + Instant until = PreparationFreeze.frozenUntil(player.getName(), now); + if (until == null) { + return null; + } + return "battle postponed (" + PreparationFreeze.formatRemaining(until, now) + ")"; + } +} diff --git a/src/main/resources/Guilds/upgrades.yml b/src/main/resources/Guilds/upgrades.yml index f8d89953..9ee30524 100644 --- a/src/main/resources/Guilds/upgrades.yml +++ b/src/main/resources/Guilds/upgrades.yml @@ -1,3 +1,5 @@ +# Set war-related: true on an upgrade to hold its timer while a postponed battle +# freezes the guild's faction (see war.battle_voting.postpone_freeze_hours). max_admin_power: name: "#76ad9dMax Administative Power" icon: "writable_book.0" diff --git a/src/main/resources/war.yml b/src/main/resources/war.yml index bde4479b..de7fa92a 100644 --- a/src/main/resources/war.yml +++ b/src/main/resources/war.yml @@ -77,6 +77,10 @@ war: pass_if_either: true # A stalled vote postpones this many times, then the battle is autoresolved. max_postponements: 1 + # Each postponement freezes both sides' build timers (regiments, installations, + # war-related guild upgrades, mercenary companies, VFBuilders projects) and + # pushes raid repair locks back by this many hours. Hiring mercenaries is blocked meanwhile. + postpone_freeze_hours: 24 # 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]. diff --git a/src/test/java/net/tfminecraft/simplefactions/war/core/WarMapperTest.java b/src/test/java/net/tfminecraft/simplefactions/war/core/WarMapperTest.java index e6bbb4f7..d651fda8 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/core/WarMapperTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/core/WarMapperTest.java @@ -780,6 +780,31 @@ void roundTrip_campaignRaidFields() { } } + @Test + void roundTrip_preparationFreeze() { + Faction attacker = mock(Faction.class); + Faction defender = mock(Faction.class); + when(attacker.getId()).thenReturn("faction_a"); + when(defender.getId()).thenReturn("faction_b"); + FactionManager.factions.add(attacker); + FactionManager.factions.add(defender); + try { + War war = new War(43, attacker, defender); + Instant until = Instant.parse("2026-08-22T14:00:00Z"); + war.setPreparationFrozenUntil(until); + + WarData data = WarMapper.toData(war); + assertEquals(until.toString(), data.preparationFrozenUntil); + assertEquals(until, WarMapper.fromData(data).getPreparationFrozenUntil()); + + data.preparationFrozenUntil = null; + assertNull(WarMapper.fromData(data).getPreparationFrozenUntil()); + } finally { + FactionManager.factions.remove(attacker); + FactionManager.factions.remove(defender); + } + } + @Test void fromData_omittedInstallationPicksDefaultsEmpty() { Faction attacker = mock(Faction.class); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/freeze/PreparationFreezeMercenaryTest.java b/src/test/java/net/tfminecraft/simplefactions/war/freeze/PreparationFreezeMercenaryTest.java new file mode 100644 index 00000000..625cbeeb --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/war/freeze/PreparationFreezeMercenaryTest.java @@ -0,0 +1,130 @@ +package net.tfminecraft.simplefactions.war.freeze; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.ArrayList; +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.Cache; +import net.tfminecraft.simplefactions.government.Government; +import net.tfminecraft.simplefactions.mercenary.MercenaryResult; +import net.tfminecraft.simplefactions.mercenary.company.MercenaryCompany; +import net.tfminecraft.simplefactions.mercenary.contract.ContractFixture; +import net.tfminecraft.simplefactions.mercenary.contract.ContractHandler; +import net.tfminecraft.simplefactions.mercenary.contract.MercenaryContract; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.war.core.War; + +class PreparationFreezeMercenaryTest { + private final List wars = new ArrayList<>(); + private ContractFixture fixture; + private Faction enemy; + + @BeforeEach + void setUp() { + Cache.warPostponeFreezeHours = 24; + PreparationFreeze.setActiveWars(() -> wars); + fixture = ContractFixture.formed(2); + enemy = ContractFixture.faction("foe"); + } + + @AfterEach + void tearDown() { + PreparationFreeze.setActiveWars(null); + ContractFixture.tearDown(); + } + + @Test + void frozenHirer_cannotBeOfferedAContract() { + freeze(fixture.hirer); + + ContractHandler.Offer offer = fixture.company.getContractHandler() + .offer(fixture.hirer, ContractFixture.validTerms(1)); + + assertFalse(offer.ok()); + assertTrue(offer.message().contains("cannot hire mercenaries")); + } + + @Test + void frozenHirer_cannotSignAnOpenOffer() { + Government government = mock(Government.class); + when(government.isCouncilMember("signer")).thenReturn(true); + when(fixture.hirer.getGovernment()).thenReturn(government); + MercenaryContract contract = fixture.offer(ContractFixture.validTerms(1), System.currentTimeMillis()); + freeze(fixture.hirer); + + MercenaryResult result = fixture.company.getContractHandler() + .accept(contract.getId(), fixture.hirer, "signer"); + + assertFalse(result.ok()); + assertTrue(contract.isOffered()); + } + + @Test + void unfrozenHirer_canStillBeOffered() { + freeze(enemy); + + assertTrue(fixture.company.getContractHandler() + .offer(fixture.hirer, ContractFixture.validTerms(1)).ok()); + } + + @Test + void companyOfFrozenHost_holdsItsSlotExpansion() { + MercenaryCompany company = fixture.company; + int before = startSlotExpansion(company); + freeze(fixture.host.guild.getFaction()); + + company.tick(); + + assertEquals(before, company.getSlotQueue().get(0).getTimeLeft()); + } + + @Test + void companyServingAFrozenHirer_holdsItsSlotExpansion() { + MercenaryCompany company = fixture.company; + MercenaryContract contract = fixture.offer(ContractFixture.validTerms(1), System.currentTimeMillis()); + assertTrue(contract.activate()); + int before = startSlotExpansion(company); + freeze(fixture.hirer); + + company.tick(); + + assertEquals(before, company.getSlotQueue().get(0).getTimeLeft()); + } + + @Test + void companyOutsideTheWar_keepsExpanding() { + MercenaryCompany company = fixture.company; + int before = startSlotExpansion(company); + freeze(enemy); + + company.tick(); + + assertNotEquals(before, company.getSlotQueue().get(0).getTimeLeft()); + } + + private int startSlotExpansion(MercenaryCompany company) { + for (int i = 0; i < company.getSlots(); i++) { + company.enlist("Recruit" + i); + } + assertTrue(company.enqueueExpansion().ok()); + return company.getSlotQueue().get(0).getTimeLeft(); + } + + private void freeze(Faction side) { + Faction other = side == enemy ? ContractFixture.faction("bystander") : enemy; + War war = new War(wars.size() + 1, side, other); + wars.add(war); + PreparationFreeze.applyPostponement(war, Instant.now()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/war/freeze/PreparationFreezeTest.java b/src/test/java/net/tfminecraft/simplefactions/war/freeze/PreparationFreezeTest.java new file mode 100644 index 00000000..940829ec --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/war/freeze/PreparationFreezeTest.java @@ -0,0 +1,161 @@ +package net.tfminecraft.simplefactions.war.freeze; + +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.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +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.Cache; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.war.campaign.runtime.BattleScheduleService; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.simplefactions.war.enums.BattleSchedulePhase; + +class PreparationFreezeTest { + private static final Instant NOW = Instant.parse("2026-08-21T14:00:00Z"); + + private Faction attacker; + private Faction defender; + private Faction outsider; + private final List wars = new ArrayList<>(); + + @BeforeEach + void setUp() { + Cache.warPostponeFreezeHours = 24; + attacker = faction("atk"); + defender = faction("def"); + outsider = faction("other"); + PreparationFreeze.setActiveWars(() -> wars); + } + + @AfterEach + void tearDown() { + PreparationFreeze.setActiveWars(null); + wars.clear(); + } + + @Test + void postponement_freezesBothSidesForTheConfiguredHours() { + War war = war(); + + PreparationFreeze.applyPostponement(war, NOW); + + Instant until = NOW.plus(24, ChronoUnit.HOURS); + assertEquals(until, war.getPreparationFrozenUntil()); + assertEquals(until, PreparationFreeze.frozenUntil(attacker, NOW)); + assertEquals(until, PreparationFreeze.frozenUntil(defender, NOW)); + assertNull(PreparationFreeze.frozenUntil(outsider, NOW)); + } + + @Test + void freeze_endsAfterItsWindow() { + War war = war(); + PreparationFreeze.applyPostponement(war, NOW); + + Instant later = NOW.plus(24, ChronoUnit.HOURS); + assertNotNull(PreparationFreeze.frozenUntil(attacker, later.minusSeconds(1))); + assertNull(PreparationFreeze.frozenUntil(attacker, later)); + } + + @Test + void secondPostponement_stacksOnTheRemainingFreeze() { + War war = war(); + PreparationFreeze.applyPostponement(war, NOW); + PreparationFreeze.applyPostponement(war, NOW.plus(2, ChronoUnit.HOURS)); + + assertEquals(NOW.plus(48, ChronoUnit.HOURS), war.getPreparationFrozenUntil()); + } + + @Test + void endedWar_doesNotFreeze() { + War war = war(); + PreparationFreeze.applyPostponement(war, NOW); + war.end(net.tfminecraft.simplefactions.war.enums.WarEndReason.WHITE_PEACE); + + assertNull(PreparationFreeze.frozenUntil(attacker, NOW)); + } + + @Test + void postponement_pushesLiveRaidRepairLocksOnly() { + War war = war(); + Instant live = NOW.plus(10, ChronoUnit.HOURS); + Instant expired = NOW.minus(1, ChronoUnit.HOURS); + war.getRaidRepairLockUntil().put("fort-live", live); + war.getRaidRepairLockUntil().put("fort-old", expired); + + PreparationFreeze.applyPostponement(war, NOW); + + assertEquals(live.plus(24, ChronoUnit.HOURS), war.getRaidRepairLockUntil().get("fort-live")); + assertEquals(expired, war.getRaidRepairLockUntil().get("fort-old")); + } + + @Test + void zeroHours_disablesTheFreeze() { + Cache.warPostponeFreezeHours = 0; + War war = war(); + + PreparationFreeze.applyPostponement(war, NOW); + + assertNull(war.getPreparationFrozenUntil()); + } + + @Test + void hiringBlockedMessage_onlyForFrozenFactions() { + War war = war(); + PreparationFreeze.applyPostponement(war, NOW); + + String message = PreparationFreeze.hiringBlockedMessage(attacker, NOW); + assertNotNull(message); + assertTrue(message.contains("24h 0m")); + assertNull(PreparationFreeze.hiringBlockedMessage(outsider, NOW)); + } + + @Test + void battleSchedulePostpone_appliesTheFreeze() { + War war = war(); + war.setBattleDay(LocalDate.of(2026, 8, 21)); + war.setBattleSchedulePhase(BattleSchedulePhase.VOTING); + + BattleScheduleService.postpone(war, NOW); + + assertEquals(NOW.plus(24, ChronoUnit.HOURS), war.getPreparationFrozenUntil()); + } + + @Test + void skipBattleDay_appliesTheFreeze() { + War war = war(); + war.setBattleDay(LocalDate.of(2026, 8, 21)); + + BattleScheduleService.skipBattleDay(war, NOW); + + assertEquals(NOW.plus(24, ChronoUnit.HOURS), war.getPreparationFrozenUntil()); + assertFalse(war.isPreparationFrozen(NOW.plus(25, ChronoUnit.HOURS))); + } + + private War war() { + War war = new War(1, attacker, defender); + wars.add(war); + return war; + } + + private static Faction faction(String id) { + Faction faction = mock(Faction.class); + when(faction.getId()).thenReturn(id); + when(faction.getName()).thenReturn(id); + when(faction.getMembers()).thenReturn(List.of()); + return faction; + } +}