From 2377879551875f0ef6b93e1edb64d4dcb8bc5e94 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:27:55 +0000 Subject: [PATCH 1/2] Freeze war preparations when a battle is postponed Each postponement (vote stall, failed autoresolve, admin skip day) freezes, for 24h, every build timer of the factions in that war: regiment expansions, installation construction, war-related guild upgrades, and the formation, slot and upgrade queues of mercenary companies they host or hire. VFBuilders projects of their members hold through the new ConstructionFreeze service. Raid repair locks move back by the same amount and no new mercenaries can be hired meanwhile. Upkeep and wages keep running. Needs VFBuilders 2.0.4 for the vehicle project freeze; older builds are skipped with a warning. Co-Authored-By: Claude Opus 5.5 (1M context) --- pom.xml | 2 +- .../net/tfminecraft/simplefactions/Cache.java | 1 + .../simplefactions/SimpleFactions.java | 11 ++ .../simplefactions/database/WarData.java | 1 + .../simplefactions/guild/Guild.java | 10 +- .../simplefactions/guild/upgrade/Upgrade.java | 5 + .../simplefactions/loaders/ConfigLoader.java | 3 + .../managers/inventory/CompanyCreator.java | 7 + .../managers/inventory/GuildCreator.java | 5 + .../inventory/InstallationCreator.java | 3 + .../managers/inventory/MilitaryCreator.java | 3 + .../mercenary/company/MercenaryCompany.java | 19 +- .../mercenary/contract/ContractHandler.java | 19 ++ .../simplefactions/objects/Faction.java | 10 +- .../runtime/BattleScheduleService.java | 15 +- .../simplefactions/war/core/War.java | 14 ++ .../simplefactions/war/core/WarMapper.java | 6 + .../war/freeze/PreparationFreeze.java | 182 ++++++++++++++++++ .../freeze/VfBuildersConstructionFreeze.java | 41 ++++ src/main/resources/Guilds/upgrades.yml | 2 + src/main/resources/war.yml | 4 + .../war/core/WarMapperTest.java | 25 +++ .../PreparationFreezeMercenaryTest.java | 130 +++++++++++++ .../war/freeze/PreparationFreezeTest.java | 161 ++++++++++++++++ 24 files changed, 666 insertions(+), 13 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/war/freeze/PreparationFreeze.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/war/freeze/PreparationFreezeMercenaryTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/war/freeze/PreparationFreezeTest.java 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..34a0c712 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,20 @@ 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; + } + net.tfminecraft.simplefactions.war.freeze.VfBuildersConstructionFreeze.register(this); + constructionFreezeRegistered = true; } 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..8dfa2e32 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java @@ -0,0 +1,41 @@ +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 when VFBuilders is enabled. Older VFBuilders builds have no freeze + * API; they are skipped with a warning instead of failing SimpleFactions. + */ + public static void register(Plugin plugin) { + try { + Bukkit.getServicesManager().register( + ConstructionFreeze.class, new VfBuildersConstructionFreeze(), plugin, ServicePriority.Normal); + plugin.getLogger().info("VFBuilders projects freeze with postponed battles"); + } catch (LinkageError e) { + plugin.getLogger().warning("VFBuilders has no ConstructionFreeze API; update it so vehicle " + + "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; + } +} From ba9aacb1f84239df1e34b321db209d236e3ab637 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:48:30 +0000 Subject: [PATCH 2/2] Check for the VFBuilders freeze API before loading the provider Co-Authored-By: Claude Opus 5.5 (1M context) --- .../simplefactions/SimpleFactions.java | 10 +++++++++- .../war/freeze/VfBuildersConstructionFreeze.java | 15 +++++---------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index 34a0c712..f34a7028 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -595,8 +595,16 @@ private void registerConstructionFreeze() { if (constructionFreezeRegistered || !getServer().getPluginManager().isPluginEnabled("VFBuilders")) { return; } - net.tfminecraft.simplefactions.war.freeze.VfBuildersConstructionFreeze.register(this); 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/war/freeze/VfBuildersConstructionFreeze.java b/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java index 8dfa2e32..daaec6c7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java @@ -14,18 +14,13 @@ public final class VfBuildersConstructionFreeze implements ConstructionFreeze { /** - * Only call when VFBuilders is enabled. Older VFBuilders builds have no freeze - * API; they are skipped with a warning instead of failing SimpleFactions. + * 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) { - try { - Bukkit.getServicesManager().register( - ConstructionFreeze.class, new VfBuildersConstructionFreeze(), plugin, ServicePriority.Normal); - plugin.getLogger().info("VFBuilders projects freeze with postponed battles"); - } catch (LinkageError e) { - plugin.getLogger().warning("VFBuilders has no ConstructionFreeze API; update it so vehicle " - + "projects freeze with postponed battles"); - } + Bukkit.getServicesManager().register( + ConstructionFreeze.class, new VfBuildersConstructionFreeze(), plugin, ServicePriority.Normal); + plugin.getLogger().info("VFBuilders projects freeze with postponed battles"); } @Override