diff --git a/README.md b/README.md
index a86d104..5a584b7 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ BirdMessenger turns written correspondence into an in-world activity. Players se
- **Travel time** — delivery time follows the distance to the recipient's character location, with an estimated arrival shown to the sender.
- **Pending deliveries** — letters wait until their recipient is online and using the correct character; in-flight mail resumes after a restart.
- **Delivery feedback** — sender and recipient receive messages and bird-themed sound feedback as correspondence progresses.
+- **Letters after a death** — letters waiting for a dead character go to their will heir, or back to whoever wrote them.
- **Optional Discord notices** — TFMCWeb integration can notify linked recipients when a letter's flight completes.
The mailbox interaction protects against accidental ordinary left-click breaking, while deliberate sneak-breaking remains available. Sending, recipient selection, and confirmation all happen in game, keeping correspondence close to the roleplay world.
@@ -28,8 +29,8 @@ Technical documentation is maintained in [TF-Minecraft/Docs](https://github.com/
With Java 21 and the [build dependencies](https://github.com/TF-Minecraft/Docs/blob/main/projects/BirdMessenger/README.md#build-and-dependencies) prepared,
run `mvn clean verify`.
-JUnit 5 and Mockito cover letters, recipient selection, delivery, persistence and
-plugin boundaries without starting a live Minecraft server.
+JUnit 5 and Mockito cover letters, recipient selection, delivery, persistence, estates
+and plugin boundaries without starting a live Minecraft server.
`mvn clean verify` requires 100% production line coverage with no class or package
exclusions. JaCoCo writes `target/site/jacoco/index.html` and
`target/site/jacoco/jacoco.xml`; Surefire results are in `target/surefire-reports/`.
diff --git a/pom.xml b/pom.xml
index 524466b..ccdb3da 100644
--- a/pom.xml
+++ b/pom.xml
@@ -14,7 +14,7 @@
UTF-8
full
2.16.0
- 2.3.0
+ 2.6.0
diff --git a/src/main/java/net/tfminecraft/birdmessenger/BirdConfig.java b/src/main/java/net/tfminecraft/birdmessenger/BirdConfig.java
index 921cc26..7259226 100644
--- a/src/main/java/net/tfminecraft/birdmessenger/BirdConfig.java
+++ b/src/main/java/net/tfminecraft/birdmessenger/BirdConfig.java
@@ -116,6 +116,15 @@ public String msgUnsentReturned() {
return message("unsent-returned", "&bA bird lands at your feet and returns your unsent letter.");
}
+ public String msgReturnedRecipientDied() {
+ return message("returned-recipient-died",
+ "&eA bird brings back a letter you sent. Returned: the recipient has died.");
+ }
+
+ public String msgReturnedToSender() {
+ return message("returned-to-sender", "&eA bird brings back a letter you sent. Returned.");
+ }
+
public String msgLetterCancelled() {
return message("letter-cancelled", "&cLetter cancelled.");
}
diff --git a/src/main/java/net/tfminecraft/birdmessenger/BirdMessenger.java b/src/main/java/net/tfminecraft/birdmessenger/BirdMessenger.java
index 0df8190..c4f4c31 100644
--- a/src/main/java/net/tfminecraft/birdmessenger/BirdMessenger.java
+++ b/src/main/java/net/tfminecraft/birdmessenger/BirdMessenger.java
@@ -7,6 +7,8 @@
import net.tfminecraft.rpcharacters.RPCharacters;
import net.tfminecraft.birdmessenger.command.BirdMessengerCommand;
import net.tfminecraft.birdmessenger.gui.CharacterPickerGui;
+import net.tfminecraft.birdmessenger.estate.EstateProbe;
+import net.tfminecraft.birdmessenger.estate.LetterEstate;
import net.tfminecraft.birdmessenger.listener.CoopListener;
import net.tfminecraft.birdmessenger.listener.CoopFurnitureListener;
import net.tfminecraft.birdmessenger.listener.GuiListener;
@@ -48,6 +50,11 @@ public void onEnable() {
MailStore store = new MailStore(this);
store.load();
mail = new MailService(this, store);
+ if (EstateProbe.available(EstateProbe.CLASSES)) {
+ LetterEstate.start(this, store);
+ } else {
+ getLogger().warning("TLibs has no estate API (2.6.0 or later); letters for dead characters keep waiting.");
+ }
Bukkit.getPluginManager().registerEvents(new CoopListener(this), this);
if (Bukkit.getPluginManager().getPlugin("ItemsAdder") != null) {
Bukkit.getPluginManager().registerEvents(new CoopFurnitureListener(this), this);
diff --git a/src/main/java/net/tfminecraft/birdmessenger/estate/EstateProbe.java b/src/main/java/net/tfminecraft/birdmessenger/estate/EstateProbe.java
new file mode 100644
index 0000000..7145897
--- /dev/null
+++ b/src/main/java/net/tfminecraft/birdmessenger/estate/EstateProbe.java
@@ -0,0 +1,27 @@
+package net.tfminecraft.birdmessenger.estate;
+
+/** Whether TLibs has the estate API (2.6.0). On older TLibs letters wait for dead characters as before. */
+public final class EstateProbe {
+
+ public static final String[] CLASSES = {
+ "net.tfminecraft.tlibs.character.estate.Estates",
+ "net.tfminecraft.tlibs.character.estate.EstateParticipant",
+ "net.tfminecraft.tlibs.character.estate.EstateDecision",
+ "net.tfminecraft.tlibs.character.estate.Outcome",
+ "net.tfminecraft.tlibs.character.estate.Holdings",
+ };
+
+ private EstateProbe() {
+ }
+
+ public static boolean available(String... classes) {
+ try {
+ for (String name : classes) {
+ Class.forName(name);
+ }
+ return true;
+ } catch (ReflectiveOperationException | LinkageError ex) {
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/birdmessenger/estate/LetterEstate.java b/src/main/java/net/tfminecraft/birdmessenger/estate/LetterEstate.java
new file mode 100644
index 0000000..dea2455
--- /dev/null
+++ b/src/main/java/net/tfminecraft/birdmessenger/estate/LetterEstate.java
@@ -0,0 +1,210 @@
+package net.tfminecraft.birdmessenger.estate;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import java.util.logging.Logger;
+
+import org.bukkit.configuration.InvalidConfigurationException;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.plugin.ServicePriority;
+
+import net.tfminecraft.birdmessenger.mail.MailStore;
+import net.tfminecraft.birdmessenger.util.DurableFiles;
+import net.tfminecraft.birdmessenger.mail.MailStore.Destination;
+import net.tfminecraft.birdmessenger.mail.MailStore.Move;
+import net.tfminecraft.birdmessenger.mail.MailStore.PendingLetter;
+import net.tfminecraft.tlibs.character.CharacterInfo;
+import net.tfminecraft.tlibs.character.CharacterRef;
+import net.tfminecraft.tlibs.character.Characters;
+import net.tfminecraft.tlibs.character.estate.EstateDecision;
+import net.tfminecraft.tlibs.character.estate.EstateParticipant;
+import net.tfminecraft.tlibs.character.estate.Estates;
+import net.tfminecraft.tlibs.character.estate.Holdings;
+import net.tfminecraft.tlibs.character.estate.Outcome;
+
+/**
+ * The {@code birdmessenger} estate participant: letters waiting for a dead character go to the will
+ * heir, or back to whoever wrote them. Loaded only after {@link EstateProbe} finds the TLibs 2.6.0
+ * estate API. Letters still in flight are left alone; they join the queue on landing and the hourly
+ * reconcile moves them.
+ */
+public final class LetterEstate implements EstateParticipant {
+ static final String ID = "birdmessenger";
+ static final long HOUR_TICKS = 20L * 60 * 60;
+
+ interface Reporter {
+ boolean report(UUID deathId, String participantId, long revision, Outcome outcome);
+ }
+
+ private final MailStore store;
+ private final Path journals;
+ private final Logger logger;
+ Predicate guard = Estates::guardHolds;
+ Supplier> decided = Estates::decided;
+ Reporter reporter = Estates::report;
+ Function> characters = Characters::info;
+ // The last outcome reported per death, so the hourly pass reports only changes.
+ private final Map> reported = new HashMap<>();
+
+ LetterEstate(MailStore store, Path journals, Logger logger) {
+ this.store = store;
+ this.journals = journals;
+ this.logger = logger;
+ }
+
+ /** Registers the participant once mail is loaded; reconciles on the first tick, then hourly. */
+ public static void start(Plugin plugin, MailStore store) {
+ LetterEstate estate = new LetterEstate(store, plugin.getDataFolder().toPath().resolve("estates"),
+ plugin.getLogger());
+ plugin.getServer().getServicesManager().register(EstateParticipant.class, estate, plugin, ServicePriority.Normal);
+ plugin.getServer().getScheduler().runTaskTimer(plugin, estate::reconcile, 1L, HOUR_TICKS);
+ }
+
+ @Override
+ public String id() {
+ return ID;
+ }
+
+ @Override
+ public Outcome settle(EstateDecision decision) {
+ if (!guard.test(decision)) {
+ return Outcome.retry("The estate guard does not hold");
+ }
+ String testator = decision.testator().characterId().toString();
+ Optional heir = decision.letters().character();
+ try {
+ // Letters with no move stay with the testator for staff; describe() lists them.
+ List moves = new ArrayList<>();
+ for (PendingLetter letter : store.identifyPending(testator)) {
+ Move move = heir.isPresent()
+ ? new Move(letter.id, Destination.HEIR, heir.get().characterId().toString(), heir.get().ownerId())
+ : toSender(letter);
+ if (move != null) {
+ moves.add(move);
+ }
+ }
+ Set planned = readJournal(decision.deathId());
+ boolean planning = false;
+ for (Move move : moves) {
+ planning |= planned.add(move.letterId().toString());
+ }
+ if (planning) {
+ writeJournal(decision.deathId(), planned);
+ }
+ store.moveEstateLetters(testator, moves);
+ Set waiting = new HashSet<>();
+ store.pendingFor(testator).forEach(letter -> waiting.add(String.valueOf(letter.id)));
+ List moved = planned.stream().filter(id -> !waiting.contains(id)).toList();
+ return Outcome.done(Map.of("letters", moved.size()), moved);
+ } catch (IOException | RuntimeException ex) {
+ logger.warning("Could not move the letters of estate " + decision.deathId() + ": " + ex);
+ return Outcome.retry("Could not move a letter: " + ex.getMessage());
+ }
+ }
+
+ /**
+ * Where a letter goes when it returns to its writer, or null to hold it for staff: the writer's
+ * character is dead or unknown, or the letter is the dead character's own unsent one.
+ */
+ private Move toSender(PendingLetter letter) {
+ if (letter.returned) {
+ return null;
+ }
+ if (letter.senderCharacterId == null) {
+ return letter.senderUuid == null ? null
+ : new Move(letter.id, Destination.SENDER_ACCOUNT, null, letter.senderUuid);
+ }
+ Optional writer;
+ try {
+ writer = characters.apply(UUID.fromString(letter.senderCharacterId));
+ } catch (IllegalArgumentException ex) {
+ return null;
+ }
+ return writer.filter(CharacterInfo::alive)
+ .map(info -> new Move(letter.id, Destination.SENDER_CHARACTER, letter.senderCharacterId,
+ info.ref().ownerId()))
+ .orElse(null);
+ }
+
+ @Override
+ public Holdings describe(UUID characterId) {
+ List labels = store.pendingFor(characterId.toString()).stream().map(this::label).toList();
+ return labels.isEmpty() ? Holdings.NONE : new Holdings(Map.of("letters", labels.size()), labels);
+ }
+
+ // Names the writer where it can, never an account or character UUID.
+ private String label(PendingLetter letter) {
+ String name = letter.id == null ? "Letter" : "Letter " + letter.id.toString().substring(0, 8);
+ if (letter.returned) {
+ return name + ", this character's own unsent letter";
+ }
+ if (letter.senderCharacterId == null) {
+ return letter.senderUuid == null ? name + " with no sender" : name + " from before writers were recorded";
+ }
+ Optional writer;
+ try {
+ writer = characters.apply(UUID.fromString(letter.senderCharacterId));
+ } catch (IllegalArgumentException ex) {
+ writer = Optional.empty();
+ }
+ return writer.map(info -> name + " from " + info.name() + (info.alive() ? "" : " (dead)"))
+ .orElse(name + " from an unknown writer");
+ }
+
+ /** Settles every decided estate whose guard holds and reports outcomes that changed. */
+ void reconcile() {
+ for (EstateDecision decision : decided.get()) {
+ // One estate failing must not stop the others until the next hour.
+ try {
+ if (!guard.test(decision)) {
+ continue;
+ }
+ Outcome outcome = settle(decision);
+ Map.Entry last = Map.entry(decision.revision(), outcome);
+ if (!last.equals(reported.get(decision.deathId()))
+ && reporter.report(decision.deathId(), ID, decision.revision(), outcome)) {
+ reported.put(decision.deathId(), last);
+ }
+ } catch (RuntimeException ex) {
+ logger.warning("Could not reconcile the letters of estate " + decision.deathId() + ": " + ex);
+ }
+ }
+ }
+
+ // Every letter ID this estate has planned to move, written before the queue changes.
+ private Set readJournal(UUID deathId) throws IOException {
+ Path file = journals.resolve(deathId + ".yml");
+ Set ids = new LinkedHashSet<>();
+ if (Files.exists(file)) {
+ YamlConfiguration journal = new YamlConfiguration();
+ try {
+ journal.loadFromString(Files.readString(file));
+ } catch (InvalidConfigurationException ex) {
+ throw new IOException("Unreadable estate journal " + file, ex);
+ }
+ ids.addAll(journal.getStringList("letters"));
+ }
+ return ids;
+ }
+
+ private void writeJournal(UUID deathId, Set ids) throws IOException {
+ YamlConfiguration journal = new YamlConfiguration();
+ journal.set("death", deathId.toString());
+ journal.set("letters", new ArrayList<>(ids));
+ DurableFiles.write(journals.resolve(deathId + ".yml"), journal.saveToString());
+ }
+}
diff --git a/src/main/java/net/tfminecraft/birdmessenger/mail/MailDelivery.java b/src/main/java/net/tfminecraft/birdmessenger/mail/MailDelivery.java
index 829b01b..4edd837 100644
--- a/src/main/java/net/tfminecraft/birdmessenger/mail/MailDelivery.java
+++ b/src/main/java/net/tfminecraft/birdmessenger/mail/MailDelivery.java
@@ -67,6 +67,21 @@ public void returnUnsent(Player owner, ItemStack letter) {
});
}
+ /**
+ * Hands back a sent letter that its recipient never received. {@code recipientDied} names the reason;
+ * letters returned to an account say only that they were returned.
+ */
+ public void returnToSender(Player owner, ItemStack letter, boolean recipientDied) {
+ if (owner == null || letter == null) {
+ return;
+ }
+ ItemGive.giveOrDrop(owner, letter.clone());
+ notifyQuietly(() -> {
+ owner.sendMessage(recipientDied ? config.msgReturnedRecipientDied() : config.msgReturnedToSender());
+ owner.playSound(owner.getLocation(), Sound.ENTITY_PARROT_FLY, 1f, 1f);
+ });
+ }
+
private static void notifyQuietly(Runnable notification) {
try {
notification.run();
diff --git a/src/main/java/net/tfminecraft/birdmessenger/mail/MailService.java b/src/main/java/net/tfminecraft/birdmessenger/mail/MailService.java
index 5a5645b..031f90d 100644
--- a/src/main/java/net/tfminecraft/birdmessenger/mail/MailService.java
+++ b/src/main/java/net/tfminecraft/birdmessenger/mail/MailService.java
@@ -76,7 +76,8 @@ public boolean trySend(Player sender, CharacterSession origin, ItemStack letter,
target.getCharacterId(),
target.getDisplayTab(),
letter.clone(),
- deliveryTime);
+ deliveryTime,
+ origin.ref().characterId().toString());
if (!SendSession.isCurrent(sender, origin)) {
refundLetter(sender, origin, letter);
return false;
@@ -136,10 +137,15 @@ public void tryDeliverPending(Player owner, CharacterSession session) {
store.drainPending(session.ref(), () -> SendSession.isCurrent(owner, session), pending -> {
if (pending.returned) {
delivery.returnUnsent(owner, pending.item);
+ } else if (pending.recipientDied) {
+ delivery.returnToSender(owner, pending.item, true);
} else {
delivery.deliver(owner, pending.item, pending.senderUuid, pending.addresseeDisplayTab);
}
});
+ // Letters returned to the account, whichever of its characters is played.
+ store.drainReturned(session.ref().ownerId(), () -> SendSession.isCurrent(owner, session),
+ pending -> delivery.returnToSender(owner, pending.item, false));
}
private void schedule(StoredMail mail) {
diff --git a/src/main/java/net/tfminecraft/birdmessenger/mail/MailStore.java b/src/main/java/net/tfminecraft/birdmessenger/mail/MailStore.java
index f5b020c..b624401 100644
--- a/src/main/java/net/tfminecraft/birdmessenger/mail/MailStore.java
+++ b/src/main/java/net/tfminecraft/birdmessenger/mail/MailStore.java
@@ -23,6 +23,7 @@
import org.bukkit.inventory.ItemStack;
import net.tfminecraft.birdmessenger.BirdMessenger;
+import net.tfminecraft.birdmessenger.util.DurableFiles;
import net.tfminecraft.tlibs.character.CharacterRef;
public final class MailStore {
@@ -30,8 +31,11 @@ public final class MailStore {
private final BirdMessenger plugin;
private final File inFlightFile;
private final File pendingFile;
+ private final File returnedFile;
private final Map inFlight = new ConcurrentHashMap<>();
private final Map> pending = new ConcurrentHashMap<>();
+ // Letters sent back to an account because their writer's character was not recorded.
+ private final Map> returned = new ConcurrentHashMap<>();
private final Set recoveryRequired = new HashSet<>();
public MailStore(BirdMessenger plugin) {
@@ -42,6 +46,7 @@ public MailStore(BirdMessenger plugin) {
}
this.inFlightFile = new File(data, "in_flight_mail.yml");
this.pendingFile = new File(data, "pending_mail.yml");
+ this.returnedFile = new File(data, "returned_mail.yml");
}
public Map inFlight() {
@@ -127,11 +132,8 @@ public void addPending(StoredMail mail) {
if (mail == null || mail.getCharacterId() == null) {
return;
}
- addPending(mail.getCharacterId(), new PendingLetter(
- mail.getOwnerUuid(),
- mail.getSenderUuid(),
- mail.getAddresseeDisplayTab(),
- mail.getItem()));
+ addPending(mail.getCharacterId(), new PendingLetter(mail.getId(), mail.getOwnerUuid(), mail.getSenderUuid(),
+ mail.getSenderCharacterId(), mail.getAddresseeDisplayTab(), mail.getItem(), false, false));
}
public void addPending(String characterId, PendingLetter letter) {
@@ -140,32 +142,226 @@ public void addPending(String characterId, PendingLetter letter) {
}
requireRecovered(pendingFile);
Objects.requireNonNull(letter.ownerUuid, "Pending letter owner is required");
- PendingLetter snapshot = new PendingLetter(letter.ownerUuid, letter.senderUuid,
- letter.addresseeDisplayTab, letter.item.clone(), letter.returned);
+ enqueue(characterId, letter.copy(letter.id, letter.ownerUuid));
+ savePending();
+ }
+
+ private void enqueue(String characterId, PendingLetter snapshot) {
// Publish only complete entries: this private map always contains nonempty lists.
pending.compute(characterId, (key, letters) -> {
if (letters == null) {
letters = new ArrayList<>();
}
for (PendingLetter existing : letters) {
- if (!letter.ownerUuid.equals(existing.ownerUuid)) {
+ if (!snapshot.ownerUuid.equals(existing.ownerUuid)) {
throw new IllegalStateException("Pending character group belongs to another owner");
}
}
letters.add(snapshot);
return letters;
});
- savePending();
+ }
+
+ /** Where an estate sends one of the dead character's waiting letters. */
+ public enum Destination {
+ /** To the will heir, as ordinary mail. */
+ HEIR,
+ /** Back to the character that wrote it, marked as returned because the recipient died. */
+ SENDER_CHARACTER,
+ /** Back to the writer's account, for letters that never recorded their character. */
+ SENDER_ACCOUNT
+ }
+
+ /**
+ * @param characterId the receiving character; unused for {@link Destination#SENDER_ACCOUNT}
+ * @param ownerUuid the receiving account
+ */
+ public record Move(UUID letterId, Destination destination, String characterId, UUID ownerUuid) {
+ }
+
+ /** A copy of this character's waiting letters, oldest first. */
+ public List pendingFor(String characterId) {
+ List letters = new ArrayList<>();
+ for (Map.Entry> entry : pending.entrySet()) {
+ if (entry.getKey().equalsIgnoreCase(characterId)) {
+ letters.addAll(entry.getValue());
+ }
+ }
+ return letters;
+ }
+
+ /**
+ * This character's waiting letters, each with a stored ID. Letters queued before IDs were kept are
+ * given one and saved first, so a move interrupted between files can be recognised. Throws, with
+ * nothing changed, when that save fails.
+ */
+ public List identifyPending(String characterId) throws IOException {
+ Map> before = snapshot(pending);
+ boolean changed = false;
+ for (Map.Entry> entry : pending.entrySet()) {
+ if (!entry.getKey().equalsIgnoreCase(characterId)) {
+ continue;
+ }
+ List letters = entry.getValue();
+ for (int i = 0; i < letters.size(); i++) {
+ if (letters.get(i).id == null) {
+ requireRecovered(pendingFile);
+ letters.set(i, letters.get(i).copy(UUID.randomUUID(), letters.get(i).ownerUuid));
+ changed = true;
+ }
+ }
+ }
+ if (changed) {
+ try {
+ writePending();
+ } catch (IOException | RuntimeException failure) {
+ restore(pending, before);
+ throw failure;
+ }
+ }
+ return pendingFor(characterId);
+ }
+
+ /**
+ * Moves the named letters out of the dead character's queue, all or nothing. Every receiving queue is
+ * checked first. Letters for an account queue are saved there before the character queue drops them;
+ * {@link #load()} discards a copy left behind by a crash in between. A failed save puts both queues
+ * back as they were in memory and throws. Returns the IDs moved now.
+ */
+ public List moveEstateLetters(String characterId, List moves) throws IOException {
+ requireRecovered(pendingFile);
+ requireRecovered(returnedFile);
+ Map byId = new LinkedHashMap<>();
+ for (Move move : moves) {
+ byId.put(move.letterId(), move);
+ if (move.destination() != Destination.SENDER_ACCOUNT) {
+ List queue = pending.get(move.characterId());
+ if (queue != null && !queue.getFirst().ownerUuid.equals(move.ownerUuid())) {
+ throw new IllegalStateException("Pending queue of " + move.characterId() + " belongs to another owner");
+ }
+ }
+ }
+ List taken = new ArrayList<>();
+ for (PendingLetter letter : pendingFor(characterId)) {
+ if (letter.id != null && byId.containsKey(letter.id)) {
+ taken.add(letter);
+ }
+ }
+ if (taken.isEmpty()) {
+ return List.of();
+ }
+ Map> pendingBefore = snapshot(pending);
+ Map> returnedBefore = snapshot(returned);
+ try {
+ boolean toAccounts = false;
+ List moved = new ArrayList<>();
+ for (PendingLetter letter : taken) {
+ Move move = byId.get(letter.id);
+ switch (move.destination()) {
+ case SENDER_ACCOUNT -> {
+ returned.computeIfAbsent(move.ownerUuid(), ignored -> new ArrayList<>())
+ .add(letter.copy(letter.id, move.ownerUuid()));
+ toAccounts = true;
+ }
+ case HEIR -> enqueue(move.characterId(), new PendingLetter(letter.id, move.ownerUuid(),
+ letter.senderUuid, letter.senderCharacterId, "", letter.item.clone(), false, false));
+ case SENDER_CHARACTER -> enqueue(move.characterId(), new PendingLetter(letter.id, move.ownerUuid(),
+ letter.senderUuid, letter.senderCharacterId, letter.addresseeDisplayTab, letter.item.clone(),
+ false, true));
+ }
+ moved.add(letter.id);
+ }
+ pending.entrySet().removeIf(entry -> {
+ if (!entry.getKey().equalsIgnoreCase(characterId)) {
+ return false;
+ }
+ entry.getValue().removeIf(letter -> letter.id != null && byId.containsKey(letter.id));
+ return entry.getValue().isEmpty();
+ });
+ if (toAccounts) {
+ writeReturned();
+ }
+ writePending();
+ return moved;
+ } catch (IOException | RuntimeException failure) {
+ restore(pending, pendingBefore);
+ restore(returned, returnedBefore);
+ throw failure;
+ }
+ }
+
+ private static Map> snapshot(Map> queues) {
+ Map> copy = new LinkedHashMap<>();
+ queues.forEach((key, letters) -> copy.put(key, new ArrayList<>(letters)));
+ return copy;
+ }
+
+ private static void restore(Map> queues, Map> snapshot) {
+ queues.clear();
+ queues.putAll(snapshot);
+ }
+
+ /**
+ * Hands this account's returned letters to {@code deliver} while {@code current} holds, then saves
+ * once. A letter goes back to the head of the queue if the hand-over throws.
+ */
+ public void drainReturned(UUID account, BooleanSupplier current, Consumer deliver) {
+ if (account == null) {
+ return;
+ }
+ boolean taken = false;
+ try {
+ while (current.getAsBoolean()) {
+ List letters = returned.get(account);
+ if (letters == null) {
+ return;
+ }
+ requireRecovered(returnedFile);
+ PendingLetter letter = letters.removeFirst();
+ if (letters.isEmpty()) {
+ returned.remove(account, letters);
+ }
+ taken = true;
+ try {
+ deliver.accept(letter);
+ } catch (RuntimeException failure) {
+ returned.computeIfAbsent(account, ignored -> new ArrayList<>()).addFirst(letter);
+ throw failure;
+ }
+ }
+ } finally {
+ if (taken) {
+ saveReturned();
+ }
+ }
}
public void load() {
loadInFlight();
loadPending();
+ loadReturned();
+ dropMovedCopies();
}
public void saveAll() {
saveInFlight();
savePending();
+ saveReturned();
+ }
+
+ // A crash after an estate saved a letter to an account queue, but before the dead character's queue
+ // dropped it, leaves the same letter in both. The account copy is the moved one.
+ private void dropMovedCopies() {
+ Set ids = new HashSet<>();
+ returned.values().forEach(letters -> letters.forEach(letter -> ids.add(letter.id)));
+ ids.remove(null);
+ int before = pending.values().stream().mapToInt(List::size).sum();
+ pending.values().forEach(letters -> letters.removeIf(letter -> ids.contains(letter.id)));
+ pending.values().removeIf(List::isEmpty);
+ if (pending.values().stream().mapToInt(List::size).sum() != before) {
+ plugin.getLogger().warning("Dropped pending letters already returned to their senders' accounts.");
+ savePending();
+ }
}
private void saveInFlight() {
@@ -176,12 +372,13 @@ private void saveInFlight() {
config.set(path + ".sender", mail.getSenderUuid().toString());
config.set(path + ".owner", mail.getOwnerUuid().toString());
config.set(path + ".character-id", mail.getCharacterId());
+ config.set(path + ".sender-character", mail.getSenderCharacterId());
config.set(path + ".addressee-display", mail.getAddresseeDisplayTab());
config.set(path + ".delivery-time", mail.getDeliveryTime());
config.set(path + ".item", mail.getItem().serialize());
}
try {
- config.save(inFlightFile);
+ DurableFiles.write(inFlightFile.toPath(), config.saveToString());
} catch (IOException ex) {
plugin.getLogger().log(Level.SEVERE, "Could not save in-flight mail", ex);
}
@@ -234,7 +431,8 @@ private void loadInFlight() {
characterId,
addresseeDisplay,
item,
- deliveryTime));
+ deliveryTime,
+ config.getString("mail." + key + ".sender-character")));
} catch (Exception ex) {
damaged = true;
plugin.getLogger().warning("Could not load in-flight mail " + key);
@@ -244,34 +442,109 @@ private void loadInFlight() {
}
private void savePending() {
+ try {
+ writePending();
+ } catch (IOException ex) {
+ plugin.getLogger().log(Level.SEVERE, "Could not save pending mail", ex);
+ }
+ }
+
+ private void writePending() throws IOException {
requireRecovered(pendingFile);
FileConfiguration config = new YamlConfiguration();
for (Map.Entry> entry : pending.entrySet()) {
String path = "characters." + entry.getKey();
List letters = entry.getValue();
config.set(path + ".owner", letters.get(0).ownerUuid.toString());
- List