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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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/`.
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.proc>full</maven.compiler.proc>
<rpcharacters.version>2.16.0</rpcharacters.version>
<tlibs.version>2.3.0</tlibs.version>
<tlibs.version>2.6.0</tlibs.version>
</properties>

<dependencies>
Expand Down
9 changes: 9 additions & 0 deletions src/main/java/net/tfminecraft/birdmessenger/BirdConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
210 changes: 210 additions & 0 deletions src/main/java/net/tfminecraft/birdmessenger/estate/LetterEstate.java
Original file line number Diff line number Diff line change
@@ -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<EstateDecision> guard = Estates::guardHolds;
Supplier<List<EstateDecision>> decided = Estates::decided;
Reporter reporter = Estates::report;
Function<UUID, Optional<CharacterInfo>> characters = Characters::info;
// The last outcome reported per death, so the hourly pass reports only changes.
private final Map<UUID, Map.Entry<Long, Outcome>> 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<CharacterRef> heir = decision.letters().character();
try {
// Letters with no move stay with the testator for staff; describe() lists them.
List<Move> 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<String> 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<String> waiting = new HashSet<>();
store.pendingFor(testator).forEach(letter -> waiting.add(String.valueOf(letter.id)));
List<String> 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<CharacterInfo> 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<String> 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<CharacterInfo> 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<Long, Outcome> 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<String> readJournal(UUID deathId) throws IOException {
Path file = journals.resolve(deathId + ".yml");
Set<String> 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<String> 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());
}
}
15 changes: 15 additions & 0 deletions src/main/java/net/tfminecraft/birdmessenger/mail/MailDelivery.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading