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));
+ }
+ }
+}