From 8e6d3d86753d0127db7686eaf08f9692720b2b97 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:13:19 +0200 Subject: [PATCH 1/3] feat: pass a solo realm to its dead leader's will heir A realm whose last member died is held for a week and then closes. Once held-realm-heir-wait-hours (24) have passed, the heir named in the dead leader's will now takes it on, if alive and in no faction, unless staff held or reverted the estate. Checked hourly and when the heir starts playing; an heir in another faction is told to leave it first. Co-Authored-By: Claude Opus 5.5 --- .../net/tfminecraft/simplefactions/Cache.java | 2 + .../simplefactions/estate/EstateBridge.java | 13 + .../simplefactions/estate/EstateCalls.java | 6 + .../simplefactions/guild/HeldRealms.java | 97 +++++++- .../identity/LeaderCharacterListener.java | 7 +- .../integration/rpcharacters/WillHeirs.java | 60 +++++ .../simplefactions/loaders/ConfigLoader.java | 2 + src/main/resources/config.yml | 5 + .../estate/EstateBridgeTest.java | 14 ++ .../guild/HeldRealmsHeirTest.java | 222 ++++++++++++++++++ .../simplefactions/guild/HeldRealmsTest.java | 1 + .../rpcharacters/WillHeirsTest.java | 91 +++++++ 12 files changed, 513 insertions(+), 7 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/WillHeirs.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsHeirTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/integration/rpcharacters/WillHeirsTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/Cache.java b/src/main/java/net/tfminecraft/simplefactions/Cache.java index 0f28b9c8..8a8871db 100644 --- a/src/main/java/net/tfminecraft/simplefactions/Cache.java +++ b/src/main/java/net/tfminecraft/simplefactions/Cache.java @@ -35,6 +35,8 @@ private Cache() {} public static double maxTradeUpkeep = 0.75; public static double maxPlaytimePrestigeExponent = 5; public static int memberShareWindowDays = 30; + /** Hours after a held realm's death before the will heir takes it on (HeldRealms). */ + public static double heldRealmHeirWaitHours = 24; public static String baseYear; public static int baseIrlYear = java.time.Year.now().getValue(); public static String bankBlock; diff --git a/src/main/java/net/tfminecraft/simplefactions/estate/EstateBridge.java b/src/main/java/net/tfminecraft/simplefactions/estate/EstateBridge.java index 62a6c850..0167bfce 100644 --- a/src/main/java/net/tfminecraft/simplefactions/estate/EstateBridge.java +++ b/src/main/java/net/tfminecraft/simplefactions/estate/EstateBridge.java @@ -60,6 +60,19 @@ public static boolean vehiclesAwaitEstate(UUID characterId) { } } + /** + * Whether a will heir may take on what a dead character left: its latest death's estate is OPEN + * or DECIDED, so staff have not held or reverted it. False when that cannot be told. + */ + public static boolean heirMayInherit(UUID characterId) { + if (characterId == null || !available()) return false; + try { + return EstateCalls.heirMayInherit(characterId); + } catch (RuntimeException | LinkageError error) { + return false; + } + } + /** Offers the death-guild answers once factions have started. */ public static void registerFallback(Plugin plugin) { if (!available()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/estate/EstateCalls.java b/src/main/java/net/tfminecraft/simplefactions/estate/EstateCalls.java index 204ca719..f6d0738d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/estate/EstateCalls.java +++ b/src/main/java/net/tfminecraft/simplefactions/estate/EstateCalls.java @@ -25,6 +25,12 @@ static boolean vehiclesAwaitEstate(UUID characterId) { .orElse(false); } + static boolean heirMayInherit(UUID characterId) { + return Estates.stateOf(characterId) + .map(state -> state == EstateState.OPEN || state == EstateState.DECIDED) + .orElse(false); + } + static void registerFallback(Plugin plugin) { plugin.getServer().getServicesManager().register(EstateFallback.class, new GuildEstateFallback(), plugin, ServicePriority.Normal); diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java b/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java index 853fa38b..6a29fbcf 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java @@ -10,25 +10,37 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Predicate; import java.util.logging.Logger; import org.bukkit.entity.Player; +import net.tfminecraft.simplefactions.Cache; import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.estate.EstateBridge; +import net.tfminecraft.simplefactions.identity.LeaderCharacterListener; import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.integration.rpcharacters.WillHeirs; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.managers.WarManager; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterStatus; /** * 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 once it is at peace. + * pause, and nobody can join it or declare war on it. A revive puts the leader back; after + * {@link Cache#heldRealmHeirWaitHours} the heir named in the dead leader's will takes it on instead. + * Staff can seat someone with {@code /faction forcejoin} and {@code /faction forceleader}. A realm + * left with no members for {@link #HOLD} closes once it is at peace. */ 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<>(); + /** The member key of the heir a dead character's will names, or null; replaced in tests. */ + static Function heirs = WillHeirs::heir; + /** Whether a dead character's estate lets its heir act (staff have not held or reverted it); replaced in tests. */ + static Predicate estates = key -> EstateBridge.heirMayInherit(Members.characterId(key)); private HeldRealms() {} @@ -81,9 +93,19 @@ public static void revived(String key) { } } + /** The character started playing: give it any held realm its will heir wait is over for. */ + public static void heirStarted(String key, long now) { + if (key == null) return; + for (Faction faction : FactionManager.getCopy()) { + Guild main = faction == null ? null : faction.getGuildHandler().getGuild(faction.getId()); + if (main != null && main.isHeld()) inherit(faction, main, now, key); + } + } + /** - * 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. + * Hourly: gives realms back to revived leaders or to the will heirs of dead ones, and closes those + * empty for {@link #HOLD} and at peace. A realm whose new leader could not be saved in is kept for + * the next hour. */ public static void closeExpired(long now, Path dataFolder) { for (var entry : new ArrayList<>(unclosed.entrySet())) { @@ -97,7 +119,8 @@ public static void closeExpired(long now, Path dataFolder) { close(faction, faction.getId(), main.getIncarnationId(), now, dataFolder); continue; } - if (main == null || !main.isHeld() || restore(faction, main) != Restore.NOT_ELIGIBLE) continue; + if (main == null || !main.isHeld() || restore(faction, main) != Restore.NOT_ELIGIBLE + || inherit(faction, main, now, null) != 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(), now, dataFolder); @@ -145,6 +168,8 @@ private static void close(Faction faction, String id, UUID incarnation, long now public static void resetForTests() { unclosed.clear(); + heirs = WillHeirs::heir; + estates = key -> EstateBridge.heirMayInherit(Members.characterId(key)); } private static boolean weekOver(Faction faction, Guild main, long now) { @@ -177,6 +202,66 @@ private static Restore restore(Faction faction, Guild main) { return Restore.NOT_SAVED; } + /** + * Seats the heir named in the dead leader's will once {@link Cache#heldRealmHeirWaitHours} have passed + * since the death. The heir must be alive and in no faction, and the estate neither held nor reverted + * by staff; an heir in another faction is told to leave it first when they start playing. {@code only} + * limits it to that heir, who has just started playing. + * A failed save puts the hold back for the next try. + */ + private static Restore inherit(Faction faction, Guild main, long now, String only) { + String dead = main.getDeadLeader(); + if (dead == null || main.getClosingSince() > 0 || !faction.getMembers().isEmpty() + || now - main.getLeaderlessSince() < heirWait() + || !Members.info(dead).map(info -> info.status() == CharacterStatus.DEAD).orElse(false)) { + return Restore.NOT_ELIGIBLE; + } + String heir = heirs.apply(dead); + if (heir == null || heir.equals(dead) || only != null && !only.equals(heir) + || !Members.info(heir).map(CharacterInfo::alive).orElse(false) || !estates.test(dead)) { + return Restore.NOT_ELIGIBLE; + } + Faction current = FactionManager.getByMember(heir); + if (current != null) { + // Told when they start playing, not every hour. + if (only != null) Members.online(heir).ifPresent(p -> p.sendMessage("§e" + Members.display(dead) + " left you " + + faction.getName() + " §ein their will. Leave " + current.getName() + " §e" + deadline(main, now) + + " to take it on.")); + return Restore.NOT_ELIGIBLE; + } + long since = main.getLeaderlessSince(); + long empty = main.getEmptySince(); + faction.addMember(heir); + faction.setLeader(heir); + if (save(faction)) { + Logger.getLogger("SimpleFactions").info("Gave " + faction.getName() + " to " + heir + + ", the will heir of its dead leader " + dead); + LeaderCharacterListener.refresh(heir); + faction.updatePrestige(); + Members.online(heir).ifPresent(p -> p.sendMessage("§a" + Members.display(dead) + " left you " + + faction.getName() + " §ain their will. You now lead it.")); + return Restore.RESTORED; + } + faction.succeedDeadLeader(heir, ""); + main.getMembers().remove(heir); + main.restoreLeaderless(dead, since, empty, null); + if (!save(faction)) Logger.getLogger("SimpleFactions").severe("Could not save " + faction.getId() + + " after undoing its passing to a will heir; its file may name them. Check it before restarting."); + return Restore.NOT_SAVED; + } + + private static long heirWait() { + return (long) (Cache.heldRealmHeirWaitHours * 3_600_000L); + } + + /** "within N days" before the held week runs out, or "soon" when the clock has not started. */ + private static String deadline(Guild main, long now) { + if (main.getEmptySince() <= 0) return "soon"; + long left = main.getEmptySince() + HOLD.toMillis() - now; + long days = Math.max(1, (left + Duration.ofDays(1).toMillis() - 1) / Duration.ofDays(1).toMillis()); + return "within " + days + (days == 1 ? " day" : " days"); + } + private static boolean save(Faction faction) { if (new Database().saveFactionDurably(faction)) return true; Logger.getLogger("SimpleFactions").warning("Could not save held realm " + faction.getId()); diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java index c3de8179..3e2ca0f5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/LeaderCharacterListener.java @@ -12,7 +12,10 @@ import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.objects.Faction; -/** Refreshes map leader names when a shared character session is ready, and returns held realms on revive. */ +/** + * Refreshes map leader names when a shared character session is ready, returns held realms on revive, + * and gives a will heir the held realm left to them when they start playing. + */ public final class LeaderCharacterListener implements Listener { @EventHandler(priority = EventPriority.MONITOR) public void onCharacterStart(CharacterSessionStartEvent event) { @@ -22,6 +25,8 @@ public void onCharacterStart(CharacterSessionStartEvent event) { || !Characters.isCurrent(event.getSession())) return; refresh(Members.key(event.getSession().ref())); net.tfminecraft.simplefactions.guild.LeaderlessGuilds.remind(player); + net.tfminecraft.simplefactions.guild.HeldRealms.heirStarted(Members.key(event.getSession().ref()), + System.currentTimeMillis()); } /** A revived leader takes back the realm held since their death. */ diff --git a/src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/WillHeirs.java b/src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/WillHeirs.java new file mode 100644 index 00000000..a12e5bcf --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/integration/rpcharacters/WillHeirs.java @@ -0,0 +1,60 @@ +package net.tfminecraft.simplefactions.integration.rpcharacters; + +import java.util.UUID; + +import org.bukkit.Bukkit; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; + +import net.tfminecraft.simplefactions.identity.Members; + +/** + * The heir a dead character's will names, read from its saved RPCharacters file ({@code will-heir}). + * A will cannot change after its testator dies, so the file holds the will made while alive. + */ +public final class WillHeirs { + private WillHeirs() {} + + /** The heir's member key; null without an heir, without RPCharacters, or when the file cannot be read. */ + public static String heir(String deadKey) { + UUID character = Members.characterId(deadKey); + UUID owner = Members.accountId(deadKey).orElse(null); + if (character == null || owner == null) return null; + try { + if (!Bukkit.getPluginManager().isPluginEnabled("RPCharacters")) return null; + return Saved.heir(owner, character); + } catch (RuntimeException | LinkageError unreadable) { + return null; + } + } + + /** The heir named in a character file's text, as a member key, or null. */ + static String parse(String json) { + if (json == null) return null; + try { + JsonElement root = JsonParser.parseString(json); + if (!root.isJsonObject()) return null; + JsonElement heir = root.getAsJsonObject().get("will-heir"); + if (heir == null || !heir.isJsonPrimitive() || !heir.getAsJsonPrimitive().isString()) return null; + return Members.key(UUID.fromString(heir.getAsString())); + } catch (RuntimeException unreadable) { + return null; + } + } + + /** Touches RPCharacters' types; loaded only once the plugin is known to be enabled. */ + private static final class Saved { + private Saved() {} + + static String heir(UUID owner, UUID character) { + var account = net.tfminecraft.rpcharacters.database.Database.readSavedAccount(owner); + if (account == null) return null; + String file = character + ".json"; + for (var saved : account.characters()) { + if (saved.file().getName().equalsIgnoreCase(file)) return parse(saved.json()); + } + return null; + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java index afdae7e3..2a365e43 100644 --- a/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/simplefactions/loaders/ConfigLoader.java @@ -61,6 +61,8 @@ public void loadConfig(File configFile) { } Cache.maxPlaytimePrestigeExponent = config.getDouble("max-prestige-playtime-exponent", 5); Cache.memberShareWindowDays = Math.max(1, config.getInt("member-share-window-days", 30)); + double heirWait = config.getDouble("held-realm-heir-wait-hours", 24); + Cache.heldRealmHeirWaitHours = Double.isFinite(heirWait) ? Math.max(0, heirWait) : 24; Cache.bankBlock = config.getString("bank-block", "v.lodestone"); Cache.maxExtraNodeCapacity = config.getInt("max-extra-node-capacity", 0); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 12ac52f6..e884c74e 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -54,6 +54,11 @@ max-prestige-playtime-exponent: 5 # characters by how long each was played over this many days. member-share-window-days: 30 max-members: 64 +# Hours after a realm's last member dies before the heir named in their will +# takes it on; a revive inside the window gives it back to them instead. The +# heir must be alive and in no faction before the held week runs out. Match it +# to RPCharacters' estates.wait-hours. Fractions are allowed for rehearsals. +held-realm-heir-wait-hours: 24 max-untitled-provinces: 5 max-free-titles: 3 province-cost: 50 diff --git a/src/test/java/net/tfminecraft/simplefactions/estate/EstateBridgeTest.java b/src/test/java/net/tfminecraft/simplefactions/estate/EstateBridgeTest.java index 862b936a..2158d9c2 100644 --- a/src/test/java/net/tfminecraft/simplefactions/estate/EstateBridgeTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/estate/EstateBridgeTest.java @@ -79,11 +79,24 @@ class EstateBridgeTest { assertFalse(EstateBridge.vehiclesAwaitEstate(null)); } + @Test void heirsMayInheritWhileTheLatestDeathsEstateIsOpenOrDecided() { + for (EstateState state : EstateState.values()) { + estates.when(() -> Estates.stateOf(character)).thenReturn(Optional.of(state)); + boolean may = state == EstateState.OPEN || state == EstateState.DECIDED; + assertEquals(may, EstateBridge.heirMayInherit(character), state.name()); + } + estates.when(() -> Estates.stateOf(character)).thenReturn(Optional.empty()); + assertFalse(EstateBridge.heirMayInherit(character), "no estate service or no estate"); + assertFalse(EstateBridge.heirMayInherit(null)); + } + @Test void estateFailuresNeverEscapeTheBridge() { estates.when(() -> Estates.stateOf(character)).thenThrow(new IllegalStateException("off the main thread")); assertFalse(EstateBridge.vehiclesAwaitEstate(character)); + assertFalse(EstateBridge.heirMayInherit(character)); estates.when(() -> Estates.stateOf(character)).thenThrow(new NoSuchMethodError("older TLibs")); assertFalse(EstateBridge.vehiclesAwaitEstate(character)); + assertFalse(EstateBridge.heirMayInherit(character)); when(server.getServicesManager()).thenThrow(new IllegalStateException("disabled")); EstateBridge.registerFallback(plugin); EstateBridge.registerVehicles(plugin, Path.of("data"), new PlayerVehicleRegistry(), new VehicleFeeStore()); @@ -94,6 +107,7 @@ class EstateBridgeTest { EstateBridge.useProbe(false); estates.when(() -> Estates.stateOf(character)).thenReturn(Optional.of(EstateState.OPEN)); assertFalse(EstateBridge.vehiclesAwaitEstate(character)); + assertFalse(EstateBridge.heirMayInherit(character)); EstateBridge.registerFallback(plugin); EstateBridge.registerVehicles(plugin, Path.of("data"), new PlayerVehicleRegistry(), new VehicleFeeStore()); assertNull(services.load(EstateFallback.class)); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsHeirTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsHeirTest.java new file mode 100644 index 00000000..478c61dc --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsHeirTest.java @@ -0,0 +1,222 @@ +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.never; +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.HashMap; +import java.util.Map; +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.Cache; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; + +/** A held realm passes to the heir its dead leader's will names (HeldRealms.inherit). */ +class HeldRealmsHeirTest { + private static final long AT = 1_000_000L; + private static final long WAIT = 24L * 3_600_000L; + private static final long WEEK = HeldRealms.HOLD.toMillis(); + @TempDir Path folder; + private FactionDomainFixture fixture; + private TestCharacters characters; + private MockedConstruction databases; + private final Map wills = new HashMap<>(); + private boolean estateOpen = true; + private int failures; + private String ruler; + private String heir; + 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)); + UUID egg = UUID.randomUUID(); + ruler = characters.character(egg, "Omeleth"); + heir = characters.character(egg, "Omeleth the Younger"); + realm = fixture.saved("realm", ruler); + main = realm.getOrCreateMainGuild(); + characters.kill(ruler); + main.removeDeadCharacter(ruler); + realm.succeedDeadLeader(ruler, ""); + HeldRealms.hold(realm, ruler, AT); + wills.put(ruler, heir); + HeldRealms.heirs = wills::get; + HeldRealms.estates = key -> estateOpen; + Cache.heldRealmHeirWaitHours = 24; + Files.createDirectories(folder.resolve("Data")); + Files.writeString(folder.resolve("Data/realm.json"), "{}"); + } + + @AfterEach + void tearDown() { + HeldRealms.resetForTests(); + Cache.heldRealmHeirWaitHours = 24; + databases.close(); + fixture.close(); + } + + @Test + void theWillHeirTakesOnTheRealmOnceTheWaitIsOver() { + Player younger = fixture.player("younger"); + characters.play(younger, heir); + HeldRealms.closeExpired(AT + WAIT - 1, folder); + assertTrue(HeldRealms.isHeld(realm), "a revive inside the wait still gives it back"); + + HeldRealms.closeExpired(AT + WAIT, folder); + assertEquals(heir, realm.getLeader()); + assertTrue(realm.isMember(heir)); + assertFalse(HeldRealms.isHeld(realm)); + assertNull(main.getDeadLeader()); + verify(younger).sendMessage("§aOmeleth left you " + realm.getName() + " §ain their will. You now lead it."); + + HeldRealms.closeExpired(AT + WEEK, folder); + assertSame(realm, FactionManager.getByString("realm"), "a realm with a leader never closes"); + } + + @Test + void aReviveInsideTheWaitGivesTheRealmBackFirst() { + characters.revive(ruler); + HeldRealms.closeExpired(AT + WAIT, folder); + assertEquals(ruler, realm.getLeader()); + assertFalse(realm.isMember(heir)); + } + + @Test + void theHeirTakesItOnWhenTheyStartPlaying() { + String stranger = characters.character(UUID.randomUUID(), "Bo Reed"); + HeldRealms.heirStarted(null, AT + WAIT); + HeldRealms.heirStarted(heir, AT + WAIT - 1); + HeldRealms.heirStarted(stranger, AT + WAIT); + assertTrue(HeldRealms.isHeld(realm), "too early, or not the heir"); + + HeldRealms.heirStarted(heir, AT + WAIT); + assertEquals(heir, realm.getLeader()); + } + + @Test + void anHeirInAnotherFactionIsToldToLeaveItFirst() { + Faction other = fixture.saved("other", characters.character(UUID.randomUUID(), "Bo Reed")); + other.addMember(heir); + Player younger = fixture.player("younger"); + characters.play(younger, heir); + HeldRealms.closeExpired(AT + WAIT, folder); + assertTrue(HeldRealms.isHeld(realm)); + verify(younger, never()).sendMessage(org.mockito.ArgumentMatchers.anyString()); + HeldRealms.heirStarted(heir, AT + WAIT); + verify(younger).sendMessage("§eOmeleth left you " + realm.getName() + " §ein their will. Leave " + + other.getName() + " §ewithin 6 days to take it on."); + + main.restoreLeaderless(ruler, AT, 0, null); + HeldRealms.heirStarted(heir, AT + WAIT); + verify(younger).sendMessage("§eOmeleth left you " + realm.getName() + " §ein their will. Leave " + + other.getName() + " §esoon to take it on."); + + main.restoreLeaderless(ruler, AT, AT, null); + HeldRealms.heirStarted(heir, AT + WEEK - 1); + verify(younger).sendMessage("§eOmeleth left you " + realm.getName() + " §ein their will. Leave " + + other.getName() + " §ewithin 1 day to take it on."); + + other.getOrCreateMainGuild().getMembers().remove(heir); + HeldRealms.closeExpired(AT + WEEK - 1, folder); + assertEquals(heir, realm.getLeader()); + } + + @Test + void noLivingHeirOrAStoppedEstateLeavesTheRealmToClose() { + Player younger = fixture.player("younger"); + characters.play(younger, heir); + estateOpen = false; + HeldRealms.closeExpired(AT + WAIT, folder); + assertTrue(HeldRealms.isHeld(realm), "staff held or reverted the estate"); + + estateOpen = true; + characters.kill(heir); + HeldRealms.closeExpired(AT + WAIT, folder); + assertTrue(HeldRealms.isHeld(realm), "the heir died too"); + + wills.put(ruler, ruler); + HeldRealms.closeExpired(AT + WAIT, folder); + wills.put(ruler, "not-a-character"); + HeldRealms.closeExpired(AT + WAIT, folder); + wills.remove(ruler); + HeldRealms.closeExpired(AT + WAIT, folder); + assertTrue(HeldRealms.isHeld(realm), "no usable will"); + verify(younger, never()).sendMessage(org.mockito.ArgumentMatchers.anyString()); + + HeldRealms.closeExpired(AT + WEEK, folder); + assertNull(FactionManager.getByString("realm")); + } + + @Test + void aRealmStaffLetSomeoneIntoOrThatIsClosingIsNotPassedOn() { + String kira = characters.character(UUID.randomUUID(), "Kira Vale"); + realm.addMember(kira); + HeldRealms.closeExpired(AT + WAIT, folder); + assertFalse(realm.isMember(heir), "staff let someone in"); + + main.getMembers().remove(kira); + main.markClosing(AT + WAIT); + HeldRealms.heirStarted(heir, AT + WAIT); + assertFalse(realm.isMember(heir), "its closure has begun"); + } + + @Test + void aLeaderWhoseRecordsCannotBeReadKeepsTheRealmHeld() { + String unknown = net.tfminecraft.simplefactions.identity.Members.key(UUID.randomUUID()); + main.restoreLeaderless(unknown, AT, AT, null); + wills.put(unknown, heir); + HeldRealms.heirStarted(heir, AT + WAIT); + assertTrue(HeldRealms.isHeld(realm)); + main.restoreLeaderless(null, AT, AT, null); + HeldRealms.heirStarted(heir, AT + WAIT); + assertTrue(HeldRealms.isHeld(realm)); + } + + @Test + void aFailedSavePutsTheHoldBackForTheNextHour() { + failures = 1; + HeldRealms.closeExpired(AT + WAIT, folder); + 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.closeExpired(AT + WAIT, folder); + assertTrue(HeldRealms.isHeld(realm), "the undo could not be saved either"); + HeldRealms.closeExpired(AT + WAIT + 1, folder); + assertEquals(heir, realm.getLeader()); + } + + @Test + void theWaitFollowsTheConfig() { + Cache.heldRealmHeirWaitHours = 0.05; + HeldRealms.closeExpired(AT + 180_000L, folder); + assertEquals(heir, realm.getLeader()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java index 7191e3d5..12dd2ff1 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsTest.java @@ -64,6 +64,7 @@ void setUp() throws Exception { main.removeDeadCharacter(ruler); realm.succeedDeadLeader(ruler, ""); HeldRealms.hold(realm, ruler, AT); + HeldRealms.heirs = key -> null; Files.createDirectories(folder.resolve("Data")); Files.writeString(folder.resolve("Data/realm.json"), "{}"); } diff --git a/src/test/java/net/tfminecraft/simplefactions/integration/rpcharacters/WillHeirsTest.java b/src/test/java/net/tfminecraft/simplefactions/integration/rpcharacters/WillHeirsTest.java new file mode 100644 index 00000000..80aa0cbb --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/integration/rpcharacters/WillHeirsTest.java @@ -0,0 +1,91 @@ +package net.tfminecraft.simplefactions.integration.rpcharacters; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.util.List; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.rpcharacters.database.Database; +import net.tfminecraft.simplefactions.guild.TestCharacters; +import net.tfminecraft.simplefactions.identity.Members; + +/** A dead character's will heir comes from its saved RPCharacters file, and anything unreadable is no heir. */ +class WillHeirsTest { + private final UUID owner = UUID.randomUUID(); + private final UUID heir = UUID.randomUUID(); + private final PluginManager plugins = mock(PluginManager.class); + private MockedStatic bukkit; + private MockedStatic database; + private String dead; + + @BeforeEach + void setUp() { + dead = TestCharacters.install().character(owner, "Omeleth"); + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + when(plugins.isPluginEnabled("RPCharacters")).thenReturn(true); + database = mockStatic(Database.class); + } + + @AfterEach + void tearDown() { + database.close(); + bukkit.close(); + } + + private void saved(Database.SavedCharacter... characters) { + database.when(() -> Database.readSavedAccount(owner)) + .thenReturn(new Database.SavedAccount(owner, "{}", 0L, List.of(characters))); + } + + private Database.SavedCharacter file(String key, String json) { + return new Database.SavedCharacter(new File(owner.toString(), Members.characterId(key) + ".json"), json, 0L); + } + + @Test + void readsTheHeirFromTheDeadCharactersOwnFile() { + String sibling = Members.key(UUID.randomUUID()); + saved(file(sibling, "{\"will-heir\":\"" + UUID.randomUUID() + "\"}"), + file(dead, "{\"will-heir\":\"" + heir + "\"}")); + assertEquals(Members.key(heir), WillHeirs.heir(dead)); + } + + @Test + void noFileNoAccountOrNoPluginIsNoHeir() { + saved(file(Members.key(UUID.randomUUID()), "{\"will-heir\":\"" + heir + "\"}")); + assertNull(WillHeirs.heir(dead), "the character has no file"); + database.when(() -> Database.readSavedAccount(owner)).thenReturn(null); + assertNull(WillHeirs.heir(dead), "the account cannot be read"); + database.when(() -> Database.readSavedAccount(owner)).thenThrow(new IllegalStateException("broken")); + assertNull(WillHeirs.heir(dead)); + when(plugins.isPluginEnabled("RPCharacters")).thenReturn(false); + assertNull(WillHeirs.heir(dead), "RPCharacters is not running"); + assertNull(WillHeirs.heir(Members.key(UUID.randomUUID())), "an unknown character has no account"); + assertNull(WillHeirs.heir("not-a-character")); + } + + @Test + void anythingButAnHeirIdIsNoHeir() { + assertNull(WillHeirs.parse(null)); + assertNull(WillHeirs.parse("[]")); + assertNull(WillHeirs.parse("{")); + assertNull(WillHeirs.parse("{}")); + assertNull(WillHeirs.parse("{\"will-heir\":null}")); + assertNull(WillHeirs.parse("{\"will-heir\":{}}")); + assertNull(WillHeirs.parse("{\"will-heir\":5}")); + assertNull(WillHeirs.parse("{\"will-heir\":\"nobody\"}")); + assertEquals(Members.key(heir), WillHeirs.parse("{\"will-heir\":\"" + heir + "\"}")); + } +} From b34c3f15701ea0464496205259bcf44631295960 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:27:50 +0200 Subject: [PATCH 2/3] fix: no inheritance once the held week has run out Co-Authored-By: Claude Opus 5.5 --- .../simplefactions/guild/HeldRealms.java | 4 ++-- .../guild/HeldRealmsHeirTest.java | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java b/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java index 6a29fbcf..c84f4f03 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java @@ -204,7 +204,7 @@ private static Restore restore(Faction faction, Guild main) { /** * Seats the heir named in the dead leader's will once {@link Cache#heldRealmHeirWaitHours} have passed - * since the death. The heir must be alive and in no faction, and the estate neither held nor reverted + * since the death and before its held week runs out, even while a war keeps it open. The heir must be alive and in no faction, and the estate neither held nor reverted * by staff; an heir in another faction is told to leave it first when they start playing. {@code only} * limits it to that heir, who has just started playing. * A failed save puts the hold back for the next try. @@ -212,7 +212,7 @@ private static Restore restore(Faction faction, Guild main) { private static Restore inherit(Faction faction, Guild main, long now, String only) { String dead = main.getDeadLeader(); if (dead == null || main.getClosingSince() > 0 || !faction.getMembers().isEmpty() - || now - main.getLeaderlessSince() < heirWait() + || now - main.getLeaderlessSince() < heirWait() || weekOver(faction, main, now) || !Members.info(dead).map(info -> info.status() == CharacterStatus.DEAD).orElse(false)) { return Restore.NOT_ELIGIBLE; } diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsHeirTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsHeirTest.java index 478c61dc..88d607df 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsHeirTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsHeirTest.java @@ -219,4 +219,23 @@ void theWaitFollowsTheConfig() { HeldRealms.closeExpired(AT + 180_000L, folder); assertEquals(heir, realm.getLeader()); } + + @Test + void anHeirWhoQualifiesAfterTheHeldWeekIsTooLate() { + characters.kill(heir); + HeldRealms.closeExpired(AT + WAIT, folder); + characters.revive(heir); + HeldRealms.heirStarted(heir, AT + WEEK); + assertTrue(HeldRealms.isHeld(realm), "the week ran out"); + + try (var wars = org.mockito.Mockito.mockStatic(net.tfminecraft.simplefactions.managers.WarManager.class, + org.mockito.Mockito.CALLS_REAL_METHODS)) { + wars.when(() -> net.tfminecraft.simplefactions.managers.WarManager.isAtWar(realm)).thenReturn(true); + HeldRealms.closeExpired(AT + WEEK + 1, folder); + assertTrue(HeldRealms.isHeld(realm), "a war keeps it open, but not for the heir"); + assertFalse(realm.isMember(heir)); + } + HeldRealms.closeExpired(AT + WEEK + 2, folder); + assertNull(FactionManager.getByString("realm")); + } } From b9fd88a035882ca95eb19b19bde8682a3e17f93a Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:58:03 +0200 Subject: [PATCH 3/3] chore: wrap the inherit doc and log the realm id Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/simplefactions/guild/HeldRealms.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java b/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java index c84f4f03..5306a7ad 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/HeldRealms.java @@ -204,10 +204,10 @@ private static Restore restore(Faction faction, Guild main) { /** * Seats the heir named in the dead leader's will once {@link Cache#heldRealmHeirWaitHours} have passed - * since the death and before its held week runs out, even while a war keeps it open. The heir must be alive and in no faction, and the estate neither held nor reverted - * by staff; an heir in another faction is told to leave it first when they start playing. {@code only} - * limits it to that heir, who has just started playing. - * A failed save puts the hold back for the next try. + * since the death and before its held week runs out, even while a war keeps it open. The heir must be + * alive and in no faction, and the estate neither held nor reverted by staff; an heir in another faction + * is told to leave it first when they start playing. {@code only} limits it to that heir, who has just + * started playing. A failed save puts the hold back for the next try. */ private static Restore inherit(Faction faction, Guild main, long now, String only) { String dead = main.getDeadLeader(); @@ -234,7 +234,7 @@ private static Restore inherit(Faction faction, Guild main, long now, String onl faction.addMember(heir); faction.setLeader(heir); if (save(faction)) { - Logger.getLogger("SimpleFactions").info("Gave " + faction.getName() + " to " + heir + Logger.getLogger("SimpleFactions").info("Gave " + faction.getId() + " to " + heir + ", the will heir of its dead leader " + dead); LeaderCharacterListener.refresh(heir); faction.updatePrestige();