diff --git a/pom.xml b/pom.xml index abdc8720..67b3922e 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ net.tfminecraft simplefactions - 4.0.0 + 4.1.0 diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index 81aa5b3b..8c63f343 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -482,6 +482,7 @@ private void startServices() { realmSettlementService = new net.tfminecraft.simplefactions.economy.settlement.RealmSettlementService(getDataFolder().toPath(), db); realmSettlementService.recover(); net.tfminecraft.simplefactions.identity.death.CharacterDeaths.reconcile(); + net.tfminecraft.simplefactions.identity.death.CharacterDeaths.indexLockGuilds(); net.tfminecraft.simplefactions.economy.EconomyRuntime.recovered(); } catch (Exception error) { failFinancialState(error); diff --git a/src/main/java/net/tfminecraft/simplefactions/api/SimpleFactionsApi.java b/src/main/java/net/tfminecraft/simplefactions/api/SimpleFactionsApi.java index 8f5ba412..f66b5892 100644 --- a/src/main/java/net/tfminecraft/simplefactions/api/SimpleFactionsApi.java +++ b/src/main/java/net/tfminecraft/simplefactions/api/SimpleFactionsApi.java @@ -1,6 +1,8 @@ package net.tfminecraft.simplefactions.api; import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.identity.death.CharacterDeaths; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.objects.Faction; @@ -35,6 +37,27 @@ public static Optional getGuildByMember(UUID characterId) { return ready() ? Optional.ofNullable(FactionManager.getGuildByMember(characterId)) : Optional.empty(); } + /** + * The guild a character's GUILD and FACTION locks belong to: a living (or + * revived) character's current guild, as {@link #getGuildByMember(UUID)}; + * a dead character's guild at death while that incarnation still exists. + * Compare guilds by incarnation, not ID. Empty while unavailable, and for + * missing, deleted or unknown characters. + */ + public static Optional lockGuild(UUID characterId) { + if (characterId == null || !ready()) return Optional.empty(); + var info = Members.info(Members.key(characterId)); + if (info.isEmpty()) return Optional.empty(); + return switch (info.get().status()) { + case ALIVE -> Optional.ofNullable(FactionManager.getGuildByMember(characterId)); + case DEAD -> CharacterDeaths.lockGuildIncarnation(characterId).flatMap(incarnation -> + FactionManager.getAllGuilds().stream() + .filter(guild -> incarnation.toString().equalsIgnoreCase(guild.getIncarnation())) + .findFirst()); + case MISSING -> Optional.empty(); + }; + } + /** Empty while unavailable; check ready() before making persistence decisions. */ public static Optional getGuildByLeader(Player player) { return ready() ? Optional.ofNullable(FactionManager.getGuildByLeader(player)) : Optional.empty(); diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/death/CharacterDeaths.java b/src/main/java/net/tfminecraft/simplefactions/identity/death/CharacterDeaths.java index 2521829a..eebe8915 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/death/CharacterDeaths.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/death/CharacterDeaths.java @@ -6,6 +6,7 @@ import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.tlibs.character.CharacterRef; @@ -16,6 +17,7 @@ public final class CharacterDeaths { private static DeathJournalStore store; private static FactionDeaths factions; + private static LockGuildIndex lockGuilds; private CharacterDeaths() {} @@ -23,12 +25,14 @@ private CharacterDeaths() {} public static void startup(Path dataFolder) throws IOException { load(dataFolder); reconcile(); + indexLockGuilds(); } /** Load obligations before settlement recovery; defer cleanup until that recovery is complete. */ public static void load(Path dataFolder) throws IOException { factions = new FactionDeaths(dataFolder); store = new DeathJournalStore(dataFolder.resolve("deaths"), factions::apply); + lockGuilds = new LockGuildIndex(); store.pendingPayments(); } @@ -42,7 +46,7 @@ public static void reconcile() throws IOException { UUID deathId = Characters.deathId(info.get().ref().characterId()) .orElseThrow(() -> new IOException("Dead character has no authoritative death ID: " + key)); if (!store.contains(deathId)) { - store.record(factions.capture(info.get().ref(), deathId, Instant.now().toEpochMilli())); + record(factions.capture(info.get().ref(), deathId, Instant.now().toEpochMilli())); } } } @@ -54,7 +58,28 @@ public static void onDeath(CharacterRef ref, UUID deathId, Instant at) throws IO var currentDeath = Characters.deathId(ref.characterId()); if (currentDeath.isPresent() && !currentDeath.get().equals(deathId)) return; if (store.contains(deathId)) store.replayAll(); - else store.record(factions.capture(ref, deathId, at.toEpochMilli())); + else record(factions.capture(ref, deathId, at.toEpochMilli())); + } + + /** After reconcile and before SimpleFactions is ready; lock lookups never read journals. */ + public static void indexLockGuilds() throws IOException { + requireStarted(); + lockGuilds.rebuild(store.all()); + } + + /** The guild incarnation a dead character's locks follow; empty when unknown, guildless, tied or not loaded. */ + public static Optional lockGuildIncarnation(UUID characterId) { + LockGuildIndex index = lockGuilds; + return index == null ? Optional.empty() : index.get(characterId); + } + + /** A written journal updates the index even when its cleanup has to be retried. */ + private static void record(DeathJournal journal) throws IOException { + try { + store.record(journal); + } finally { + if (store.contains(journal.deathId)) lockGuilds.capture(journal); + } } public static List pendingPayments() { @@ -75,6 +100,7 @@ public static void applyPayments(String settlementId, Map paidCent public static void stop() { store = null; factions = null; + lockGuilds = null; } private static void requireStarted() throws IOException { diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/death/DeathJournal.java b/src/main/java/net/tfminecraft/simplefactions/identity/death/DeathJournal.java index 2db04d95..4a8fcd3f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/death/DeathJournal.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/death/DeathJournal.java @@ -13,6 +13,8 @@ final class DeathJournal { UUID ownerId; UUID characterId; long at; + /** The guild a dead owner's GUILD and FACTION locks follow; absent when guildless or written before 6a. */ + UUID lockGuildIncarnation; List steps = new ArrayList<>(); List claims = new ArrayList<>(); Map> payments = new LinkedHashMap<>(); diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/death/DeathJournalStore.java b/src/main/java/net/tfminecraft/simplefactions/identity/death/DeathJournalStore.java index 1b14474c..28bea3b2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/death/DeathJournalStore.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/death/DeathJournalStore.java @@ -131,7 +131,7 @@ private static boolean cleaned(DeathJournal journal, String id) { return journal.steps.stream().anyMatch(step -> step.id.equals(id) && step.done); } - private List all() throws IOException { + List all() throws IOException { if (!Files.exists(folder)) return List.of(); List journals = new ArrayList<>(); try (var files = Files.list(folder)) { 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 8f7edef1..f864fb6d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/death/FactionDeaths.java @@ -22,10 +22,14 @@ import net.tfminecraft.simplefactions.guild.Guild; 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.simplefactions.war.battle.persistence.WarbandMapper; import net.tfminecraft.simplefactions.war.battle.warband.WarbandManager; import net.tfminecraft.simplefactions.war.battle.warband.WarbandMembershipService; +import net.tfminecraft.simplefactions.war.civilwar.CivilWarSnapshot; +import net.tfminecraft.simplefactions.war.civilwar.split.CivilWarMemberMove; +import net.tfminecraft.simplefactions.war.core.War; import net.tfminecraft.tlibs.character.CharacterInfo; import net.tfminecraft.tlibs.character.CharacterRef; @@ -53,6 +57,8 @@ DeathJournal capture(CharacterRef ref, UUID deathId, long at) throws IOException journal.characterId = ref.characterId(); journal.at = at; String key = Members.key(ref); + // Before the snapshots, which can create a main guild. + journal.lockGuildIncarnation = lockGuildIncarnation(key); List successions = new ArrayList<>(); for (Faction faction : FactionManager.getCopy()) { JsonObject before = snapshot(faction); @@ -117,6 +123,36 @@ DeathJournal capture(CharacterRef ref, UUID deathId, long at) throws IOException return journal; } + /** + * The first guild the living lookup would answer, through the String overload, + * which unlike the Player and UUID ones also answers during boot reconcile. + * A member moved into a civil war's rebel main guild follows their origin guild. + */ + static UUID lockGuildIncarnation(String key) { + Guild guild = FactionManager.getGuildByMember(key); + return guild == null ? null : UUID.fromString(civilWarOrigin(guild, key).getIncarnation()); + } + + /** Where untangle would restore the member, resolved now; the wanted leader counts as moved. */ + private static Guild civilWarOrigin(Guild guild, String key) { + for (War war : WarManager.getActive()) { + CivilWarSnapshot snapshot = war.getCivilWarSnapshot(); + if (snapshot == null) continue; + Faction rebels = FactionManager.getByString(snapshot.getTempRebelFactionId()); + if (rebels == null || rebels.getGuildHandler().getGuild(rebels.getId()) != guild) continue; + CivilWarMemberMove move = snapshot.getMemberMoves().stream() + .filter(candidate -> candidate != null && key.equals(candidate.player())) + .findFirst().orElse(null); + Faction host = FactionManager.getByString(snapshot.getHostFactionId()); + if (move == null || host == null) return guild; + // Untangle moves the rebel guilds into the host before restoring members. + Guild origin = host.getGuildHandler().getGuild(move.originGuildId()); + if (origin == null) origin = rebels.getGuildHandler().getGuild(move.originGuildId()); + return origin == null ? host.getOrCreateMainGuild() : origin; + } + return guild; + } + boolean apply(DeathJournal journal, DeathJournal.Step step) throws IOException { return switch (step.kind) { case "rights" -> removeRights(journal, step.plan); diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/death/LockGuildIndex.java b/src/main/java/net/tfminecraft/simplefactions/identity/death/LockGuildIndex.java new file mode 100644 index 00000000..1111fa81 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/identity/death/LockGuildIndex.java @@ -0,0 +1,61 @@ +package net.tfminecraft.simplefactions.identity.death; + +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +/** Character ID to the guild incarnation a dead owner's GUILD and FACTION locks follow. */ +final class LockGuildIndex { + private final Map entries = new HashMap<>(); + + /** Only each character's most recent journal counts; two journals at the same moment deny. */ + void rebuild(List journals) { + Map> latest = new HashMap<>(); + for (DeathJournal journal : journals) { + List winners = latest.get(journal.characterId); + if (winners == null || journal.at > winners.getFirst().at) { + latest.put(journal.characterId, new ArrayList<>(List.of(journal))); + } else if (journal.at == winners.getFirst().at) { + winners.add(journal); + } + } + entries.clear(); + latest.forEach((character, winners) -> { + UUID incarnation = winners.size() == 1 ? incarnation(winners.getFirst()) : null; + if (incarnation != null) entries.put(character, incarnation); + }); + } + + /** A guildless death removes the entry an earlier death left. */ + void capture(DeathJournal journal) { + if (journal.lockGuildIncarnation == null) entries.remove(journal.characterId); + else entries.put(journal.characterId, journal.lockGuildIncarnation); + } + + Optional get(UUID characterId) { + return Optional.ofNullable(entries.get(characterId)); + } + + /** + * Journals written before the field existed use the first guild listing the + * character in the rights snapshots, in their order, without civil-war mapping. + */ + static UUID incarnation(DeathJournal journal) { + if (journal.lockGuildIncarnation != null) return journal.lockGuildIncarnation; + JsonPrimitive key = new JsonPrimitive(journal.characterId.toString()); + for (DeathJournal.Step step : journal.steps) { + if (!step.kind.equals("rights") || !step.plan.has("guilds")) continue; + for (JsonElement guild : step.plan.getAsJsonArray("guilds")) { + if (guild.getAsJsonObject().getAsJsonArray("members").contains(key)) { + return UUID.fromString(guild.getAsJsonObject().get("incarnation").getAsString()); + } + } + } + return null; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/death/LockGuildIndexTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/death/LockGuildIndexTest.java new file mode 100644 index 00000000..90460143 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/identity/death/LockGuildIndexTest.java @@ -0,0 +1,94 @@ +package net.tfminecraft.simplefactions.identity.death; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class LockGuildIndexTest { + final UUID character = UUID.randomUUID(); + final String key = character.toString(); + final LockGuildIndex index = new LockGuildIndex(); + + DeathJournal journal(long at, JsonObject... rights) { + DeathJournal journal = new DeathJournal(); + journal.deathId = UUID.randomUUID(); + journal.ownerId = UUID.randomUUID(); + journal.characterId = character; + journal.at = at; + for (JsonObject plan : rights) journal.steps.add(new DeathJournal.Step("rights:" + UUID.randomUUID(), "rights", plan)); + journal.steps.add(new DeathJournal.Step("online", "online", new JsonObject())); + return journal; + } + + static JsonObject guild(UUID incarnation, String... members) { + JsonObject guild = new JsonObject(); + guild.addProperty("incarnation", incarnation.toString()); + JsonArray list = new JsonArray(); + for (String member : members) list.add(member); + guild.add("members", list); + guild.add("invites", new JsonArray()); + return guild; + } + + static JsonObject realm(JsonObject... guilds) { + JsonObject realm = new JsonObject(); + realm.addProperty("realm", UUID.randomUUID().toString()); + JsonArray list = new JsonArray(); + for (JsonObject guild : guilds) list.add(guild); + realm.add("guilds", list); + return realm; + } + + @Test void olderJournalsUseTheFirstGuildListingTheCharacterInSnapshotOrder() { + UUID invitedOnly = UUID.randomUUID(), first = UUID.randomUUID(), later = UUID.randomUUID(); + JsonObject invited = guild(invitedOnly, "someone"); + invited.getAsJsonArray("invites").add(key); + DeathJournal journal = journal(1, new JsonObject(), + realm(invited, guild(first, "someone", key)), realm(guild(later, key))); + index.rebuild(List.of(journal)); + assertEquals(Optional.of(first), index.get(character)); + } + + @Test void recordedIncarnationWinsOverTheSnapshot() { + UUID recorded = UUID.randomUUID(); + DeathJournal journal = journal(1, realm(guild(UUID.randomUUID(), key))); + journal.lockGuildIncarnation = recorded; + index.rebuild(List.of(journal)); + assertEquals(Optional.of(recorded), index.get(character)); + } + + @Test void onlyTheMostRecentJournalPerCharacterCounts() { + UUID guild = UUID.randomUUID(); + DeathJournal inGuild = journal(1, realm(guild(guild, key))); + DeathJournal guildless = journal(2, realm(guild(UUID.randomUUID(), "someone"))); + index.rebuild(List.of(guildless, inGuild)); + assertTrue(index.get(character).isEmpty(), "a later guildless death leaves no entry"); + index.rebuild(List.of(journal(3, realm(guild(guild, key))), guildless)); + assertEquals(Optional.of(guild), index.get(character)); + } + + @Test void journalsAtTheSameMomentDeny() { + UUID guild = UUID.randomUUID(); + index.rebuild(List.of(journal(1, realm(guild(UUID.randomUUID(), key))), + journal(5, realm(guild(guild, key))), journal(5, realm(guild(guild, key))))); + assertTrue(index.get(character).isEmpty()); + } + + @Test void rebuildReplacesEarlierEntriesAndCaptureKeepsThemCurrent() { + UUID first = UUID.randomUUID(), second = UUID.randomUUID(); + index.rebuild(List.of(journal(1, realm(guild(first, key))))); + index.rebuild(List.of()); + assertTrue(index.get(character).isEmpty()); + DeathJournal captured = journal(2); + captured.lockGuildIncarnation = second; + index.capture(captured); + assertEquals(Optional.of(second), index.get(character)); + index.capture(journal(3)); + assertTrue(index.get(character).isEmpty(), "a guildless capture deletes the entry"); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/death/LockGuildTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/death/LockGuildTest.java new file mode 100644 index 00000000..373fd161 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/identity/death/LockGuildTest.java @@ -0,0 +1,321 @@ +package net.tfminecraft.simplefactions.identity.death; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import net.tfminecraft.simplefactions.api.SimpleFactionsApi; +import net.tfminecraft.simplefactions.database.Database; +import net.tfminecraft.simplefactions.database.DurableJson; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.DividendBreakdown; +import net.tfminecraft.simplefactions.guild.income.Ledger; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.managers.SessionManager; +import net.tfminecraft.simplefactions.managers.WarManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.FactionDomainFixture; +import net.tfminecraft.simplefactions.war.battle.warband.WarbandManager; +import net.tfminecraft.simplefactions.war.battle.warband.WarbandMembershipService; +import net.tfminecraft.simplefactions.war.civilwar.CivilWarSnapshot; +import net.tfminecraft.simplefactions.war.civilwar.split.CivilWarMemberMove; +import net.tfminecraft.simplefactions.war.core.War; +import net.tfminecraft.tlibs.character.*; +import org.bukkit.Bukkit; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedConstruction; + +/** The guild a character's GUILD and FACTION locks follow, alive and after death. */ +class LockGuildTest { + @TempDir Path folder; + FactionDomainFixture fixture; + MockedConstruction databases; + MockedConstruction ledgers; + final Map characters = new HashMap<>(); + final Map deathIds = new HashMap<>(); + final AtomicBoolean saveWorks = new AtomicBoolean(true); + long clock = 1000; + + @BeforeEach void setup() throws Exception { + fixture = new FactionDomainFixture(); + databases = mockConstruction(Database.class, (database, context) -> + when(database.saveFactionDurably(any())).thenAnswer(call -> saveWorks.get())); + ledgers = mockConstruction(Ledger.class, (ledger, context) -> { + when(ledger.getHistory()).thenReturn(mock(net.tfminecraft.simplefactions.guild.income.LedgerHistory.class)); + when(ledger.getDividendBreakdown()).thenReturn(new DividendBreakdown(0, 0, 0, 0, 1, 0)); + }); + Members.Resolver resolver = mock(Members.Resolver.class, CALLS_REAL_METHODS); + when(resolver.info(anyString())).thenAnswer(call -> Optional.ofNullable(characters.get(call.getArgument(0)))); + when(resolver.online(anyString())).thenReturn(Optional.empty()); + Members.setResolver(resolver); + CharacterIdentity identity = Bukkit.getServicesManager().load(CharacterIdentity.class); + when(identity.info(any(UUID.class))).thenAnswer(call -> + Optional.ofNullable(characters.get(call.getArgument(0).toString()))); + when(identity.deathId(any(UUID.class))).thenAnswer(call -> Optional.ofNullable(deathIds.get(call.getArgument(0)))); + when(fixture.ui.plugin.getSessionManager()).thenReturn(new SessionManager()); + WarbandManager.resetForTests(); + WarbandMembershipService.resetForTests(); + Files.createDirectories(folder.resolve("Data")); + CharacterDeaths.startup(folder); + } + + @AfterEach void close() { + CharacterDeaths.stop(); + Members.useSharedIdentity(); + WarbandManager.resetForTests(); + WarbandMembershipService.resetForTests(); + ledgers.close(); + databases.close(); + fixture.close(); + } + + CharacterRef character() { + CharacterRef ref = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + status(ref, CharacterStatus.ALIVE); + return ref; + } + + void status(CharacterRef ref, CharacterStatus status) { + characters.put(key(ref), new CharacterInfo(ref, "Character", status, null, Set.of(), 0, Instant.EPOCH)); + } + + static String key(CharacterRef ref) { + return ref.characterId().toString(); + } + + UUID kill(CharacterRef ref) throws IOException { + status(ref, CharacterStatus.DEAD); + UUID death = UUID.randomUUID(); + deathIds.put(ref.characterId(), death); + CharacterDeaths.onDeath(ref, death, Instant.ofEpochMilli(clock++)); + return death; + } + + Optional lockGuild(CharacterRef ref) { + return SimpleFactionsApi.lockGuild(ref.characterId()); + } + + /** A guild that keeps a living member, so the dead owner's death does not dissolve it. */ + Guild guild(Faction faction, String id, CharacterRef... members) { + CharacterRef keeper = character(); + Guild guild = fixture.guild(faction, id, key(keeper)); + for (CharacterRef member : members) guild.addMember(key(member)); + return guild; + } + + Faction realm(String id) { + Faction faction = fixture.saved(id, key(character())); + faction.getOrCreateMainGuild(); + return faction; + } + + void restart() throws IOException { + CharacterDeaths.stop(); + CharacterDeaths.startup(folder); + } + + @Test void livingRevivedDeadAndMissingCharactersEachGetTheirOwnAnswer() throws Exception { + CharacterRef owner = character(); + Guild smiths = guild(realm("realm"), "smiths", owner); + assertSame(smiths, lockGuild(owner).orElseThrow()); + when(fixture.ui.plugin.isStarted()).thenReturn(false); + assertTrue(lockGuild(owner).isEmpty(), "unavailable until SimpleFactions is ready"); + when(fixture.ui.plugin.isStarted()).thenReturn(true); + + kill(owner); + assertFalse(smiths.isMember(key(owner))); + assertSame(smiths, lockGuild(owner).orElseThrow(), "the guild at death keeps the dead owner's locks"); + restart(); + assertSame(smiths, lockGuild(owner).orElseThrow(), "rebuilt from the journal at start"); + + status(owner, CharacterStatus.ALIVE); + assertTrue(lockGuild(owner).isEmpty(), "a revived character lost their guild at death"); + smiths.addMember(key(owner)); + assertSame(smiths, lockGuild(owner).orElseThrow()); + + status(owner, CharacterStatus.MISSING); + assertTrue(lockGuild(owner).isEmpty()); + characters.remove(key(owner)); + assertTrue(lockGuild(owner).isEmpty(), "deleted, unknown or quarantined characters have no info"); + assertTrue(SimpleFactionsApi.lockGuild(null).isEmpty()); + } + + @Test void disbandingAndRecreatingTheGuildDoesNotInheritADeadOwnersLocks() throws Exception { + CharacterRef owner = character(); + Faction faction = realm("realm"); + Guild original = guild(faction, "smiths", owner); + kill(owner); + assertSame(original, lockGuild(owner).orElseThrow()); + faction.getGuildHandler().removeGuild(original.getId()); + Guild recreated = guild(faction, "smiths"); + assertEquals(original.getId(), recreated.getId()); + assertTrue(lockGuild(owner).isEmpty(), "a new incarnation is a different guild"); + } + + @Test void ownerWhoDiedAgainWithoutAGuildLeavesNoGuildAccess() throws Exception { + CharacterRef owner = character(); + Guild smiths = guild(realm("realm"), "smiths", owner); + kill(owner); + assertSame(smiths, lockGuild(owner).orElseThrow()); + status(owner, CharacterStatus.ALIVE); + kill(owner); + assertTrue(lockGuild(owner).isEmpty(), "the guildless death removed the entry"); + restart(); + assertTrue(lockGuild(owner).isEmpty(), "the most recent journal wins at start"); + } + + @Test void deathFoundByTheBootReconcileRecordsItsGuildWhileNotReady() throws Exception { + CharacterRef owner = character(); + Guild smiths = guild(realm("realm"), "smiths", owner); + CharacterDeaths.stop(); + when(fixture.ui.plugin.isStarted()).thenReturn(false); + status(owner, CharacterStatus.DEAD); + UUID death = UUID.randomUUID(); + deathIds.put(owner.characterId(), death); + assertNull(FactionManager.getGuildByMember(owner.characterId()), "the gated lookups answer nothing during boot"); + + CharacterDeaths.startup(folder); + assertFalse(smiths.isMember(key(owner)), "the reconcile cleaned up the death"); + DeathJournal journal = DurableJson.read(folder.resolve("deaths/" + death + ".json"), DeathJournal.class); + assertEquals(smiths.getIncarnationId(), journal.lockGuildIncarnation); + assertEquals(Optional.of(smiths.getIncarnationId()), CharacterDeaths.lockGuildIncarnation(owner.characterId())); + when(fixture.ui.plugin.isStarted()).thenReturn(true); + assertSame(smiths, lockGuild(owner).orElseThrow()); + } + + @Test void journalWrittenBeforeTheFieldExistedLoadsAndDerivesItsGuildFromTheSnapshot() throws Exception { + CharacterRef owner = character(); + Guild smiths = guild(realm("realm"), "smiths", owner); + UUID death = kill(owner); + Path file = folder.resolve("deaths/" + death + ".json"); + var json = com.google.gson.JsonParser.parseString(Files.readString(file)).getAsJsonObject(); + assertNotNull(json.remove("lockGuildIncarnation")); + Files.writeString(file, json.toString()); + restart(); + assertSame(smiths, lockGuild(owner).orElseThrow()); + assertTrue(DurableJson.read(file, DeathJournal.class).complete); + } + + @Test void memberOfTwoGuildsFollowsTheFirstMatchAliveAndDead() throws Exception { + CharacterRef owner = character(); + Guild first = guild(realm("first"), "first-guild", owner); + Guild second = guild(realm("second"), "second-guild", owner); + Guild living = FactionManager.getGuildByMember(key(owner)); + assertSame(first, living); + assertSame(living, lockGuild(owner).orElseThrow()); + kill(owner); + assertSame(living, lockGuild(owner).orElseThrow()); + assertNotSame(second, lockGuild(owner).orElseThrow()); + } + + @Test void cleanupThatMustBeRetriedStillUpdatesTheIndex() throws Exception { + CharacterRef owner = character(); + Guild smiths = guild(realm("realm"), "smiths", owner); + saveWorks.set(false); + assertThrows(IOException.class, () -> kill(owner)); + assertSame(smiths, lockGuild(owner).orElseThrow()); + } + + @Test void indexIsUnavailableUntilLoaded() { + CharacterDeaths.stop(); + assertThrows(IOException.class, CharacterDeaths::indexLockGuilds); + assertTrue(CharacterDeaths.lockGuildIncarnation(UUID.randomUUID()).isEmpty()); + } + + @Nested class CivilWar { + Faction host; + Faction rebels; + Guild hostMain; + Guild rebelMain; + Guild origin; + Guild rebelGuild; + CivilWarSnapshot snapshot = new CivilWarSnapshot(); + War war = mock(War.class); + + @BeforeEach void war() { + host = realm("host"); + rebels = realm("rebels"); + hostMain = host.getOrCreateMainGuild(); + rebelMain = rebels.getOrCreateMainGuild(); + origin = guild(host, "origin"); + rebelGuild = guild(rebels, "rebel-guild"); + snapshot.setHostFactionId("host"); + snapshot.setTempRebelFactionId("rebels"); + when(war.isActive()).thenReturn(true); + when(war.getCivilWarSnapshot()).thenReturn(snapshot); + War otherWar = mock(War.class); + when(otherWar.isActive()).thenReturn(true); + WarManager.get().add(otherWar); + WarManager.get().add(war); + } + + CharacterRef rebel(String originGuildId) { + CharacterRef member = character(); + rebelMain.addMember(key(member)); + if (originGuildId != null) { + snapshot.getMemberMoves().add(new CivilWarMemberMove(key(member), originGuildId, false)); + } + return member; + } + + UUID recorded(CharacterRef member) throws IOException { + return new FactionDeaths(folder).capture(member, UUID.randomUUID(), clock++).lockGuildIncarnation; + } + + @Test void movedMemberFollowsTheOriginGuild() throws Exception { + CharacterRef member = rebel("origin"); + assertEquals(origin.getIncarnationId(), recorded(member)); + kill(member); + assertSame(origin, lockGuild(member).orElseThrow()); + } + + @Test void originAmongTheRebelGuildsResolvesAsUntangleWouldAfterMovingThem() throws Exception { + assertEquals(rebelGuild.getIncarnationId(), recorded(rebel("rebel-guild"))); + assertEquals(rebelMain.getIncarnationId(), recorded(rebel("rebels"))); + } + + @Test void movedMemberWhoseOriginIsGoneFollowsTheHostMainGuild() throws Exception { + assertEquals(hostMain.getIncarnationId(), recorded(rebel("disbanded"))); + } + + @Test void memberWithoutAMoveKeepsTheRebelMainGuild() throws Exception { + assertEquals(rebelMain.getIncarnationId(), recorded(rebel(null))); + } + + @Test void wantedLeaderIsRecordedLikeAnyMovedMember() throws Exception { + CharacterRef wanted = rebel("origin"); + snapshot.setWantedLeaderName(key(wanted)); + assertEquals(origin.getIncarnationId(), recorded(wanted)); + } + + @Test void relocatedRebelGuildKeepsItsOwnIncarnation() throws Exception { + CharacterRef member = character(); + rebelGuild.addMember(key(member)); + snapshot.getMemberMoves().add(new CivilWarMemberMove(key(member), "origin", false)); + assertEquals(rebelGuild.getIncarnationId(), recorded(member)); + } + + @Test void missingHostLeavesTheRebelMainGuild() throws Exception { + CharacterRef member = rebel("origin"); + snapshot.setHostFactionId("vanished"); + assertEquals(rebelMain.getIncarnationId(), recorded(member)); + } + + @Test void endedWarAppliesNoMapping() throws Exception { + CharacterRef member = rebel("origin"); + when(war.isActive()).thenReturn(false); + assertEquals(rebelMain.getIncarnationId(), recorded(member)); + when(war.isActive()).thenReturn(true); + snapshot.setTempRebelFactionId("vanished"); + assertEquals(rebelMain.getIncarnationId(), recorded(member)); + } + } +}