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