From 9e2c4b5f3b0f221f97d3f0cb2c172640e471363d Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:27:09 +0000 Subject: [PATCH 1/5] feat: hold a realm for a week after its last member dies Co-Authored-By: Claude Opus 5.5 --- .../simplefactions/SimpleFactions.java | 1 + .../simplefactions/database/Database.java | 2 +- .../settlement/DailyPreparationJournal.java | 3 +- .../economy/settlement/FactionUpkeepPlan.java | 15 ++ .../RealmSettlementCalculation.java | 3 +- .../estate/GuildEstateFallback.java | 10 +- .../simplefactions/guild/Guild.java | 12 +- .../simplefactions/guild/HeldRealms.java | 140 +++++++++++ .../guild/LeaderlessGuilds.java | 2 +- .../identity/LeaderCharacterListener.java | 9 +- .../identity/death/FactionDeaths.java | 28 +-- .../map/export/PublicFactionProjection.java | 17 +- .../war/declare/WarGoalValidator.java | 3 + .../DailyPreparationJournalTest.java | 22 ++ .../RealmSettlementCalculationTest.java | 11 + .../estate/GuildEstateFallbackTest.java | 17 ++ .../simplefactions/guild/HeldRealmsTest.java | 234 ++++++++++++++++++ .../simplefactions/guild/TestCharacters.java | 7 + .../identity/death/FactionDeathsTest.java | 24 +- .../export/PublicFactionProjectionTest.java | 14 ++ 20 files changed, 529 insertions(+), 45 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index 086c5760..41615c9f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -566,6 +566,7 @@ private void startServices() { try { net.tfminecraft.simplefactions.identity.death.CharacterDeaths.reconcile(); } catch (Exception error) { net.tfminecraft.simplefactions.economy.EconomyRuntime.failClosed(error); return; } net.tfminecraft.simplefactions.guild.LeaderlessGuilds.closeExpired(System.currentTimeMillis()); + net.tfminecraft.simplefactions.guild.HeldRealms.closeExpired(System.currentTimeMillis(), getDataFolder().toPath()); }, 72000L, 72000L); net.tfminecraft.simplefactions.identity.LeaderCharacterListener.refresh(null); } diff --git a/src/main/java/net/tfminecraft/simplefactions/database/Database.java b/src/main/java/net/tfminecraft/simplefactions/database/Database.java index de06d4b5..c9e2bbf6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/Database.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/Database.java @@ -425,7 +425,7 @@ private boolean saveFaction(Faction f, boolean durable) { gd.leader = g.getLeader(); gd.leaderCharacter = g.getLeaderCharacter(); gd.leaderCharacterOf = g.getLeaderCharacterOf(); - if (g.isLeaderless()) { + if (g.isLeaderless() || g.isHeld()) { gd.deadLeader = g.getDeadLeader(); gd.leaderlessSince = g.getLeaderlessSince(); gd.emptySince = g.getEmptySince(); diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournal.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournal.java index 768bf497..3bcb70bd 100644 --- a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournal.java +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournal.java @@ -129,7 +129,8 @@ private static FactionUpkeepPlan afterProvinceLoss(FactionUpkeepPlan plan, List< private static Integer removal(Faction faction) { List provinces = faction.getProvinces(); - if (CivilWarBorderLock.isLocked(faction) || !TitleManager.overProvinceCap(faction) || provinces.isEmpty()) return null; + if (CivilWarBorderLock.isLocked(faction) || net.tfminecraft.simplefactions.guild.HeldRealms.isHeld(faction) + || !TitleManager.overProvinceCap(faction) || provinces.isEmpty()) return null; int remove = provinces.getLast(); if (remove == faction.getCapital() && provinces.size() > 1) remove = provinces.get(provinces.size() - 2); return remove == faction.getCapital() ? null : remove; diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepPlan.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepPlan.java index bd161daf..f578f144 100644 --- a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepPlan.java +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepPlan.java @@ -21,6 +21,7 @@ public record FactionUpkeepPlan(String faction, Map regimentSlo long charge, double armyLedgerCost, double installationLedgerCost) { public static FactionUpkeepPlan calculate(Faction faction) { + if (net.tfminecraft.simplefactions.guild.HeldRealms.isHeld(faction)) return paused(faction); List regiments = new ArrayList<>(); if (faction.getMilitary() != null) { for (Regiment regiment : faction.getMilitary().getRegiments()) { @@ -73,6 +74,20 @@ public static FactionUpkeepPlan calculate(Faction faction) { return new FactionUpkeepPlan(faction.getId(), slots, installations, construction, charge, army, ledgerCost); } + /** A held realm pays nothing and keeps its army and installations as they are. */ + private static FactionUpkeepPlan paused(Faction faction) { + Map slots = new LinkedHashMap<>(); + if (faction.getMilitary() != null) { + for (Regiment regiment : faction.getMilitary().getRegiments()) { + if (regiment != null) slots.put(regiment.getId(), regiment.getCurrentSlots()); + } + } + List installations = faction.getInstallationHandler().getAll().stream() + .sorted(Comparator.comparing(Installation::getId)).map(Installation::toData).toList(); + return new FactionUpkeepPlan(faction.getId(), slots, new ArrayList<>(installations), + faction.getInstallationHandler().serializeConstruction(), 0, 0, 0); + } + private static double armyCost(List regiments, double factor) { double raw = 0; for (Regiment regiment : regiments) raw += regiment.getTotalUpkeep(); diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculation.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculation.java index 9005e724..89470c73 100644 --- a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculation.java +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculation.java @@ -193,7 +193,8 @@ private void liquidate(Guild guild, boolean moved) { change.incarnation = guild.getIncarnationId(); guild.getBranches().forEach((key, branch) -> change.branches.put(key, branch.getLevel())); changes.guilds.add(change); - if (!moved || guild.getBank() == null || net.tfminecraft.simplefactions.inactivity.InactivityService.isFullyInactive(guild.getMembers())) return; + if (!moved || guild.getBank() == null || net.tfminecraft.simplefactions.inactivity.InactivityService.isFullyInactive(guild.getMembers()) + || net.tfminecraft.simplefactions.guild.HeldRealms.isHeld(guild.getFaction())) return; int size = change.branches.values().stream().mapToInt(Integer::intValue).sum(); Random random = new Random(day ^ guild.getIncarnationId().getMostSignificantBits() ^ guild.getIncarnationId().getLeastSignificantBits()); for (int guard = 0; balances.get(account(guild)) < 0 && size > 0 && guard < 1000; guard++) { diff --git a/src/main/java/net/tfminecraft/simplefactions/estate/GuildEstateFallback.java b/src/main/java/net/tfminecraft/simplefactions/estate/GuildEstateFallback.java index 4b370130..ed3c715c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/estate/GuildEstateFallback.java +++ b/src/main/java/net/tfminecraft/simplefactions/estate/GuildEstateFallback.java @@ -7,6 +7,7 @@ import net.tfminecraft.simplefactions.api.SimpleFactionsApi; import net.tfminecraft.simplefactions.economy.settlement.SettlementGuilds; import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.HeldRealms; import net.tfminecraft.simplefactions.guild.LeaderlessGuilds; import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.identity.death.CharacterDeaths; @@ -30,7 +31,7 @@ public DeathGuild deathGuild(UUID characterId, UUID deathId) { case UNAVAILABLE -> DeathGuild.UNAVAILABLE; case NONE -> DeathGuild.NONE; case GUILD -> { - if (LeaderlessGuilds.closing(recorded.incarnation())) yield DeathGuild.UNAVAILABLE; + if (closing(recorded.incarnation())) yield DeathGuild.UNAVAILABLE; Guild live = byIncarnation(recorded.incarnation()); yield DeathGuild.guild(recorded.incarnation(), live != null, live == null ? Optional.empty() : leader(live), Optional.ofNullable(recorded.realm())); @@ -40,7 +41,7 @@ public DeathGuild deathGuild(UUID characterId, UUID deathId) { @Override public GuildLookup guild(UUID incarnation) { - if (incarnation == null || !SimpleFactionsApi.ready() || LeaderlessGuilds.closing(incarnation)) { + if (incarnation == null || !SimpleFactionsApi.ready() || closing(incarnation)) { return GuildLookup.UNAVAILABLE; } Guild guild = byIncarnation(incarnation); @@ -60,6 +61,11 @@ public GuildLookup guildByName(String name) { return matches.isEmpty() ? GuildLookup.GONE : GuildLookup.live(ref(matches.getFirst())); } + /** A guild being closed is neither live nor gone yet. */ + private static boolean closing(UUID incarnation) { + return LeaderlessGuilds.closing(incarnation) || HeldRealms.closing(incarnation, System.currentTimeMillis()); + } + private static Guild byIncarnation(UUID incarnation) { return FactionManager.getAllGuilds().stream() .filter(guild -> incarnation.toString().equalsIgnoreCase(guild.getIncarnation())) diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index 58cfbae9..3206fd8c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -551,7 +551,12 @@ public void restoreLeaderless(String deadKey, long since, long empty, String off /** Starts or stops the empty clock of a leaderless guild; true when it changed. */ public boolean trackEmpty(long now) { - long next = members.isEmpty() ? (emptySince > 0 ? emptySince : now) : 0; + return trackEmpty(now, members.isEmpty()); + } + + /** As {@link #trackEmpty(long)}, with emptiness judged by the caller (a held realm counts every guild). */ + public boolean trackEmpty(long now, boolean empty) { + long next = empty ? (emptySince > 0 ? emptySince : now) : 0; if (next == emptySince) return false; emptySince = next; return true; @@ -562,6 +567,11 @@ public boolean isLeaderless() { return !isBase() && leaderlessSince > 0 && (leader == null || leader.isBlank()); } + /** A realm's own guild whose last member died: the realm is held for a revive (HeldRealms). */ + public boolean isHeld() { + return isBase() && leaderlessSince > 0 && (getLeader() == null || getLeader().isBlank()); + } + public String getDeadLeader() { return deadLeader; } public long getLeaderlessSince() { return leaderlessSince; } public long getEmptySince() { return emptySince; } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java b/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java new file mode 100644 index 00000000..70767a2d --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java @@ -0,0 +1,140 @@ +package net.tfminecraft.simplefactions.guild; + +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; +import org.bukkit.entity.Player; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.tlibs.character.CharacterInfo; + +/** + * A realm whose last member died is held: its main guild keeps the dead leader, upkeep pauses and + * nobody can declare war on it. A revive puts the leader back; staff can seat someone with + * {@code /faction forcejoin} and {@code /faction forceleader}. A realm left with no members for + * {@link #HOLD} closes. + */ +public final class HeldRealms { + public static final Duration HOLD = Duration.ofDays(7); + /** Realms whose closure failed part way, by id, with their main guild's incarnation; retried hourly. */ + private static final Map unclosed = new ConcurrentHashMap<>(); + + private HeldRealms() {} + + public static boolean isHeld(Faction faction) { + if (faction == null || faction.getGuildHandler() == null) return false; + Guild main = faction.getGuildHandler().getGuild(faction.getId()); + return main != null && main.isHeld(); + } + + /** Holds a realm whose last member died; a replay keeps the first time. */ + public static void hold(Faction faction, String deadKey, long at) { + faction.getOrCreateMainGuild().markLeaderless(deadKey, at); + } + + /** Whether estates must wait: the realm's hold has run out, or its closure is not finished. */ + public static boolean closing(UUID incarnation, long now) { + if (incarnation == null) return false; + if (unclosed.containsValue(incarnation)) return true; + for (Faction faction : FactionManager.getCopy()) { + Guild main = faction.getGuildHandler().getGuild(faction.getId()); + if (main != null && incarnation.equals(main.getIncarnationId())) return expired(faction, main, now); + } + return false; + } + + /** The character came back to life: give back the realm it was holding, if nobody has taken it. */ + public static void revived(String key) { + for (Faction faction : FactionManager.getCopy()) { + Guild main = faction.getGuildHandler().getGuild(faction.getId()); + if (main != null && main.isHeld() && key.equals(main.getDeadLeader())) restore(faction, main); + } + } + + /** Hourly: gives realms back to revived leaders and closes those empty for {@link #HOLD}. */ + public static void closeExpired(long now, Path dataFolder) { + for (var entry : new ArrayList<>(unclosed.entrySet())) { + Faction faction = FactionManager.getByString(entry.getKey()); + Guild main = faction == null ? null : faction.getGuildHandler().getGuild(faction.getId()); + // A new realm may have taken the name and the file; it is left alone. + if (main != null && !entry.getValue().equals(main.getIncarnationId())) unclosed.remove(entry.getKey()); + else close(faction, entry.getKey(), entry.getValue(), dataFolder); + } + for (Faction faction : new ArrayList<>(FactionManager.factions)) { + Guild main = faction == null ? null : faction.getGuildHandler().getGuild(faction.getId()); + if (main == null || !main.isHeld() || restore(faction, main)) continue; + if (main.trackEmpty(now, faction.getMembers().isEmpty())) save(faction); + if (expired(faction, main, now)) close(faction, faction.getId(), main.getIncarnationId(), dataFolder); + } + } + + /** Removes a realm (null when already out of memory), its file and every other realm's relation with it. */ + private static void close(Faction faction, String id, UUID incarnation, Path dataFolder) { + try { + if (faction != null) FactionManager.deleteFaction(faction); + Files.deleteIfExists(dataFolder.resolve("Data/" + id + ".json")); + try (FileChannel directory = FileChannel.open(dataFolder.resolve("Data"), StandardOpenOption.READ)) { + directory.force(true); + } + for (Faction survivor : FactionManager.getCopy()) { + survivor.getDiplomacyHandler().removeRelation(id); + if (!new Database().saveFactionDurably(survivor)) throw new IOException("cannot save " + survivor.getId()); + } + unclosed.remove(id); + Logger.getLogger("SimpleFactions").info("Closed " + id + ": its last member died and nobody took it on within " + + HOLD.toDays() + " days"); + } catch (IOException error) { + unclosed.put(id, incarnation); + Logger.getLogger("SimpleFactions").severe("Could not finish closing " + id + " (" + error.getMessage() + + "); it is tried again next hour"); + } + } + + public static void resetForTests() { + unclosed.clear(); + } + + private static boolean expired(Faction faction, Guild main, long now) { + return main.isHeld() && faction.getMembers().isEmpty() && main.getEmptySince() > 0 + && now - main.getEmptySince() >= HOLD.toMillis(); + } + + /** True when the revived leader is back in charge; a failed save puts the hold back. */ + private static boolean restore(Faction faction, Guild main) { + String dead = main.getDeadLeader(); + if (dead == null || !faction.getMembers().isEmpty() || FactionManager.getByMember(dead) != null + || !Members.info(dead).map(CharacterInfo::alive).orElse(false)) return false; + long since = main.getLeaderlessSince(); + long empty = main.getEmptySince(); + faction.addMember(dead); + faction.setLeader(dead); + if (save(faction)) { + Logger.getLogger("SimpleFactions").info("Gave " + faction.getName() + " back to its revived leader " + dead); + Player owner = Members.online(dead).orElse(null); + if (owner != null) owner.sendMessage("§aYou lead " + faction.getName() + " §aagain."); + return true; + } + faction.succeedDeadLeader(dead, ""); + main.getMembers().remove(dead); + main.restoreLeaderless(dead, since, empty, null); + if (!save(faction)) Logger.getLogger("SimpleFactions").severe("Could not save " + faction.getId() + + " after undoing its return to a revived leader; its file may name them. Check it before restarting."); + return false; + } + + private static boolean save(Faction faction) { + if (new Database().saveFactionDurably(faction)) return true; + Logger.getLogger("SimpleFactions").warning("Could not save held realm " + faction.getId()); + return false; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/LeaderlessGuilds.java b/src/main/java/net/tfminecraft/simplefactions/guild/LeaderlessGuilds.java index 9b5f6fe4..d8b3a23d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/LeaderlessGuilds.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/LeaderlessGuilds.java @@ -57,7 +57,7 @@ public static boolean handle(Player p, String[] args) { /** "Name (dead)" in grey for an empty seat whose leader died, else null. */ public static String deadLeaderLabel(Guild guild) { - if (!guild.isLeaderless() || guild.getDeadLeader() == null) return null; + if (!guild.isLeaderless() && !guild.isHeld() || guild.getDeadLeader() == null) return null; return "§7" + Members.display(guild.getDeadLeader()) + " (dead)"; } diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java index dae186da..c3de8179 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java @@ -4,6 +4,7 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import net.tfminecraft.tlibs.character.CharacterRevivedEvent; import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; import net.tfminecraft.tlibs.character.Characters; @@ -11,7 +12,7 @@ import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.objects.Faction; -/** Refreshes map leader names when a shared character session is ready. */ +/** Refreshes map leader names when a shared character session is ready, and returns held realms on revive. */ public final class LeaderCharacterListener implements Listener { @EventHandler(priority = EventPriority.MONITOR) public void onCharacterStart(CharacterSessionStartEvent event) { @@ -23,6 +24,12 @@ public void onCharacterStart(CharacterSessionStartEvent event) { net.tfminecraft.simplefactions.guild.LeaderlessGuilds.remind(player); } + /** A revived leader takes back the realm held since their death. */ + @EventHandler(priority = EventPriority.MONITOR) + public void onRevived(CharacterRevivedEvent event) { + net.tfminecraft.simplefactions.guild.HeldRealms.revived(Members.key(event.getCharacter())); + } + /** * Re-reads every realm and guild leader's character (or only the * character with member key `player` when given) and, if any name diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java b/src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java index 81648ba3..858e6a6d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java @@ -6,10 +6,7 @@ import java.io.IOException; import java.math.BigDecimal; import java.math.RoundingMode; -import java.nio.channels.FileChannel; -import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; @@ -199,9 +196,11 @@ private boolean succeed(DeathJournal journal, JsonObject plan) throws IOExceptio String key = journal.characterId.toString(); Faction faction = realm(plan.get("realm").getAsString()); if (plan.get("emptyRealm").getAsBoolean() && plan.get("base").getAsBoolean()) { - if (faction != null && (!faction.getMembers().isEmpty() - || faction.getGuildHandler().getGuilds().stream().anyMatch(Guild::hasUnresolvedLeader))) return false; - disbandRealm(faction, plan.get("faction").getAsString()); + if (faction == null) return true; + if (!faction.getMembers().isEmpty() + || faction.getGuildHandler().getGuilds().stream().anyMatch(Guild::hasUnresolvedLeader)) return false; + net.tfminecraft.simplefactions.guild.HeldRealms.hold(faction, key, journal.at); + save(faction); return true; } if (faction == null) return true; @@ -293,23 +292,6 @@ private java.io.File warbandFile(String id) { return dataFolder.resolve("Warbands/warband_" + id + ".json").toFile(); } - private void disbandRealm(Faction faction, String id) throws IOException { - if (faction == null && FactionManager.getByString(id) != null) { - throw new IOException("A new realm uses the old death journal's name: " + id); - } - if (faction != null) { - FactionManager.deleteFaction(faction); - } - Files.deleteIfExists(dataFolder.resolve("Data/" + id + ".json")); - try (FileChannel directory = FileChannel.open(dataFolder.resolve("Data"), StandardOpenOption.READ)) { - directory.force(true); - } - for (Faction survivor : FactionManager.getCopy()) { - survivor.getDiplomacyHandler().removeRelation(id); - save(survivor); - } - } - /** Other realms' movements are saved here; the guild's own realm is saved by the caller. */ private static void leaveMovements(Guild guild, Faction own) throws IOException { for (Faction other : net.tfminecraft.simplefactions.guild.LeaderlessGuilds.withdrawFromMovements(guild)) { diff --git a/src/main/java/net/tfminecraft/simplefactions/map/export/PublicFactionProjection.java b/src/main/java/net/tfminecraft/simplefactions/map/export/PublicFactionProjection.java index 9bf6b458..0bef04c7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/map/export/PublicFactionProjection.java +++ b/src/main/java/net/tfminecraft/simplefactions/map/export/PublicFactionProjection.java @@ -15,7 +15,7 @@ public static JsonObject faction(JsonObject source) { for (String field : new String[] {"banner", "provinces", "titles", "relations", "trade relation", "treaty relation", "laws"}) { result.add(field, values(source, field)); } - leader(source, result); + leader(source, result, mainGuildDeadLeader(source)); JsonArray guilds = new JsonArray(); JsonArray members = new JsonArray(); if (source.has("guilds") && source.get("guilds").isJsonArray()) { @@ -34,7 +34,7 @@ public static JsonObject faction(JsonObject source) { private static JsonObject guild(JsonObject source) { JsonObject result = fields(source, "id", "name", "rgb", "type", "capital", "stance"); result.add("banner", values(source, "banner")); - leader(source, result); + leader(source, result, source.get("dead leader")); JsonArray members = new JsonArray(); for (JsonElement member : values(source, "members")) members.add(Members.display(member.getAsString())); result.add("members", members); @@ -48,10 +48,19 @@ private static JsonObject guild(JsonObject source) { return result; } - private static void leader(JsonObject source, JsonObject target) { + /** A held realm keeps its dead leader on its main guild, the one sharing the realm's id. */ + private static JsonElement mainGuildDeadLeader(JsonObject source) { + JsonElement id = source.get("id"); + if (id == null || !source.has("guilds") || !source.get("guilds").isJsonArray()) return null; + for (JsonElement entry : source.getAsJsonArray("guilds")) { + if (entry.isJsonObject() && id.equals(entry.getAsJsonObject().get("id"))) return entry.getAsJsonObject().get("dead leader"); + } + return null; + } + + private static void leader(JsonObject source, JsonObject target, JsonElement dead) { JsonElement stored = source.get("leader"); String key = stored != null && stored.isJsonPrimitive() ? stored.getAsString() : null; - JsonElement dead = source.get("dead leader"); boolean leaderless = (key == null || key.isBlank()) && dead != null && dead.isJsonPrimitive(); if (leaderless) key = dead.getAsString(); String name = Members.display(key) + (leaderless ? " (dead)" : ""); diff --git a/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidator.java b/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidator.java index 4c6e6b9b..1c41531d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidator.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/declare/WarGoalValidator.java @@ -70,6 +70,9 @@ private WarValidationResult validateShared(WarDeclareRequest request) { if (attacker.getId().equalsIgnoreCase(defender.getId())) { return WarValidationResult.fail("§cYou cannot declare war on your own faction."); } + if (net.tfminecraft.simplefactions.guild.HeldRealms.isHeld(defender)) { + return WarValidationResult.fail("§c" + defender.getName() + " §chas no living members and cannot be attacked."); + } War sharedWar = WarManager.findSharedActiveWar(attacker, defender); if (sharedWar != null) { Side attackerSide = sharedWar.getSide(attacker); diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournalTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournalTest.java index edd6ea42..2c132bc6 100644 --- a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournalTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournalTest.java @@ -57,6 +57,28 @@ class DailyPreparationJournalTest { } } + @Test void aHeldRealmKeepsItsProvincesAndArmyAndPaysNoUpkeep() throws Exception { + try (var disk = new PersistenceFilesFixture(); var fixture = new FactionDomainFixture(); var titles = mockStatic(TitleManager.class, CALLS_REAL_METHODS)) { + String leader = UUID.randomUUID().toString(); + var faction = fixture.saved("realm", leader); + faction.addProvince(1); faction.addProvince(2); + faction.setCapital(1, true); + titles.when(() -> TitleManager.overProvinceCap(faction)).thenReturn(true); + var guard = new net.tfminecraft.simplefactions.army.Regiment(fixture.regiment("guard", false, 2, 5)); + guard.setCurrentSlots(6); + faction.getMilitary().getRegiments().add(guard); + faction.succeedDeadLeader(leader, ""); + net.tfminecraft.simplefactions.guild.HeldRealms.hold(faction, leader, 1); + var database = mock(Database.class); + when(database.saveFactionDurably(faction)).thenReturn(true); + var prepared = new DailyPreparationJournal(disk.root.resolve("day-preparation"), database).prepare(2, 1000); + assertEquals(List.of(1, 2), faction.getProvinces()); + var upkeep = prepared.upkeep().getFirst(); + assertEquals(0, upkeep.charge()); + assertEquals(Map.of("guard", 6), upkeep.regimentSlots()); + } + } + @Test void malformedOrReplacedPreparationCannotStartAnotherDay() throws Exception { try (var disk = new PersistenceFilesFixture(); var fixture = new FactionDomainFixture()) { var faction = fixture.saved("realm", UUID.randomUUID().toString()); diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java index 83f3a4bd..4be963d0 100644 --- a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java @@ -99,6 +99,17 @@ RealmSettlementChanges changes(SettlementJournal.Plan plan) { assertEquals(2, changes(debt).guilds.stream().filter(c -> c.incarnation.equals(guild.getIncarnationId())).findFirst().orElseThrow().branches.get(0)); } + @Test void aHeldRealmKeepsItsBranchesThroughAShortfall() { + guild.getBranch(0).levelUp(); + guild.getBranch(0).levelUp(); + Ledger.setNodeUpkeepLookup(g -> g == guild ? 200 : 0); + String leader = faction.getLeader(); + faction.succeedDeadLeader(leader, ""); + net.tfminecraft.simplefactions.guild.HeldRealms.hold(faction, leader, 1); + var after = changes(plan(List.of())).guilds.stream().filter(c -> c.incarnation.equals(guild.getIncarnationId())).findFirst().orElseThrow(); + assertEquals(2, after.branches.get(0)); + } + @Test void fixedDeathDividendsReserveNormalPoolAndPartialWagesStayOwed() { net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(guild.getBank(), 10.0); var company = new MercenaryCompany(guild, "Company", null, 0); diff --git a/src/test/java/net/tfminecraft/simplefactions/estate/GuildEstateFallbackTest.java b/src/test/java/net/tfminecraft/simplefactions/estate/GuildEstateFallbackTest.java index b8a84b09..a74e2ebb 100644 --- a/src/test/java/net/tfminecraft/simplefactions/estate/GuildEstateFallbackTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/estate/GuildEstateFallbackTest.java @@ -177,6 +177,23 @@ static UUID ruling(Faction faction) { assertEquals(Optional.empty(), answer.leader()); } + @Test void aHeldRealmIsLiveWithoutALeaderUntilItsWeekRunsOut() throws Exception { + CharacterRef ruler = character(); + Faction faction = realm("realm", ruler); + clock = System.currentTimeMillis(); + UUID death = kill(ruler); + Guild main = faction.getGuildHandler().getGuild("realm"); + assertTrue(main.isHeld()); + var answer = (DeathGuild.Guild) fallback.deathGuild(ruler.characterId(), death); + assertTrue(answer.live()); + assertEquals(Optional.empty(), answer.leader()); + assertEquals(Optional.of(main.getIncarnationId()), answer.realm()); + main.restoreLeaderless(key(ruler), main.getLeaderlessSince(), + System.currentTimeMillis() - net.tfminecraft.simplefactions.guild.HeldRealms.HOLD.toMillis(), null); + assertEquals(DeathGuild.UNAVAILABLE, fallback.deathGuild(ruler.characterId(), death)); + assertEquals(GuildLookup.UNAVAILABLE, fallback.guild(main.getIncarnationId())); + } + @Test void anUnsavedClosureIsUnavailableUntilItsRealmIsSaved() throws Exception { CharacterRef master = character(); Faction faction = realm("realm", character()); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java new file mode 100644 index 00000000..7ba18f59 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java @@ -0,0 +1,234 @@ +package net.tfminecraft.simplefactions.guild; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedConstruction; + +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.identity.LeaderCharacterListener; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.war.declare.WarDeclareRequest; +import net.tfminecraft.simplefactions.war.declare.WarGoalValidator; +import net.tfminecraft.simplefactions.war.enums.WarGoalType; +import net.tfminecraft.tlibs.character.CharacterRevivedEvent; + +class HeldRealmsTest { + private static final long AT = 1_000_000L; + private static final long WEEK = HeldRealms.HOLD.toMillis(); + @TempDir Path folder; + private FactionDomainFixture fixture; + private TestCharacters characters; + private MockedConstruction databases; + private int failures; + private String ruler; + private Faction realm; + private Guild main; + + @BeforeEach + void setUp() throws Exception { + fixture = new FactionDomainFixture(); + characters = TestCharacters.install(); + databases = mockConstruction(Database.class, + (database, context) -> when(database.saveFactionDurably(any())).thenAnswer(call -> failures-- <= 0)); + ruler = characters.character(UUID.randomUUID(), "Queen Ada"); + realm = fixture.saved("realm", ruler); + main = realm.getOrCreateMainGuild(); + characters.kill(ruler); + main.removeDeadCharacter(ruler); + realm.succeedDeadLeader(ruler, ""); + HeldRealms.hold(realm, ruler, AT); + Files.createDirectories(folder.resolve("Data")); + Files.writeString(folder.resolve("Data/realm.json"), "{}"); + } + + @AfterEach + void tearDown() { + HeldRealms.resetForTests(); + databases.close(); + fixture.close(); + } + + private Faction survivor() { + Faction survivor = fixture.saved("survivor", characters.character(UUID.randomUUID(), "Bo Reed")); + survivor.getOrCreateMainGuild(); + fixture.subject(survivor, realm); + return survivor; + } + + @Test + void aHeldRealmShowsItsDeadLeaderUntilStaffSeatSomeone() { + assertTrue(realm.getMembers().isEmpty()); + assertTrue(HeldRealms.isHeld(realm)); + assertFalse(main.isLeaderless()); + assertFalse(HeldRealms.isHeld(null)); + assertEquals("§7Queen Ada (dead)", LeaderlessGuilds.deadLeaderLabel(main)); + + String kira = characters.character(UUID.randomUUID(), "Kira Vale"); + realm.addMember(kira); + realm.setLeader(kira); + assertFalse(HeldRealms.isHeld(realm)); + assertNull(main.getDeadLeader()); + assertNull(LeaderlessGuilds.deadLeaderLabel(main)); + } + + @Test + void aRealmNobodyTakesOnClosesAfterAWeek() { + Faction survivor = survivor(); + UUID incarnation = main.getIncarnationId(); + assertFalse(HeldRealms.closing(null, AT + WEEK)); + assertFalse(HeldRealms.closing(UUID.randomUUID(), AT + WEEK)); + assertFalse(HeldRealms.closing(incarnation, AT + WEEK - 1)); + + HeldRealms.closeExpired(AT + WEEK - 1, folder); + assertSame(realm, FactionManager.getByString("realm")); + assertTrue(HeldRealms.closing(incarnation, AT + WEEK)); + + HeldRealms.closeExpired(AT + WEEK, folder); + assertNull(FactionManager.getByString("realm")); + assertFalse(Files.exists(folder.resolve("Data/realm.json"))); + assertFalse(survivor.getDiplomacyHandler().getRelations().containsKey("realm")); + assertFalse(HeldRealms.closing(incarnation, AT + WEEK)); + } + + @Test + void aMemberStopsTheClockAndLeavingStartsItAgain() { + String kira = characters.character(UUID.randomUUID(), "Kira Vale"); + realm.addMember(kira); + HeldRealms.closeExpired(AT + WEEK, folder); + assertSame(realm, FactionManager.getByString("realm")); + assertEquals(0, main.getEmptySince()); + assertTrue(HeldRealms.isHeld(realm), "the seat stays empty until staff fill it"); + + main.getMembers().remove(kira); + HeldRealms.closeExpired(AT + WEEK + 1, folder); + assertEquals(AT + WEEK + 1, main.getEmptySince()); + assertSame(realm, FactionManager.getByString("realm")); + HeldRealms.closeExpired(AT + 2 * WEEK + 1, folder); + assertNull(FactionManager.getByString("realm")); + } + + @Test + void anUnfinishedClosureIsFinishedLaterAndHoldsEstatesMeanwhile() { + Faction survivor = survivor(); + UUID incarnation = main.getIncarnationId(); + failures = 1; + HeldRealms.closeExpired(AT + WEEK, folder); + assertNull(FactionManager.getByString("realm")); + assertTrue(HeldRealms.closing(incarnation, AT)); + + HeldRealms.closeExpired(AT + WEEK + 1, folder); + assertFalse(HeldRealms.closing(incarnation, AT)); + assertFalse(survivor.getDiplomacyHandler().getRelations().containsKey("realm")); + } + + @Test + void aNewRealmThatTookTheNameIsLeftAlone() throws Exception { + survivor(); + UUID incarnation = main.getIncarnationId(); + failures = 1; + HeldRealms.closeExpired(AT + WEEK, folder); + Faction newcomer = fixture.saved("realm", characters.character(UUID.randomUUID(), "Kira Vale")); + newcomer.getOrCreateMainGuild(); + Files.writeString(folder.resolve("Data/realm.json"), "new"); + + HeldRealms.closeExpired(AT + WEEK + 1, folder); + assertSame(newcomer, FactionManager.getByString("realm")); + assertEquals("new", Files.readString(folder.resolve("Data/realm.json"))); + assertFalse(HeldRealms.closing(incarnation, AT)); + } + + @Test + void aRevivedLeaderGetsTheRealmBack() { + Player ada = fixture.player("ada"); + characters.revive(ruler); + characters.play(ada, ruler); + HeldRealms.revived(ruler); + assertEquals(ruler, realm.getLeader()); + assertTrue(main.isMember(ruler)); + assertFalse(HeldRealms.isHeld(realm)); + verify(ada).sendMessage("§aYou lead " + realm.getName() + " §aagain."); + } + + @Test + void theHourlyCheckReturnsTheRealmWhenTheReviveWasMissed() { + characters.revive(ruler); + HeldRealms.closeExpired(AT + WEEK, folder); + assertSame(realm, FactionManager.getByString("realm")); + assertEquals(ruler, realm.getLeader()); + } + + @Test + void theReviveEventReturnsTheRealm() { + characters.revive(ruler); + new LeaderCharacterListener().onRevived( + new CharacterRevivedEvent(characters.info(ruler).orElseThrow().ref(), UUID.randomUUID())); + assertEquals(ruler, realm.getLeader()); + } + + @Test + void nobodyCanDeclareWarOnAHeldRealm() { + Faction attacker = fixture.saved("attacker", characters.character(UUID.randomUUID(), "Bo Reed")); + attacker.getOrCreateMainGuild(); + var result = new WarGoalValidator().validate(new WarDeclareRequest(attacker, realm, WarGoalType.WAR, null, null)); + assertFalse(result.isValid()); + assertEquals("§c" + realm.getName() + " §chas no living members and cannot be attacked.", result.getMessage()); + } + + @Test + void aFailedSaveKeepsTheRealmHeld() { + characters.revive(ruler); + failures = 1; + HeldRealms.revived(ruler); + assertTrue(HeldRealms.isHeld(realm)); + assertEquals(ruler, main.getDeadLeader()); + assertEquals(AT, main.getLeaderlessSince()); + assertEquals(AT, main.getEmptySince()); + assertTrue(realm.getMembers().isEmpty()); + assertEquals("", realm.getLeader()); + + failures = 2; + HeldRealms.revived(ruler); + assertTrue(HeldRealms.isHeld(realm)); + HeldRealms.revived(ruler); + assertEquals(ruler, realm.getLeader()); + } + + @Test + void aReviveDoesNotTakeBackARealmThatMovedOn() { + HeldRealms.revived(ruler); + assertTrue(HeldRealms.isHeld(realm), "still dead"); + + characters.revive(ruler); + Faction other = fixture.saved("other", characters.character(UUID.randomUUID(), "Bo Reed")); + other.addMember(ruler); + HeldRealms.revived(ruler); + assertTrue(HeldRealms.isHeld(realm), "joined another realm since"); + + other.getOrCreateMainGuild().getMembers().remove(ruler); + String kira = characters.character(UUID.randomUUID(), "Kira Vale"); + realm.addMember(kira); + HeldRealms.revived(kira); + HeldRealms.revived(ruler); + assertTrue(HeldRealms.isHeld(realm), "staff let someone in"); + assertFalse(realm.isMember(ruler)); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/TestCharacters.java b/src/test/java/net/tfminecraft/simplefactions/guild/TestCharacters.java index 5173467c..d633ddbe 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/TestCharacters.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/TestCharacters.java @@ -43,6 +43,13 @@ public void kill(String key) { info.traits(), info.playtimeSeconds(), info.lastPlayedAt())); } + /** Marks a registered character as alive again. */ + public void revive(String key) { + CharacterInfo info = characters.get(key); + characters.put(key, new CharacterInfo(info.ref(), info.name(), CharacterStatus.ALIVE, info.raceId(), + info.traits(), info.playtimeSeconds(), info.lastPlayedAt())); + } + /** Starts a session for {@code key} on that player; null ends it. */ public void play(Player player, String key) { if (key == null) playing.remove(player); diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/death/FactionDeathsTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/death/FactionDeathsTest.java index b75c0d05..0f5e941c 100644 --- a/src/test/java/net/tfminecraft/simplefactions/identity/death/FactionDeathsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/identity/death/FactionDeathsTest.java @@ -217,20 +217,23 @@ DeathJournal.Step step(DeathJournal journal, String kind) { assertEquals(started, faction.getGovernment().serialize().lastElectionDate); } - @Test void completelyEmptyRealmDeletesItsFileAndRelationsButPreservesClaims() throws Exception { + @Test void completelyEmptyRealmIsHeldWithItsRelationsAndClaims() throws Exception { Faction faction = realm(key); Faction survivor = fixture.saved("survivor", living()); survivor.getOrCreateMainGuild(); fixture.subject(survivor, faction); - Files.writeString(folder.resolve("Data/realm.json"), "old faction"); DeathJournal journal = capture(); store.record(journal); - assertNull(FactionManager.getByString("realm")); - assertFalse(Files.exists(folder.resolve("Data/realm.json"))); + assertSame(faction, FactionManager.getByString("realm")); + assertTrue(net.tfminecraft.simplefactions.guild.HeldRealms.isHeld(faction)); + assertEquals(key, faction.getOrCreateMainGuild().getDeadLeader()); + assertEquals(1000, faction.getOrCreateMainGuild().getEmptySince()); + assertTrue(survivor.getDiplomacyHandler().getRelations().containsKey("realm")); assertFalse(store.pendingPayments().isEmpty()); - effects.apply(journal, step(journal, "succession")); - fixture.saved("realm", living()).getOrCreateMainGuild(); - assertThrows(IOException.class, () -> effects.apply(journal, step(journal, "succession"))); + assertTrue(effects.apply(journal, step(journal, "succession"))); + assertEquals(1000, faction.getOrCreateMainGuild().getLeaderlessSince()); + FactionManager.deleteFaction(faction); + assertTrue(effects.apply(journal, step(journal, "succession"))); } @Test void changedRealmMembershipAndQuarantinePreventDestructiveSuccession() throws Exception { @@ -425,12 +428,13 @@ DeathJournal.Step step(DeathJournal journal, String kind) { assertTrue(membership.getPendingRejoin(dead.ownerId()).isFor(other)); } - @Test void lastMemberDeathAlsoDisbandsAnAlreadyVacantRealm() throws Exception { + @Test void lastMemberDeathAlsoHoldsAnAlreadyVacantRealm() throws Exception { Faction faction = realm(""); assertTrue(faction.getOrCreateMainGuild().getMembers().isEmpty()); - fixture.guild(faction, "last", key); + Guild last = fixture.guild(faction, "last", key); store.record(capture()); - assertNull(FactionManager.getByString("realm")); + assertTrue(net.tfminecraft.simplefactions.guild.HeldRealms.isHeld(faction)); + assertTrue(last.isLeaderless()); } @Test void warbandRemovalUsesCapturedCharacterAfterSessionHasEnded() throws Exception { diff --git a/src/test/java/net/tfminecraft/simplefactions/map/export/PublicFactionProjectionTest.java b/src/test/java/net/tfminecraft/simplefactions/map/export/PublicFactionProjectionTest.java index 5a976f51..8b6eee51 100644 --- a/src/test/java/net/tfminecraft/simplefactions/map/export/PublicFactionProjectionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/map/export/PublicFactionProjectionTest.java @@ -43,6 +43,20 @@ void missingOrMalformedPublicFieldsCannotCopyPrivateObjects() { .getAsJsonArray("guilds").size()); } + @Test + void heldRealmShowsItsDeadLeaderMarkedDead() { + var characters = net.tfminecraft.simplefactions.guild.TestCharacters.install(); + String dead = characters.character(java.util.UUID.randomUUID(), "Grim Hollow"); + var held = PublicFactionProjection.faction(JsonParser.parseString(""" + {"id":"realm","leader":"","guilds":[{"id":"other"},{"id":"realm","leader":"","dead leader":"%s"}]} + """.formatted(dead)).getAsJsonObject()); + assertEquals("Grim Hollow (dead)", held.get("leader character").getAsString()); + var open = PublicFactionProjection.faction(JsonParser.parseString(""" + {"id":"realm","leader":"","guilds":[{"id":"other","dead leader":"%s"}]} + """.formatted(dead)).getAsJsonObject()); + assertFalse(open.get("leader character").getAsString().contains("(dead)")); + } + @Test void emptySeatShowsTheDeadLeaderMarkedDead() { var characters = net.tfminecraft.simplefactions.guild.TestCharacters.install(); From 9efa3d91e546a647db8a82199182aa4b655b4fa4 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:48:50 +0000 Subject: [PATCH 2/5] fix: keep held realms safe through failed saves, invitations, taxes and wars Co-Authored-By: Claude Opus 5.5 --- .../simplefactions/guild/HeldRealms.java | 71 ++++++++++----- .../simplefactions/guild/income/Ledger.java | 11 ++- .../identity/death/FactionDeaths.java | 17 +++- .../managers/InviteCommands.java | 4 + .../managers/inventory/FactionCreator.java | 6 +- .../simplefactions/guild/HeldRealmsTest.java | 88 ++++++++++++++++++- .../income/LedgerSettlementCoverageTest.java | 26 ++++++ .../identity/death/FactionDeathsTest.java | 16 +++- 8 files changed, 200 insertions(+), 39 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java b/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java index 70767a2d..8f5fddd9 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java @@ -15,14 +15,15 @@ import net.tfminecraft.simplefactions.database.Database; import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.WarManager; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.tlibs.character.CharacterInfo; /** - * A realm whose last member died is held: its main guild keeps the dead leader, upkeep pauses and - * nobody can declare war on it. A revive puts the leader back; staff can seat someone with + * A realm whose last member died is held: its main guild keeps the dead leader, upkeep and taxes + * pause, and nobody can join it or declare war on it. A revive puts the leader back; staff can seat someone with * {@code /faction forcejoin} and {@code /faction forceleader}. A realm left with no members for - * {@link #HOLD} closes. + * {@link #HOLD} closes once it is at peace. */ public final class HeldRealms { public static final Duration HOLD = Duration.ofDays(7); @@ -37,18 +38,28 @@ public static boolean isHeld(Faction faction) { return main != null && main.isHeld(); } + /** The realm leader as the viewer sees it: "Name (dead)" while held. */ + public static String leaderName(Player viewer, Faction faction) { + String dead = isHeld(faction) ? LeaderlessGuilds.deadLeaderLabel(faction.getOrCreateMainGuild()) : null; + return dead != null ? dead : Members.display(viewer, faction.getLeader()); + } + /** Holds a realm whose last member died; a replay keeps the first time. */ public static void hold(Faction faction, String deadKey, long at) { faction.getOrCreateMainGuild().markLeaderless(deadKey, at); + faction.getInvited().clear(); } - /** Whether estates must wait: the realm's hold has run out, or its closure is not finished. */ + /** + * Whether estates must wait: the realm's week has run out (it may still be at war), or its closure is + * not finished. + */ public static boolean closing(UUID incarnation, long now) { if (incarnation == null) return false; if (unclosed.containsValue(incarnation)) return true; for (Faction faction : FactionManager.getCopy()) { Guild main = faction.getGuildHandler().getGuild(faction.getId()); - if (main != null && incarnation.equals(main.getIncarnationId())) return expired(faction, main, now); + if (main != null && incarnation.equals(main.getIncarnationId())) return weekOver(faction, main, now); } return false; } @@ -61,35 +72,45 @@ public static void revived(String key) { } } - /** Hourly: gives realms back to revived leaders and closes those empty for {@link #HOLD}. */ + /** + * Hourly: gives realms back to revived leaders and closes those empty for {@link #HOLD} and at + * peace. A realm whose revived leader could not be saved back in is kept for the next hour. + */ public static void closeExpired(long now, Path dataFolder) { for (var entry : new ArrayList<>(unclosed.entrySet())) { - Faction faction = FactionManager.getByString(entry.getKey()); - Guild main = faction == null ? null : faction.getGuildHandler().getGuild(faction.getId()); - // A new realm may have taken the name and the file; it is left alone. - if (main != null && !entry.getValue().equals(main.getIncarnationId())) unclosed.remove(entry.getKey()); - else close(faction, entry.getKey(), entry.getValue(), dataFolder); + // A realm still loaded is decided below; a new realm that took the name is left alone. + if (FactionManager.getByString(entry.getKey()) == null) close(null, entry.getKey(), entry.getValue(), dataFolder); + else unclosed.remove(entry.getKey()); } for (Faction faction : new ArrayList<>(FactionManager.factions)) { Guild main = faction == null ? null : faction.getGuildHandler().getGuild(faction.getId()); - if (main == null || !main.isHeld() || restore(faction, main)) continue; + if (main == null || !main.isHeld() || restore(faction, main) != Restore.NOT_ELIGIBLE) continue; if (main.trackEmpty(now, faction.getMembers().isEmpty())) save(faction); - if (expired(faction, main, now)) close(faction, faction.getId(), main.getIncarnationId(), dataFolder); + if (weekOver(faction, main, now) && !WarManager.isAtWar(faction)) { + close(faction, faction.getId(), main.getIncarnationId(), dataFolder); + } } } - /** Removes a realm (null when already out of memory), its file and every other realm's relation with it. */ + /** + * Removes every other realm's relation, trade agreement and treaty with the realm first, so a + * partial closure never leaves them for a new realm of the same name, then the realm and its file. + * {@code faction} is null when it already left memory. + */ private static void close(Faction faction, String id, UUID incarnation, Path dataFolder) { try { + for (Faction survivor : FactionManager.getCopy()) { + if (survivor == faction) continue; + survivor.getDiplomacyHandler().removeRelation(id); + survivor.getDiplomacyHandler().removeTradeRelation(id); + survivor.getDiplomacyHandler().removeTreatyRelation(id); + if (!new Database().saveFactionDurably(survivor)) throw new IOException("cannot save " + survivor.getId()); + } if (faction != null) FactionManager.deleteFaction(faction); Files.deleteIfExists(dataFolder.resolve("Data/" + id + ".json")); try (FileChannel directory = FileChannel.open(dataFolder.resolve("Data"), StandardOpenOption.READ)) { directory.force(true); } - for (Faction survivor : FactionManager.getCopy()) { - survivor.getDiplomacyHandler().removeRelation(id); - if (!new Database().saveFactionDurably(survivor)) throw new IOException("cannot save " + survivor.getId()); - } unclosed.remove(id); Logger.getLogger("SimpleFactions").info("Closed " + id + ": its last member died and nobody took it on within " + HOLD.toDays() + " days"); @@ -104,16 +125,18 @@ public static void resetForTests() { unclosed.clear(); } - private static boolean expired(Faction faction, Guild main, long now) { + private static boolean weekOver(Faction faction, Guild main, long now) { return main.isHeld() && faction.getMembers().isEmpty() && main.getEmptySince() > 0 && now - main.getEmptySince() >= HOLD.toMillis(); } - /** True when the revived leader is back in charge; a failed save puts the hold back. */ - private static boolean restore(Faction faction, Guild main) { + private enum Restore { NOT_ELIGIBLE, RESTORED, NOT_SAVED } + + /** Puts the revived leader back in charge; a failed save puts the hold back for the next try. */ + private static Restore restore(Faction faction, Guild main) { String dead = main.getDeadLeader(); if (dead == null || !faction.getMembers().isEmpty() || FactionManager.getByMember(dead) != null - || !Members.info(dead).map(CharacterInfo::alive).orElse(false)) return false; + || !Members.info(dead).map(CharacterInfo::alive).orElse(false)) return Restore.NOT_ELIGIBLE; long since = main.getLeaderlessSince(); long empty = main.getEmptySince(); faction.addMember(dead); @@ -122,14 +145,14 @@ private static boolean restore(Faction faction, Guild main) { Logger.getLogger("SimpleFactions").info("Gave " + faction.getName() + " back to its revived leader " + dead); Player owner = Members.online(dead).orElse(null); if (owner != null) owner.sendMessage("§aYou lead " + faction.getName() + " §aagain."); - return true; + return Restore.RESTORED; } faction.succeedDeadLeader(dead, ""); main.getMembers().remove(dead); main.restoreLeaderless(dead, since, empty, null); if (!save(faction)) Logger.getLogger("SimpleFactions").severe("Could not save " + faction.getId() + " after undoing its return to a revived leader; its file may name them. Check it before restarting."); - return false; + return Restore.NOT_SAVED; } private static boolean save(Faction faction) { diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java index c495b71c..8ca8c830 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java @@ -14,6 +14,7 @@ import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.HeldRealms; import net.tfminecraft.simplefactions.guild.income.entry.PlayerEntry; import net.tfminecraft.simplefactions.guild.loans.Loan; import net.tfminecraft.simplefactions.guild.loans.LoanFunding; @@ -724,7 +725,8 @@ private static boolean isDividendCashflow(Cashflow cashflow) { public double getOverlordTax() { Faction f = guild.getFaction(); Faction overlord = f.getOverlord(); - if (overlord == null) return 0.0; + // A held realm pays no overlord tax or tribute until it is taken on or closed. + if (overlord == null || HeldRealms.isHeld(f)) return 0.0; double base = getGrossTaxableIncome(); return base * f.getOverlordTaxRate(overlord); @@ -732,6 +734,7 @@ public double getOverlordTax() { public double getTributeTax() { Faction f = guild.getFaction(); + if (HeldRealms.isHeld(f)) return 0.0; double base = getInternalTaxableIncome(); double paid = 0.0; for (FactionModifier mod : f.getModifiers()) { @@ -745,7 +748,7 @@ public double getTributeTax() { public double getTributeRecieved() { double total = 0.0; for(Faction f : FactionManager.factions) { - if(f.getId().equals(guild.getFaction().getId())) continue; + if(f.getId().equals(guild.getFaction().getId()) || HeldRealms.isHeld(f)) continue; for(FactionModifier mod : f.getModifiers()) { if(mod.getFrom() == null) continue; if(!mod.getType().equals(FactionModifiers.TRIBUTE)) continue; @@ -892,7 +895,7 @@ public static Map>> collect f.getName(), Math.abs(ledger.getIncome(Cashflow.OVERLORD_TAX))); } - if (f.getModifiers() != null) { + if (f.getModifiers() != null && !HeldRealms.isHeld(f)) { double base = ledger.getInternalTaxableIncome(); for (FactionModifier mod : f.getModifiers()) { if (mod.getFrom() == null) continue; @@ -1129,7 +1132,7 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { case TRIBUTE_PAYMENTS: { if(!guild.isBase()) return; //only base pays Faction f = guild.getFaction(); - if (f == null || f.getModifiers() == null) return; + if (f == null || f.getModifiers() == null || HeldRealms.isHeld(f)) return; double base = getInternalTaxableIncome(); for (FactionModifier mod : f.getModifiers()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java b/src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java index 858e6a6d..f44ef23f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java @@ -172,8 +172,13 @@ private boolean removeRights(DeathJournal journal, JsonObject plan) throws IOExc Faction faction = realm(plan.get("realm").getAsString()); if (faction == null) return true; String key = journal.characterId.toString(); + boolean member = faction.isMember(key); for (Guild guild : faction.getGuildHandler().getGuilds()) guild.removeDeadCharacter(key); faction.succeedDeadLeader(key, ""); + // Held in the same save as the last member's removal, so a revive before succession still finds it. + if (member && faction.getMembers().isEmpty() && blank(faction.getLeader())) { + net.tfminecraft.simplefactions.guild.HeldRealms.hold(faction, key, journal.at); + } faction.getGovernment().removeDeadCharacter(key); boolean vacatedOffice = faction.getEspionage().removeDeadCharacter(new CharacterRef(journal.ownerId, journal.characterId), journal.deathId, journal.at); @@ -197,9 +202,11 @@ private boolean succeed(DeathJournal journal, JsonObject plan) throws IOExceptio Faction faction = realm(plan.get("realm").getAsString()); if (plan.get("emptyRealm").getAsBoolean() && plan.get("base").getAsBoolean()) { if (faction == null) return true; - if (!faction.getMembers().isEmpty() - || faction.getGuildHandler().getGuilds().stream().anyMatch(Guild::hasUnresolvedLeader)) return false; - net.tfminecraft.simplefactions.guild.HeldRealms.hold(faction, key, journal.at); + if (faction.getGuildHandler().getGuilds().stream().anyMatch(Guild::hasUnresolvedLeader)) return false; + // A seat filled or a member let in since is left to them and to staff. + if (faction.getMembers().isEmpty() && blank(faction.getLeader())) { + net.tfminecraft.simplefactions.guild.HeldRealms.hold(faction, key, journal.at); + } save(faction); return true; } @@ -299,6 +306,10 @@ private static void leaveMovements(Guild guild, Faction own) throws IOException } } + private static boolean blank(String value) { + return value == null || value.isBlank(); + } + private static Faction realm(String incarnation) { return FactionManager.getCopy().stream() .filter(faction -> faction.getOrCreateMainGuild().getIncarnation().equals(incarnation)) diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/InviteCommands.java b/src/main/java/net/tfminecraft/simplefactions/managers/InviteCommands.java index 9c0015f0..3dfcc375 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/InviteCommands.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/InviteCommands.java @@ -162,6 +162,10 @@ private static void joinFaction(Player p, String id) { p.sendMessage("§cNo faction with that name exists"); return; } + if (net.tfminecraft.simplefactions.guild.HeldRealms.isHeld(f)) { + p.sendMessage("§c" + f.getName() + " §chas no living members, so nobody can join it."); + return; + } if (!f.consumeInvite(Members.key(p))) { p.sendMessage("§cYou need to be invited to this faction by the leader first!"); return; diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java index a58e5c00..e90006fc 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FactionCreator.java @@ -93,7 +93,7 @@ public List listLore(Player p, Faction f) { if(realmSize > 0 && realmSize-f.getProvinces().size() > 0) lore.add(StringFormatter.formatHex("#d4c9aeRealm Size: #7a706a"+realmSize+" #a39ba8("+(realmSize-f.getProvinces().size())+" from subjects)")); else if(realmSize > 0) lore.add(StringFormatter.formatHex("#d4c9aeRealm Size: #7a706a"+realmSize)); lore.add(" "); - lore.add(StringFormatter.formatHex("#9c9775"+f.getRulerTitle()+": #c2bea7"+Members.display(p, f.getLeader()))); + lore.add(StringFormatter.formatHex("#9c9775"+f.getRulerTitle()+": #c2bea7"+net.tfminecraft.simplefactions.guild.HeldRealms.leaderName(p, f))); lore.add(StringFormatter.formatHex("#b8ae61Ruling System: #d4c9ae"+f.getGovernmentString())); lore.add(StringFormatter.formatHex("#b8ae61Culture: #d4c9ae"+f.getCulture())); lore.add(StringFormatter.formatHex("#b8ae61Religion: #d4c9ae"+f.getReligion())); @@ -292,7 +292,7 @@ public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { i = new ItemStack(Material.PLAYER_HEAD, 1); SkullMeta m = (SkullMeta) i.getItemMeta(); m.addItemFlags(ItemFlag.HIDE_ADDITIONAL_TOOLTIP); - m.setDisplayName(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+Members.display(p, f.getLeader()))); + m.setDisplayName(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+net.tfminecraft.simplefactions.guild.HeldRealms.leaderName(p, f))); MemberHeads.apply(m, f.getLeader()); List lore = new ArrayList(); lore.add(StringFormatter.formatHex("#b8ae61Ruling System: #d4c9ae"+f.getGovernmentString())); @@ -306,7 +306,7 @@ public ItemStack createMenuItem(Player p, Faction f, MenuItemType t) { MemberHeads.apply(m, f.getLeader()); List lore = new ArrayList(); Government gov = f.getGovernment(); - lore.add(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+Members.display(p, f.getLeader()))); + lore.add(StringFormatter.formatHex("#9c9775§l"+f.getRulerTitle()+": #c2bea7"+net.tfminecraft.simplefactions.guild.HeldRealms.leaderName(p, f))); double power = Formatter.formatDouble(gov.getPower()); double maxPower = Formatter.formatDouble(gov.getMaxPower()); String powerString = ((power < 0) ? "§c" : "") + power+"/"+((maxPower < 0) ? "§c" : "") + maxPower; diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java index 7ba18f59..806965fc 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java @@ -6,7 +6,9 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -20,8 +22,12 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.managers.InviteCommands; +import net.tfminecraft.simplefactions.managers.WarManager; import net.tfminecraft.simplefactions.identity.LeaderCharacterListener; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.objects.Faction; @@ -127,25 +133,51 @@ void aMemberStopsTheClockAndLeavingStartsItAgain() { } @Test - void anUnfinishedClosureIsFinishedLaterAndHoldsEstatesMeanwhile() { + void aFailedSurvivorSaveKeepsTheRealmUntilTheNextHour() { Faction survivor = survivor(); UUID incarnation = main.getIncarnationId(); failures = 1; HeldRealms.closeExpired(AT + WEEK, folder); - assertNull(FactionManager.getByString("realm")); + assertSame(realm, FactionManager.getByString("realm")); assertTrue(HeldRealms.closing(incarnation, AT)); HeldRealms.closeExpired(AT + WEEK + 1, folder); + assertNull(FactionManager.getByString("realm")); assertFalse(HeldRealms.closing(incarnation, AT)); assertFalse(survivor.getDiplomacyHandler().getRelations().containsKey("realm")); } + @Test + void closingRemovesTradeAgreementsAndTreaties() { + Faction survivor = survivor(); + survivor.getDiplomacyHandler().getTradeRelations().put("realm", null); + survivor.getDiplomacyHandler().getTreatyRelations().put("realm", null); + HeldRealms.closeExpired(AT + WEEK, folder); + assertFalse(survivor.getDiplomacyHandler().getTradeRelations().containsKey("realm")); + assertFalse(survivor.getDiplomacyHandler().getTreatyRelations().containsKey("realm")); + } + + @Test + void aClosureThatFailsAfterLeavingMemoryIsFinishedLater() throws Exception { + UUID incarnation = main.getIncarnationId(); + Files.delete(folder.resolve("Data/realm.json")); + Files.delete(folder.resolve("Data")); + HeldRealms.closeExpired(AT + WEEK, folder); + assertNull(FactionManager.getByString("realm")); + assertTrue(HeldRealms.closing(incarnation, AT)); + + Files.createDirectories(folder.resolve("Data")); + HeldRealms.closeExpired(AT + WEEK + 1, folder); + assertFalse(HeldRealms.closing(incarnation, AT)); + } + @Test void aNewRealmThatTookTheNameIsLeftAlone() throws Exception { - survivor(); UUID incarnation = main.getIncarnationId(); - failures = 1; + Files.delete(folder.resolve("Data/realm.json")); + Files.delete(folder.resolve("Data")); HeldRealms.closeExpired(AT + WEEK, folder); + Files.createDirectories(folder.resolve("Data")); Faction newcomer = fixture.saved("realm", characters.character(UUID.randomUUID(), "Kira Vale")); newcomer.getOrCreateMainGuild(); Files.writeString(folder.resolve("Data/realm.json"), "new"); @@ -156,6 +188,54 @@ void aNewRealmThatTookTheNameIsLeftAlone() throws Exception { assertFalse(HeldRealms.closing(incarnation, AT)); } + @Test + void aRealmAtWarClosesOnlyOnceTheWarIsOver() { + try (MockedStatic wars = mockStatic(WarManager.class, CALLS_REAL_METHODS)) { + wars.when(() -> WarManager.isAtWar(realm)).thenReturn(true); + HeldRealms.closeExpired(AT + WEEK, folder); + assertSame(realm, FactionManager.getByString("realm")); + assertTrue(HeldRealms.closing(main.getIncarnationId(), AT + WEEK), "estates keep waiting"); + } + HeldRealms.closeExpired(AT + WEEK + 1, folder); + assertNull(FactionManager.getByString("realm")); + } + + @Test + void nobodyCanJoinAHeldRealmOnAnOldInvitation() { + String kira = characters.character(UUID.randomUUID(), "Kira Vale"); + realm.getInvited().add(kira); + HeldRealms.hold(realm, ruler, AT); + assertTrue(realm.getInvited().isEmpty()); + + realm.getInvited().add(kira); + Player kiraPlayer = fixture.player("kira"); + characters.play(kiraPlayer, kira); + InviteCommands.handle(kiraPlayer, "faction", new String[] {"join", "realm"}); + verify(kiraPlayer).sendMessage("§c" + realm.getName() + " §chas no living members, so nobody can join it."); + assertFalse(realm.isMember(kira)); + } + + @Test + void menusShowTheDeadLeaderWhileTheRealmIsHeld() { + Player viewer = fixture.player("viewer"); + assertEquals("§7Queen Ada (dead)", HeldRealms.leaderName(viewer, realm)); + String kira = characters.character(UUID.randomUUID(), "Kira Vale"); + realm.addMember(kira); + realm.setLeader(kira); + assertEquals(Members.display(viewer, kira), HeldRealms.leaderName(viewer, realm)); + } + + @Test + void aRevivedLeaderWhoseSaveFailsAtTheDeadlineKeepsTheRealm() { + characters.revive(ruler); + failures = 1; + HeldRealms.closeExpired(AT + WEEK, folder); + assertSame(realm, FactionManager.getByString("realm")); + assertTrue(HeldRealms.isHeld(realm)); + HeldRealms.closeExpired(AT + WEEK + 1, folder); + assertEquals(ruler, realm.getLeader()); + } + @Test void aRevivedLeaderGetsTheRealmBack() { Player ada = fixture.player("ada"); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerSettlementCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerSettlementCoverageTest.java index 518b49a4..0c1fa7c2 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerSettlementCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerSettlementCoverageTest.java @@ -142,6 +142,32 @@ void vassalTaxAndGuildTaxHistoryMatchActualBufferedTransfers() { assertEquals(0, capital.getLedger().getOverlordTax()); } + @Test + void aHeldRealmPaysNoOverlordTaxOrTribute() { + Faction subject = fixture.saved("subject", "Cara"); + fixture.subject(home, subject); + Guild subjectCapital = subject.getOrCreateMainGuild(); + subjectCapital.getTradeBreakdown().setIncome(100); + Faction creditor = fixture.saved("creditor", "Dan"); + Guild creditorCapital = creditor.getOrCreateMainGuild(); + YamlConfiguration y = new YamlConfiguration(); + y.set("r.recieve-modifiers", List.of("TRIBUTE(10)")); + subject.getDiplomacyHandler().setRelation(creditor, + new Relation(new RelationType("tribute", y.getConfigurationSection("r")), RelationLoader.getDefaultAttitude())); + assertTrue(subjectCapital.getLedger().getTributeTax() > 0); + subject.succeedDeadLeader("Cara", ""); + net.tfminecraft.simplefactions.guild.HeldRealms.hold(subject, "Cara", 1); + assertEquals(0, subjectCapital.getLedger().getOverlordTax()); + assertEquals(0, capital.getLedger().getIncome(Cashflow.VASSALS)); + assertEquals(0, subjectCapital.getLedger().getTributeTax()); + assertEquals(0, creditorCapital.getLedger().getTributeRecieved()); + var history = Ledger.collectHistoryDay(List.of(subjectCapital)); + assertNull(history.getOrDefault(creditorCapital, Map.of()).get(LedgerHistory.Source.TRIBUTES)); + DailyGuildTransfers transfers = new DailyGuildTransfers(); + subjectCapital.getLedger().populateDailyTransfers(transfers); + assertTrue(transfers.getTransfers().getOrDefault(subjectCapital, Map.of()).values().stream().allMatch(v -> v == 0)); + } + @Test void bankruptPayersAndGuildsWithoutTradeDoNotPayReparations() { Faction creditor = fixture.saved("creditor", "Cara"); diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/death/FactionDeathsTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/death/FactionDeathsTest.java index 0f5e941c..991d26d3 100644 --- a/src/test/java/net/tfminecraft/simplefactions/identity/death/FactionDeathsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/identity/death/FactionDeathsTest.java @@ -240,8 +240,9 @@ DeathJournal.Step step(DeathJournal journal, String kind) { Faction faction = realm(key); DeathJournal journal = capture(); effects.apply(journal, step(journal, "rights")); + assertTrue(net.tfminecraft.simplefactions.guild.HeldRealms.isHeld(faction), "held with the last member's removal"); faction.getOrCreateMainGuild().addMember(living()); - assertFalse(effects.apply(journal, step(journal, "succession"))); + assertTrue(effects.apply(journal, step(journal, "succession")), "a member let in since is left to staff"); faction.getOrCreateMainGuild().getMembers().clear(); faction.setLeader(Members.QUARANTINED + "unresolved"); assertFalse(effects.apply(journal, step(journal, "succession"))); @@ -428,6 +429,19 @@ DeathJournal.Step step(DeathJournal journal, String kind) { assertTrue(membership.getPendingRejoin(dead.ownerId()).isFor(other)); } + @Test void aSeatFilledBeforeSuccessionIsNotHeldAgain() throws Exception { + Faction faction = realm(key); + DeathJournal journal = capture(); + effects.apply(journal, step(journal, "rights")); + String staffPick = living(); + faction.addMember(staffPick); + faction.setLeader(staffPick); + faction.getOrCreateMainGuild().getMembers().clear(); + assertTrue(effects.apply(journal, step(journal, "succession"))); + assertEquals(staffPick, faction.getLeader()); + assertFalse(net.tfminecraft.simplefactions.guild.HeldRealms.isHeld(faction)); + } + @Test void lastMemberDeathAlsoHoldsAnAlreadyVacantRealm() throws Exception { Faction faction = realm(""); assertTrue(faction.getOrCreateMainGuild().getMembers().isEmpty()); From ca7adcea605c4ae2c5df22685c166f8eff070be4 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:03:29 +0000 Subject: [PATCH 3/5] fix: finish begun closures, pause held realm vehicles and restart the week on join Co-Authored-By: Claude Opus 5.5 --- .../simplefactions/database/Database.java | 1 + .../simplefactions/database/GuildData.java | 3 ++ .../simplefactions/guild/Guild.java | 10 +++++ .../simplefactions/guild/HeldRealms.java | 21 +++++++--- .../vehicles/maintenance/UpkeepPause.java | 10 +++-- .../maintenance/VehicleSettlement.java | 1 + .../database/DatabaseCoverageTest.java | 12 ++++++ .../simplefactions/guild/HeldRealmsTest.java | 41 +++++++++++++++++-- .../maintenance/VehicleSettlementTest.java | 14 +++++++ .../maintenance/VehicleUpkeepServiceTest.java | 24 +++++++++++ 10 files changed, 125 insertions(+), 12 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/database/Database.java b/src/main/java/net/tfminecraft/simplefactions/database/Database.java index c9e2bbf6..04f8df19 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/Database.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/Database.java @@ -429,6 +429,7 @@ private boolean saveFaction(Faction f, boolean durable) { gd.deadLeader = g.getDeadLeader(); gd.leaderlessSince = g.getLeaderlessSince(); gd.emptySince = g.getEmptySince(); + if (g.getClosingSince() > 0) gd.closingSince = g.getClosingSince(); gd.leaderOffer = g.getLeaderOffer(); } gd.rgb = g.getRGB(); diff --git a/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java index 90491f5f..34148689 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java @@ -28,6 +28,9 @@ public class GuildData { /** When a leaderless guild was last left with no members; it closes 30 days later. */ @SerializedName("empty since") public Long emptySince; + /** When a held realm's closure began. */ + @SerializedName("closing since") + public Long closingSince; /** The character the realm leader has offered the empty seat to. */ @SerializedName("leader offer") public String leaderOffer; diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index 3206fd8c..9da9f95f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -114,6 +114,7 @@ public class Guild { private long leaderlessSince; private long emptySince; private String leaderOffer; + private long closingSince; public Guild(Faction f) { host = f; @@ -194,6 +195,7 @@ public Guild(GuildData data, Faction host) { this.leaderlessSince = data.leaderlessSince == null ? 0 : data.leaderlessSince; this.emptySince = data.emptySince == null ? 0 : data.emptySince; this.leaderOffer = data.leaderOffer; + this.closingSince = data.closingSince == null ? 0 : data.closingSince; this.rgb = data.rgb; this.capital = data.capital; this.stance = data.stance != null ? Stance.valueOf(data.stance) : Stance.NEUTRAL; @@ -483,6 +485,10 @@ public void addMember(String p) { } consumeInvite(p); members.add(p); + // Anyone let into a held realm stops its week; the hourly check starts it again once empty. + Guild main = isBase() ? this + : host == null || host.getGuildHandler() == null ? null : host.getGuildHandler().getGuild(host.getId()); + if (main != null && main.isHeld()) main.emptySince = 0; } public void kick(String member) { String stored = find(members, member); @@ -576,6 +582,9 @@ public boolean isHeld() { public long getLeaderlessSince() { return leaderlessSince; } public long getEmptySince() { return emptySince; } public String getLeaderOffer() { return leaderOffer; } + /** When a held realm's closure began; from then on it closes even if its leader is revived. */ + public long getClosingSince() { return closingSince; } + public void markClosing(long at) { closingSince = at; } public void offerLeadership(String key) { leaderOffer = key; } @@ -586,6 +595,7 @@ private void clearLeaderless() { leaderlessSince = 0; emptySince = 0; leaderOffer = null; + closingSince = 0; } public boolean isLeader(String p) { String currentLeader = getLeader(); diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java b/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java index 8f5fddd9..cfee403e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java @@ -79,7 +79,7 @@ public static void revived(String key) { public static void closeExpired(long now, Path dataFolder) { for (var entry : new ArrayList<>(unclosed.entrySet())) { // A realm still loaded is decided below; a new realm that took the name is left alone. - if (FactionManager.getByString(entry.getKey()) == null) close(null, entry.getKey(), entry.getValue(), dataFolder); + if (FactionManager.getByString(entry.getKey()) == null) close(null, entry.getKey(), entry.getValue(), now, dataFolder); else unclosed.remove(entry.getKey()); } for (Faction faction : new ArrayList<>(FactionManager.factions)) { @@ -87,18 +87,27 @@ public static void closeExpired(long now, Path dataFolder) { if (main == null || !main.isHeld() || restore(faction, main) != Restore.NOT_ELIGIBLE) continue; if (main.trackEmpty(now, faction.getMembers().isEmpty())) save(faction); if (weekOver(faction, main, now) && !WarManager.isAtWar(faction)) { - close(faction, faction.getId(), main.getIncarnationId(), dataFolder); + close(faction, faction.getId(), main.getIncarnationId(), now, dataFolder); } } } /** - * Removes every other realm's relation, trade agreement and treaty with the realm first, so a - * partial closure never leaves them for a new realm of the same name, then the realm and its file. + * Saves the realm as closing, so a revive can no longer take back a realm whose relations are + * partly gone; removes every other realm's relation, trade agreement and treaty with it, so a + * partial closure never leaves them for a new realm of the same name; then the realm and its file. * {@code faction} is null when it already left memory. */ - private static void close(Faction faction, String id, UUID incarnation, Path dataFolder) { + private static void close(Faction faction, String id, UUID incarnation, long now, Path dataFolder) { try { + Guild main = faction == null ? null : faction.getOrCreateMainGuild(); + if (main != null && main.getClosingSince() == 0) { + main.markClosing(now); + if (!save(faction)) { + main.markClosing(0); + throw new IOException("cannot save " + id + " as closing"); + } + } for (Faction survivor : FactionManager.getCopy()) { if (survivor == faction) continue; survivor.getDiplomacyHandler().removeRelation(id); @@ -135,7 +144,7 @@ private enum Restore { NOT_ELIGIBLE, RESTORED, NOT_SAVED } /** Puts the revived leader back in charge; a failed save puts the hold back for the next try. */ private static Restore restore(Faction faction, Guild main) { String dead = main.getDeadLeader(); - if (dead == null || !faction.getMembers().isEmpty() || FactionManager.getByMember(dead) != null + if (dead == null || main.getClosingSince() > 0 || !faction.getMembers().isEmpty() || FactionManager.getByMember(dead) != null || !Members.info(dead).map(CharacterInfo::alive).orElse(false)) return Restore.NOT_ELIGIBLE; long since = main.getLeaderlessSince(); long empty = main.getEmptySince(); diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/UpkeepPause.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/UpkeepPause.java index b42e2a1b..7fd43e48 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/UpkeepPause.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/UpkeepPause.java @@ -1,14 +1,17 @@ package net.tfminecraft.simplefactions.vehicles.maintenance; import net.tfminecraft.simplefactions.estate.EstateBridge; +import net.tfminecraft.simplefactions.guild.HeldRealms; import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService; import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; import net.tfminecraft.tlibs.character.CharacterStatus; /** * A dead payer's vehicles take no new unpaid days and no upkeep damage while they wait for - * the estate: an unpaid marker set before the death stays until the recipient pays. + * the estate: an unpaid marker set before the death stays until the recipient pays. A held + * realm's pool and installation vehicles are paused the same way. */ final class UpkeepPause { private UpkeepPause() {} @@ -21,8 +24,9 @@ static boolean paused(String payer) { static boolean paused(PlayerVehicleRegistry registry, String vehicle) { return registry.getByVehicleUuid(vehicle) - .filter(row -> row.getMode() == OwnershipMode.PERSONAL && row.getPlayerUuid() != null) - .map(row -> paused(row.getPlayerUuid().toString())) + .map(row -> row.getMode() == OwnershipMode.PERSONAL + ? row.getPlayerUuid() != null && paused(row.getPlayerUuid().toString()) + : HeldRealms.isHeld(FactionVehiclePoolService.payingFaction(row))) .orElse(false); } } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlement.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlement.java index 7cab9dfc..a48d1fac 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlement.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlement.java @@ -87,6 +87,7 @@ var record = registry.getByVehicleUuid(vehicle.vehicle()).orElse(null); if (record.getMode() == OwnershipMode.PERSONAL) continue; long upkeep = Money.cents(VehiclesConfigLoader.getUpkeep(record.getVehicleTypeId())); var faction = FactionVehiclePoolService.payingFaction(record); + if (net.tfminecraft.simplefactions.guild.HeldRealms.isHeld(faction)) continue; if (upkeep <= 0 || (faction == null && record.getMode() == OwnershipMode.INSTALLATION)) { unpaid.remove(record.getVehicleUuid()); continue; diff --git a/src/test/java/net/tfminecraft/simplefactions/database/DatabaseCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/database/DatabaseCoverageTest.java index 082bd199..f52b1609 100644 --- a/src/test/java/net/tfminecraft/simplefactions/database/DatabaseCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/database/DatabaseCoverageTest.java @@ -283,6 +283,18 @@ void absentNonDirectoryAndMalformedStoresDoNotProduceObjects(String kind) throws private List load(String kind) { return switch(kind) {case "Wars" -> database.loadWars();case "Battles" -> database.loadBattles();default -> database.loadWarbands();}; } + @Test void savingAHeldRealmKeepsItsDeadLeaderAndClosingTime() throws Exception { + Files.createDirectories(disk.root.resolve("Data")); + Faction realm=fixture.saved("realm","Alice"); + Guild main=realm.getOrCreateMainGuild(); + main.kick("Alice");realm.succeedDeadLeader("Alice","");main.markLeaderless("Alice",42L);main.markClosing(99L); + assertTrue(database.saveFactionChecked(realm)); + FactionData saved=JsonUtil.readJson(disk.root.resolve("Data/realm.json").toFile(),FactionData.class); + GuildData stored=saved.guilds.stream().filter(entry->"realm".equals(entry.id)).findFirst().orElseThrow(); + assertEquals("Alice",stored.deadLeader);assertEquals(42L,stored.leaderlessSince);assertEquals(99L,stored.closingSince); + assertEquals(99L,new Guild(stored,realm).getClosingSince()); + } + @Test void savingALeaderlessGuildKeepsItsDeadLeaderAndOffer() throws Exception { Files.createDirectories(disk.root.resolve("Data")); Faction realm=fixture.saved("realm","Alice"); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java index 806965fc..f11db259 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java @@ -45,6 +45,7 @@ class HeldRealmsTest { private TestCharacters characters; private MockedConstruction databases; private int failures; + private Faction failFor; private String ruler; private Faction realm; private Guild main; @@ -54,7 +55,8 @@ void setUp() throws Exception { fixture = new FactionDomainFixture(); characters = TestCharacters.install(); databases = mockConstruction(Database.class, - (database, context) -> when(database.saveFactionDurably(any())).thenAnswer(call -> failures-- <= 0)); + (database, context) -> when(database.saveFactionDurably(any())) + .thenAnswer(call -> call.getArgument(0) != failFor && failures-- <= 0)); ruler = characters.character(UUID.randomUUID(), "Queen Ada"); realm = fixture.saved("realm", ruler); main = realm.getOrCreateMainGuild(); @@ -133,20 +135,53 @@ void aMemberStopsTheClockAndLeavingStartsItAgain() { } @Test - void aFailedSurvivorSaveKeepsTheRealmUntilTheNextHour() { + void aClosureThatHasBegunFinishesEvenIfTheLeaderIsRevived() { Faction survivor = survivor(); UUID incarnation = main.getIncarnationId(); - failures = 1; + failFor = survivor; HeldRealms.closeExpired(AT + WEEK, folder); assertSame(realm, FactionManager.getByString("realm")); + assertEquals(AT + WEEK, main.getClosingSince()); assertTrue(HeldRealms.closing(incarnation, AT)); + characters.revive(ruler); + HeldRealms.revived(ruler); + assertTrue(HeldRealms.isHeld(realm), "a closure that has begun is not undone"); + failFor = null; HeldRealms.closeExpired(AT + WEEK + 1, folder); assertNull(FactionManager.getByString("realm")); assertFalse(HeldRealms.closing(incarnation, AT)); assertFalse(survivor.getDiplomacyHandler().getRelations().containsKey("realm")); } + @Test + void aRealmThatCannotBeSavedAsClosingIsLeftUntouchedUntilTheNextHour() { + Faction survivor = survivor(); + failures = 1; + HeldRealms.closeExpired(AT + WEEK, folder); + assertSame(realm, FactionManager.getByString("realm")); + assertEquals(0, main.getClosingSince()); + assertTrue(survivor.getDiplomacyHandler().getRelations().containsKey("realm")); + assertTrue(HeldRealms.closing(main.getIncarnationId(), AT)); + HeldRealms.closeExpired(AT + WEEK + 1, folder); + assertNull(FactionManager.getByString("realm")); + } + + @Test + void lettingSomeoneInStopsTheWeekAtOnce() { + Guild smiths = fixture.guild(realm, "smiths", characters.character(UUID.randomUUID(), "Bo Reed")); + smiths.getMembers().clear(); + main.restoreLeaderless(ruler, AT, AT, null); + smiths.addMember(characters.character(UUID.randomUUID(), "Kira Vale")); + assertEquals(0, main.getEmptySince()); + smiths.getMembers().clear(); + realm.addMember(characters.character(UUID.randomUUID(), "Lin Moss")); + main.getMembers().clear(); + HeldRealms.closeExpired(AT + WEEK, folder); + assertSame(realm, FactionManager.getByString("realm"), "the week starts again from the next check"); + assertEquals(AT + WEEK, main.getEmptySince()); + } + @Test void closingRemovesTradeAgreementsAndTreaties() { Faction survivor = survivor(); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlementTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlementTest.java index d9db5710..22ac3ae5 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlementTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlementTest.java @@ -215,6 +215,20 @@ Faction faction() { assertTrue(maintenance.isUnpaid("orphan")); } + @Test void aHeldRealmsVehiclesAreNotChargedAndKeepTheirMarkers() { + var owed = vehicle("owed", "ship", null, OwnershipMode.POOL); + var fresh = vehicle("fresh", "ship", null, OwnershipMode.INSTALLATION); + maintenance.markUnpaid("owed", 1); + Faction held = faction(); + when(held.getOrCreateMainGuild().isHeld()).thenReturn(true); + pools.when(() -> FactionVehiclePoolService.payingFaction(owed)).thenReturn(held); + pools.when(() -> FactionVehiclePoolService.payingFaction(fresh)).thenReturn(held); + calculate(); + assertEquals(1, after().get("owed").getAsLong()); + assertFalse(after().has("fresh")); + assertTrue(legs.isEmpty()); + } + @Test void durableAfterValuesReplayWithoutChangingUnpaidAgesOrChargingAgain() throws Exception { vehicle("v", "ship", null, OwnershipMode.PERSONAL); calculate(); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepServiceTest.java index 1ea7abaf..98d322aa 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepServiceTest.java @@ -189,6 +189,30 @@ void hourlyDecaySparesADeadPayersVehicleWhileItWaitsForTheEstate() { } } + @Test + void hourlyDecaySparesAHeldRealmsVehicles() { + registry.register(new PlayerVehicleRecord(null, "pooled", "ironclad", OwnershipMode.POOL, null)); + registry.register(new PlayerVehicleRecord(null, "berthed", "ironclad", OwnershipMode.INSTALLATION, null)); + store.markUnpaid("pooled", 1L); + store.markUnpaid("berthed", 1L); + Faction held = org.mockito.Mockito.mock(Faction.class); + var handler = org.mockito.Mockito.mock(net.tfminecraft.simplefactions.objects.handler.GuildHandler.class); + var main = org.mockito.Mockito.mock(net.tfminecraft.simplefactions.guild.Guild.class); + org.mockito.Mockito.when(held.getId()).thenReturn("realm"); + org.mockito.Mockito.when(held.getGuildHandler()).thenReturn(handler); + org.mockito.Mockito.when(handler.getGuild("realm")).thenReturn(main); + org.mockito.Mockito.when(main.isHeld()).thenReturn(true); + try (var pools = mockStatic(net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService.class)) { + pools.when(() -> net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService.payingFaction( + org.mockito.ArgumentMatchers.argThat(row -> row != null && "pooled".equals(row.getVehicleUuid())))) + .thenReturn(held); + service.tickHourlyDecay(); + assertEquals(Set.of("berthed"), + decayApi.calls.stream().map(call -> call.uuid).collect(java.util.stream.Collectors.toSet())); + assertTrue(store.isUnpaid("pooled")); + } + } + @Test void hourlyDecayDamagesUnpaidVehicles() { store.markUnpaid("vehicle-1", 1L); From e0bf61af295e8b702fc4724751429aac0dce08c6 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:13:17 +0000 Subject: [PATCH 4/5] fix: hold estates for the whole time a realm is held and always finish a begun closure Co-Authored-By: Claude Opus 5.5 --- .../estate/GuildEstateFallback.java | 4 +-- .../simplefactions/guild/HeldRealms.java | 21 ++++++++++++--- .../managers/CommandManager.java | 8 ++++++ .../estate/GuildEstateFallbackTest.java | 15 +++++------ .../simplefactions/guild/HeldRealmsTest.java | 27 ++++++++++--------- .../managers/CommandManagerCoverageTest.java | 13 +++++++++ 6 files changed, 62 insertions(+), 26 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/estate/GuildEstateFallback.java b/src/main/java/net/tfminecraft/simplefactions/estate/GuildEstateFallback.java index ed3c715c..82307d33 100644 --- a/src/main/java/net/tfminecraft/simplefactions/estate/GuildEstateFallback.java +++ b/src/main/java/net/tfminecraft/simplefactions/estate/GuildEstateFallback.java @@ -61,9 +61,9 @@ public GuildLookup guildByName(String name) { return matches.isEmpty() ? GuildLookup.GONE : GuildLookup.live(ref(matches.getFirst())); } - /** A guild being closed is neither live nor gone yet. */ + /** A guild being closed, or a held realm, is neither live nor gone yet. */ private static boolean closing(UUID incarnation) { - return LeaderlessGuilds.closing(incarnation) || HeldRealms.closing(incarnation, System.currentTimeMillis()); + return LeaderlessGuilds.closing(incarnation) || HeldRealms.unsettled(incarnation); } private static Guild byIncarnation(UUID incarnation) { diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java b/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java index cfee403e..853fa38b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java @@ -32,6 +32,15 @@ public final class HeldRealms { private HeldRealms() {} + public static final String CLOSING = "§cThis faction's week ran out and it is being closed."; + + /** Whether the realm's closure has begun; it then always finishes. */ + public static boolean isClosing(Faction faction) { + if (faction == null || faction.getGuildHandler() == null) return false; + Guild main = faction.getGuildHandler().getGuild(faction.getId()); + return main != null && main.getClosingSince() > 0; + } + public static boolean isHeld(Faction faction) { if (faction == null || faction.getGuildHandler() == null) return false; Guild main = faction.getGuildHandler().getGuild(faction.getId()); @@ -51,15 +60,15 @@ public static void hold(Faction faction, String deadKey, long at) { } /** - * Whether estates must wait: the realm's week has run out (it may still be at war), or its closure is - * not finished. + * Whether estate lookups must wait: the realm is held or its closure is not finished. Estates of deaths + * in it then pass once it is taken on (live with a leader) or closed (gone), and a revive cancels them. */ - public static boolean closing(UUID incarnation, long now) { + public static boolean unsettled(UUID incarnation) { if (incarnation == null) return false; if (unclosed.containsValue(incarnation)) return true; for (Faction faction : FactionManager.getCopy()) { Guild main = faction.getGuildHandler().getGuild(faction.getId()); - if (main != null && incarnation.equals(main.getIncarnationId())) return weekOver(faction, main, now); + if (main != null && incarnation.equals(main.getIncarnationId())) return main.isHeld() || main.getClosingSince() > 0; } return false; } @@ -84,6 +93,10 @@ public static void closeExpired(long now, Path dataFolder) { } for (Faction faction : new ArrayList<>(FactionManager.factions)) { Guild main = faction == null ? null : faction.getGuildHandler().getGuild(faction.getId()); + if (main != null && main.getClosingSince() > 0) { + close(faction, faction.getId(), main.getIncarnationId(), now, dataFolder); + continue; + } if (main == null || !main.isHeld() || restore(faction, main) != Restore.NOT_ELIGIBLE) continue; if (main.trackEmpty(now, faction.getMembers().isEmpty())) save(faction); if (weekOver(faction, main, now) && !WarManager.isAtWar(faction)) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java index d6161d58..da157527 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java @@ -1455,6 +1455,10 @@ else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("forced p.sendMessage("§a[SimpleFactions]§c Error! faction does not exist!"); return true; } + if(net.tfminecraft.simplefactions.guild.HeldRealms.isClosing(f)) { + p.sendMessage(net.tfminecraft.simplefactions.guild.HeldRealms.CLOSING); + return true; + } String heir = Members.find(f.getMembers(), rest(args, 2)); if(heir == null) { p.sendMessage("§cPlayer is not in the faction"); @@ -1485,6 +1489,10 @@ else if(cmd.getName().equalsIgnoreCase(cmd1) && args[0].equalsIgnoreCase("forced p.sendMessage("§a[SimpleFactions]§c Error! faction does not exist!"); return true; } + if(net.tfminecraft.simplefactions.guild.HeldRealms.isClosing(f)) { + p.sendMessage(net.tfminecraft.simplefactions.guild.HeldRealms.CLOSING); + return true; + } String joiner = Members.findOnline(args[2]); if(joiner == null) { p.sendMessage("§cNo online character or character ID matches "+args[2]); diff --git a/src/test/java/net/tfminecraft/simplefactions/estate/GuildEstateFallbackTest.java b/src/test/java/net/tfminecraft/simplefactions/estate/GuildEstateFallbackTest.java index a74e2ebb..0e007b6d 100644 --- a/src/test/java/net/tfminecraft/simplefactions/estate/GuildEstateFallbackTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/estate/GuildEstateFallbackTest.java @@ -177,21 +177,20 @@ static UUID ruling(Faction faction) { assertEquals(Optional.empty(), answer.leader()); } - @Test void aHeldRealmIsLiveWithoutALeaderUntilItsWeekRunsOut() throws Exception { + @Test void aHeldRealmIsUnavailableUntilStaffTakeItOn() throws Exception { CharacterRef ruler = character(); Faction faction = realm("realm", ruler); - clock = System.currentTimeMillis(); UUID death = kill(ruler); Guild main = faction.getGuildHandler().getGuild("realm"); assertTrue(main.isHeld()); - var answer = (DeathGuild.Guild) fallback.deathGuild(ruler.characterId(), death); - assertTrue(answer.live()); - assertEquals(Optional.empty(), answer.leader()); - assertEquals(Optional.of(main.getIncarnationId()), answer.realm()); - main.restoreLeaderless(key(ruler), main.getLeaderlessSince(), - System.currentTimeMillis() - net.tfminecraft.simplefactions.guild.HeldRealms.HOLD.toMillis(), null); assertEquals(DeathGuild.UNAVAILABLE, fallback.deathGuild(ruler.characterId(), death)); assertEquals(GuildLookup.UNAVAILABLE, fallback.guild(main.getIncarnationId())); + CharacterRef heir = character(); + faction.addMember(key(heir)); + faction.setLeader(key(heir)); + var answer = (DeathGuild.Guild) fallback.deathGuild(ruler.characterId(), death); + assertTrue(answer.live()); + assertEquals(Optional.of(heir.characterId()), answer.leader()); } @Test void anUnsavedClosureIsUnavailableUntilItsRealmIsSaved() throws Exception { diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java index f11db259..7191e3d5 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java @@ -102,19 +102,19 @@ void aHeldRealmShowsItsDeadLeaderUntilStaffSeatSomeone() { void aRealmNobodyTakesOnClosesAfterAWeek() { Faction survivor = survivor(); UUID incarnation = main.getIncarnationId(); - assertFalse(HeldRealms.closing(null, AT + WEEK)); - assertFalse(HeldRealms.closing(UUID.randomUUID(), AT + WEEK)); - assertFalse(HeldRealms.closing(incarnation, AT + WEEK - 1)); + assertFalse(HeldRealms.unsettled(null)); + assertFalse(HeldRealms.unsettled(UUID.randomUUID())); + assertTrue(HeldRealms.unsettled(incarnation), "estates wait while the realm is held"); HeldRealms.closeExpired(AT + WEEK - 1, folder); assertSame(realm, FactionManager.getByString("realm")); - assertTrue(HeldRealms.closing(incarnation, AT + WEEK)); + assertTrue(HeldRealms.unsettled(incarnation)); HeldRealms.closeExpired(AT + WEEK, folder); assertNull(FactionManager.getByString("realm")); assertFalse(Files.exists(folder.resolve("Data/realm.json"))); assertFalse(survivor.getDiplomacyHandler().getRelations().containsKey("realm")); - assertFalse(HeldRealms.closing(incarnation, AT + WEEK)); + assertFalse(HeldRealms.unsettled(incarnation)); } @Test @@ -142,15 +142,18 @@ void aClosureThatHasBegunFinishesEvenIfTheLeaderIsRevived() { HeldRealms.closeExpired(AT + WEEK, folder); assertSame(realm, FactionManager.getByString("realm")); assertEquals(AT + WEEK, main.getClosingSince()); - assertTrue(HeldRealms.closing(incarnation, AT)); + assertTrue(HeldRealms.unsettled(incarnation)); + assertTrue(HeldRealms.isClosing(realm)); + assertFalse(HeldRealms.isClosing(null)); characters.revive(ruler); HeldRealms.revived(ruler); assertTrue(HeldRealms.isHeld(realm), "a closure that has begun is not undone"); + realm.addMember(characters.character(UUID.randomUUID(), "Kira Vale")); failFor = null; HeldRealms.closeExpired(AT + WEEK + 1, folder); assertNull(FactionManager.getByString("realm")); - assertFalse(HeldRealms.closing(incarnation, AT)); + assertFalse(HeldRealms.unsettled(incarnation)); assertFalse(survivor.getDiplomacyHandler().getRelations().containsKey("realm")); } @@ -162,7 +165,7 @@ void aRealmThatCannotBeSavedAsClosingIsLeftUntouchedUntilTheNextHour() { assertSame(realm, FactionManager.getByString("realm")); assertEquals(0, main.getClosingSince()); assertTrue(survivor.getDiplomacyHandler().getRelations().containsKey("realm")); - assertTrue(HeldRealms.closing(main.getIncarnationId(), AT)); + assertTrue(HeldRealms.unsettled(main.getIncarnationId())); HeldRealms.closeExpired(AT + WEEK + 1, folder); assertNull(FactionManager.getByString("realm")); } @@ -199,11 +202,11 @@ void aClosureThatFailsAfterLeavingMemoryIsFinishedLater() throws Exception { Files.delete(folder.resolve("Data")); HeldRealms.closeExpired(AT + WEEK, folder); assertNull(FactionManager.getByString("realm")); - assertTrue(HeldRealms.closing(incarnation, AT)); + assertTrue(HeldRealms.unsettled(incarnation)); Files.createDirectories(folder.resolve("Data")); HeldRealms.closeExpired(AT + WEEK + 1, folder); - assertFalse(HeldRealms.closing(incarnation, AT)); + assertFalse(HeldRealms.unsettled(incarnation)); } @Test @@ -220,7 +223,7 @@ void aNewRealmThatTookTheNameIsLeftAlone() throws Exception { HeldRealms.closeExpired(AT + WEEK + 1, folder); assertSame(newcomer, FactionManager.getByString("realm")); assertEquals("new", Files.readString(folder.resolve("Data/realm.json"))); - assertFalse(HeldRealms.closing(incarnation, AT)); + assertFalse(HeldRealms.unsettled(incarnation)); } @Test @@ -229,7 +232,7 @@ void aRealmAtWarClosesOnlyOnceTheWarIsOver() { wars.when(() -> WarManager.isAtWar(realm)).thenReturn(true); HeldRealms.closeExpired(AT + WEEK, folder); assertSame(realm, FactionManager.getByString("realm")); - assertTrue(HeldRealms.closing(main.getIncarnationId(), AT + WEEK), "estates keep waiting"); + assertTrue(HeldRealms.unsettled(main.getIncarnationId()), "estates keep waiting"); } HeldRealms.closeExpired(AT + WEEK + 1, folder); assertNull(FactionManager.getByString("realm")); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java index 38fb7fe8..63a6c35b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/CommandManagerCoverageTest.java @@ -304,6 +304,19 @@ void provinceCommandsRefuseDisabledProvinceIntegration(String command) { verifyNoInteractions(map); } + @ParameterizedTest + @ValueSource(strings = {"forceleader realm Alice", "forcejoin realm Bob"}) + void staffCannotTakeOnARealmWhoseClosureHasBegun(String command) { + admin(); + Guild main = mock(Guild.class); + when(guilds.getGuild("realm")).thenReturn(main); + when(main.getClosingSince()).thenReturn(5L); + assertTrue(run("faction", command.split(" "))); + verify(player).sendMessage(net.tfminecraft.simplefactions.guild.HeldRealms.CLOSING); + verify(faction, never()).setLeader(anyString()); + verify(faction, never()).addMember(anyString()); + } + @ParameterizedTest @ValueSource( strings = { From cae0a93948815ef6d6c9b26e105d5a1572f204ef Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:23:05 +0000 Subject: [PATCH 5/5] fix: pause company slot upkeep and hold name lookups for held realms Co-Authored-By: Claude Opus 5.5 --- .../estate/GuildEstateFallback.java | 2 +- .../simplefactions/guild/income/Ledger.java | 3 ++- .../estate/GuildEstateFallbackTest.java | 1 + .../guild/income/LedgerMercenaryTest.java | 15 +++++++++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/estate/GuildEstateFallback.java b/src/main/java/net/tfminecraft/simplefactions/estate/GuildEstateFallback.java index 82307d33..b746fc93 100644 --- a/src/main/java/net/tfminecraft/simplefactions/estate/GuildEstateFallback.java +++ b/src/main/java/net/tfminecraft/simplefactions/estate/GuildEstateFallback.java @@ -58,7 +58,7 @@ public GuildLookup guildByName(String name) { .filter(guild -> name.trim().equalsIgnoreCase(guild.getName())) .toList(); if (matches.size() > 1) return GuildLookup.UNAVAILABLE; - return matches.isEmpty() ? GuildLookup.GONE : GuildLookup.live(ref(matches.getFirst())); + return matches.isEmpty() ? GuildLookup.GONE : guild(matches.getFirst().getIncarnationId()); } /** A guild being closed, or a held realm, is neither live nor gone yet. */ diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java index 8ca8c830..0c9d64c2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java @@ -498,7 +498,8 @@ private MercenaryCompany getFormedCompany() { /** Slot upkeep the host guild owes for its own company, zero for everyone else. */ private double getCompanySlotUpkeep() { MercenaryCompany company = getFormedCompany(); - return company == null ? 0 : company.getSlotUpkeep(); + // A held realm pays no army upkeep, its companies' slots included. + return company == null || HeldRealms.isHeld(guild.getFaction()) ? 0 : company.getSlotUpkeep(); } /** diff --git a/src/test/java/net/tfminecraft/simplefactions/estate/GuildEstateFallbackTest.java b/src/test/java/net/tfminecraft/simplefactions/estate/GuildEstateFallbackTest.java index 0e007b6d..8383388d 100644 --- a/src/test/java/net/tfminecraft/simplefactions/estate/GuildEstateFallbackTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/estate/GuildEstateFallbackTest.java @@ -185,6 +185,7 @@ static UUID ruling(Faction faction) { assertTrue(main.isHeld()); assertEquals(DeathGuild.UNAVAILABLE, fallback.deathGuild(ruler.characterId(), death)); assertEquals(GuildLookup.UNAVAILABLE, fallback.guild(main.getIncarnationId())); + assertEquals(GuildLookup.UNAVAILABLE, fallback.guildByName(main.getName()), "by name as by incarnation"); CharacterRef heir = character(); faction.addMember(key(heir)); faction.setLeader(key(heir)); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerMercenaryTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerMercenaryTest.java index c3bd15a5..f2c6d1b0 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerMercenaryTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerMercenaryTest.java @@ -157,6 +157,21 @@ void companyUpkeepLeavesTheHostGuildEvenWhenItIsNotTheCapital() { assertEquals(-30.0, host.ledger.getIncome(Cashflow.UPGRADES_UPKEEP), 1e-9); } + @Test + void aHeldRealmsCompanyPaysNoSlotUpkeep() { + Fixture host = hostGuild(false); + when(host.company.getSlotUpkeep()).thenReturn(24.0); + when(host.faction.getId()).thenReturn("realm"); + Guild main = mock(Guild.class); + when(host.guildHandler.getGuild("realm")).thenReturn(main); + when(main.isHeld()).thenReturn(true); + + assertEquals(0.0, host.ledger.getIncome(Cashflow.MILITARY_UPKEEP), 1e-9); + var transfers = new net.tfminecraft.simplefactions.utils.DailyGuildTransfers(); + host.ledger.populateDailyTransfers(transfers); + assertEquals(0.0, transfers.getExternalDeltas().getOrDefault(host.guild, 0.0), 1e-9); + } + @Test void aCapitalPaysBothItsArmyAndItsCompany() { Fixture host = hostGuild(true);