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..5306a7ad 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 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(); + if (dead == null || main.getClosingSince() > 0 || !faction.getMembers().isEmpty() + || now - main.getLeaderlessSince() < heirWait() || weekOver(faction, main, now) + || !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.getId() + " 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..88d607df --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/HeldRealmsHeirTest.java @@ -0,0 +1,241 @@ +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()); + } + + @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")); + } +} 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 + "\"}")); + } +}