Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>net.tfminecraft</groupId>
<artifactId>simplefactions</artifactId>
<version>4.0.0</version>
<version>4.1.0</version>

<properties>
<argLine></argLine>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -35,6 +37,27 @@ public static Optional<Guild> 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<Guild> 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<Guild> getGuildByLeader(Player player) {
return ready() ? Optional.ofNullable(FactionManager.getGuildByLeader(player)) : Optional.empty();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,19 +17,22 @@
public final class CharacterDeaths {
private static DeathJournalStore store;
private static FactionDeaths factions;
private static LockGuildIndex lockGuilds;

private CharacterDeaths() {}

/** After faction loading and settlement recovery, before enabling gameplay or timers. */
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();
}

Expand All @@ -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()));
}
}
}
Expand All @@ -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<UUID> 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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

public static List<OwedPayment> pendingPayments() {
Expand All @@ -75,6 +100,7 @@ public static void applyPayments(String settlementId, Map<String, Long> paidCent
public static void stop() {
store = null;
factions = null;
lockGuilds = null;
}

private static void requireStarted() throws IOException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Step> steps = new ArrayList<>();
List<Claim> claims = new ArrayList<>();
Map<String, Map<String, Long>> payments = new LinkedHashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeathJournal> all() throws IOException {
List<DeathJournal> all() throws IOException {
if (!Files.exists(folder)) return List.of();
List<DeathJournal> journals = new ArrayList<>();
try (var files = Files.list(folder)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<DeathJournal.Step> successions = new ArrayList<>();
for (Faction faction : FactionManager.getCopy()) {
JsonObject before = snapshot(faction);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UUID, UUID> entries = new HashMap<>();

/** Only each character's most recent journal counts; two journals at the same moment deny. */
void rebuild(List<DeathJournal> journals) {
Map<UUID, List<DeathJournal>> latest = new HashMap<>();
for (DeathJournal journal : journals) {
List<DeathJournal> 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<UUID> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading
Loading