Files
canalhandia/src/main/java/dev/marcospaulo/canalhandia/DeathLog.java
T
marcos 26148740ef Add offline mail, death history and named achievements
Three features that all answer questions the server could already have
answered but was throwing away.

Mail (/recado <jogador> <texto>, /recados). On a server where people rarely
overlap, "achei diamante em -400 70 200" had to go through Discord or be lost.
Messages are queued for anyone who has joined before — resolved through
usercache.json, case-insensitively, because nobody types a name with the right
capitalisation — and delivered on their next join. If the recipient is online
it is delivered immediately instead of queued, since queueing it would mean
the person standing next to you reads it only after a relog.

Delivery is destructive: a message that stayed queued would be re-read on every
single join, turning a helpful note into a nuisance. It is also delayed a few
seconds and re-checks isOnline, because a player can leave inside the delay and
the mail would otherwise be consumed with nobody there to read it. Its own
join handler, not a branch inside onJoin, which returns early when the
curiosidades module is off — mail must not depend on an unrelated module.
Inbox capped per recipient, counting every sender, so the cap cannot be
bypassed with a second account.

Death history (/mortes). The mortes module already knew where and how someone
died and discarded it once the coords were delivered on respawn. Keeping the
last ten per player answers what people actually ask a day later. Eviction is
per player, not global, or one player's bad night would erase everyone else's
history. Your own deaths only: where someone died is where their stuff is, and
a public list of that is a looting guide.

Named achievements (/conquistas). Milestones covers round numbers; this covers
the combinations that say something about how someone plays — "Casca Grossa"
(50 hours, under 10 deaths), "Turista" (100 hours, barely mined), "Imortal às
Avessas" (dies more than once per hundred blocks mined). Every condition is a
pure function of a stat map, so the catalogue is unit-tested without a server.
The ratio ones carry a floor on absolute mining, so a new player is not handed
a joke achievement on their second death — there is a test for exactly that.

First sight is silent, like Milestones: the first time a player is checked,
whatever they have already earned is recorded without announcing it. Otherwise
enabling the module would dump a dozen announcements for history earned months
ago. Players who qualify for nothing are still marked as seen, or every later
check would treat them as new and stay silent forever.

Achievements share the milestone task rather than adding a second timer: both
sweep every online player's statistics, so one pass does the work of two.
Stats.totalOf sums a material-keyed statistic into a long — the per-material
values are ints and a long-running player's total can pass Integer.MAX_VALUE.

The /conquistas checklist uses "[x]" on Bedrock instead of "✔", which renders
there as a tofu box — the same per-platform rule the reaction labels follow.

Msg.ago renders wall-clock timestamps as "há 2 dias"; a timestamp from the
future clamps to "agora" rather than printing a negative age.

263 tests, up from 218.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-08 00:49:56 +00:00

142 lines
4.7 KiB
Java

package dev.marcospaulo.canalhandia;
import org.bukkit.configuration.file.YamlConfiguration;
import java.io.File;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* A short history of where and how each player died.
*
* <p>The {@code mortes} module already knows all of this at death time and then
* throws it away once the coordinates have been delivered on respawn. Keeping it
* costs a few lines of YAML and answers the question people actually ask a day
* later: "onde foi que eu morri com o pico de diamante?"
*
* <p>Bounded per player, oldest dropped first. This is a recent-history feature,
* not an archive — on a server where someone dies fifty times a night, an
* unbounded log would grow without ever being read.
*/
final class DeathLog {
/** One recorded death. {@code at} is a wall-clock millisecond timestamp. */
record Entry(String playerId, String flavor, String world, int x, int y, int z, long at) {
String coords() {
return x + ", " + y + ", " + z;
}
String place() {
return coords() + (world == null || world.isBlank() ? "" : " (" + world + ")");
}
}
/**
* How many deaths are kept per player. Ten covers "where did I die
* recently" without turning the file into a diary.
*/
static final int MAX_PER_PLAYER = 10;
private final File file;
private final List<Entry> entries = new ArrayList<>();
DeathLog(File file) {
this.file = file;
load();
}
void load() {
YamlConfiguration yaml = file.exists() ? YamlConfiguration.loadConfiguration(file) : null;
synchronized (entries) {
entries.clear();
if (yaml == null) {
return;
}
for (String key : yaml.getKeys(false)) {
String playerId = yaml.getString(key + ".jogador-id");
if (playerId == null) {
continue;
}
entries.add(new Entry(playerId,
yaml.getString(key + ".causa", "bateu as botas"),
yaml.getString(key + ".mundo", ""),
yaml.getInt(key + ".x"),
yaml.getInt(key + ".y"),
yaml.getInt(key + ".z"),
yaml.getLong(key + ".em", 0)));
}
}
}
/** Records a death, evicting this player's oldest once past the cap. */
void record(String playerId, String flavor, String world, int x, int y, int z) {
synchronized (entries) {
entries.add(new Entry(playerId, flavor, world, x, y, z, System.currentTimeMillis()));
// Evict only this player's oldest. A global cap would let one
// player's bad night erase everyone else's history.
List<Entry> mine = forPlayerLocked(playerId);
while (mine.size() > MAX_PER_PLAYER) {
Entry oldest = mine.remove(mine.size() - 1);
entries.remove(oldest);
}
}
save();
}
/** This player's deaths, newest first. */
List<Entry> forPlayer(String playerId) {
synchronized (entries) {
return forPlayerLocked(playerId);
}
}
/** Caller must hold the lock. Newest first. */
private List<Entry> forPlayerLocked(String playerId) {
List<Entry> out = new ArrayList<>();
for (Entry entry : entries) {
if (entry.playerId().equals(playerId)) {
out.add(entry);
}
}
out.sort(Comparator.comparingLong(Entry::at).reversed());
return out;
}
int size() {
synchronized (entries) {
return entries.size();
}
}
void clear(String playerId) {
synchronized (entries) {
entries.removeIf(entry -> entry.playerId().equals(playerId));
}
save();
}
private void save() {
YamlConfiguration yaml = new YamlConfiguration();
synchronized (entries) {
for (int i = 0; i < entries.size(); i++) {
Entry entry = entries.get(i);
String key = "d" + i;
yaml.set(key + ".jogador-id", entry.playerId());
yaml.set(key + ".causa", entry.flavor());
yaml.set(key + ".mundo", entry.world());
yaml.set(key + ".x", entry.x());
yaml.set(key + ".y", entry.y());
yaml.set(key + ".z", entry.z());
yaml.set(key + ".em", entry.at());
}
}
try {
yaml.save(file);
} catch (Exception e) {
throw new IllegalStateException("não consegui gravar " + file, e);
}
}
}