26148740ef
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
142 lines
4.7 KiB
Java
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);
|
|
}
|
|
}
|
|
}
|