Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d263bee90 | |||
| cee0b020a1 | |||
| 34a0b44a03 |
@@ -1,12 +1,16 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextColor;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
@@ -23,11 +27,18 @@ import java.util.logging.Logger;
|
||||
* a number, another metric, or {@code metrica/numero}. Metrics are written in
|
||||
* friendly units — distance in kilometres, time in hours — normalised from the
|
||||
* raw statistics before evaluation, so the file reads the way a person thinks.
|
||||
* A metric may also be a {@link StatRef} like {@code matou:creeper}, reaching any
|
||||
* per-mob or per-block vanilla counter with no code change.
|
||||
*
|
||||
* <p>Each title also carries a {@code tier} (comum…lendario) that colours its
|
||||
* chat tag, and an optional {@code cor} override (named or {@code #hex}), so a
|
||||
* legendary reads gold and a rare reads aqua without touching Java.
|
||||
*/
|
||||
final class Achievement {
|
||||
|
||||
/** Metrics a condition may read, in the units the config is written in.
|
||||
* mineracao/combate/mortes/pesca/pulos are raw counts; distancia is km; tempo is hours. */
|
||||
/** Friendly metrics a condition may read, in the units the config is written in.
|
||||
* mineracao/combate/mortes/pesca/pulos are raw counts; distancia is km; tempo is hours.
|
||||
* A condition may also name a {@link StatRef} (e.g. {@code matou:creeper}). */
|
||||
private static final List<String> METRICS = List.of(
|
||||
"mineracao", "combate", "mortes", "pesca", "pulos", "distancia", "tempo");
|
||||
|
||||
@@ -38,12 +49,17 @@ final class Achievement {
|
||||
private final String title;
|
||||
private final String description;
|
||||
private final Condition condition;
|
||||
private final TextColor color;
|
||||
private final Set<String> statRefs;
|
||||
|
||||
private Achievement(String key, String title, String description, Condition condition) {
|
||||
private Achievement(String key, String title, String description, Condition condition,
|
||||
TextColor color, Set<String> statRefs) {
|
||||
this.key = key;
|
||||
this.title = title;
|
||||
this.description = description;
|
||||
this.condition = condition;
|
||||
this.color = color;
|
||||
this.statRefs = Set.copyOf(statRefs);
|
||||
}
|
||||
|
||||
String key() {
|
||||
@@ -58,6 +74,11 @@ final class Achievement {
|
||||
return description;
|
||||
}
|
||||
|
||||
/** The colour this title's chat tag is drawn in, from its tier or {@code cor} override. */
|
||||
TextColor color() {
|
||||
return color;
|
||||
}
|
||||
|
||||
/** True when this player's raw statistics satisfy the condition. */
|
||||
boolean met(Map<String, Long> rawStats) {
|
||||
return rawStats != null && condition.met(normalise(rawStats));
|
||||
@@ -75,6 +96,16 @@ final class Achievement {
|
||||
return catalog.toArray(new Achievement[0]);
|
||||
}
|
||||
|
||||
/** Every vanilla {@link StatRef} the live catalogue mentions, so the stats
|
||||
* reader knows which per-mob/per-block counters to fetch. Empty until load. */
|
||||
static Set<String> referencedStats() {
|
||||
Set<String> all = new HashSet<>();
|
||||
for (Achievement achievement : catalog) {
|
||||
all.addAll(achievement.statRefs);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
static Achievement byKey(String key) {
|
||||
if (key == null) {
|
||||
return null;
|
||||
@@ -123,7 +154,8 @@ final class Achievement {
|
||||
}
|
||||
try {
|
||||
out.add(parse(key, entry.getString("titulo", ""),
|
||||
entry.getString("descricao", ""), entry.getStringList("condicoes")));
|
||||
entry.getString("descricao", ""), entry.getStringList("condicoes"),
|
||||
entry.getString("tier"), entry.getString("cor")));
|
||||
} catch (IllegalArgumentException bad) {
|
||||
log.warning("Conquista '" + key + "' ignorada: " + bad.getMessage());
|
||||
}
|
||||
@@ -131,8 +163,14 @@ final class Achievement {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Builds one achievement, parsing its condition clauses. Visible for tests. */
|
||||
/** Builds one achievement with the default (comum) colour. Visible for tests. */
|
||||
static Achievement parse(String key, String title, String description, List<String> conditions) {
|
||||
return parse(key, title, description, conditions, null, null);
|
||||
}
|
||||
|
||||
/** Builds one achievement, parsing its condition clauses and resolving its colour. */
|
||||
static Achievement parse(String key, String title, String description, List<String> conditions,
|
||||
String tier, String cor) {
|
||||
String normalizedKey = key == null ? "" : key.trim().toLowerCase(Locale.ROOT);
|
||||
if (!normalizedKey.matches("[a-z-]+")) {
|
||||
throw new IllegalArgumentException("chave inválida (use apenas a-z e '-'): " + key);
|
||||
@@ -144,8 +182,16 @@ final class Achievement {
|
||||
throw new IllegalArgumentException("sem condicoes");
|
||||
}
|
||||
List<Clause> clauses = new ArrayList<>();
|
||||
Set<String> refs = new HashSet<>();
|
||||
for (String raw : conditions) {
|
||||
clauses.add(Clause.parse(raw));
|
||||
Clause clause = Clause.parse(raw);
|
||||
clauses.add(clause);
|
||||
if (StatRef.isRef(clause.metric())) {
|
||||
refs.add(clause.metric());
|
||||
}
|
||||
if (clause.rhsMetric() != null && StatRef.isRef(clause.rhsMetric())) {
|
||||
refs.add(clause.rhsMetric());
|
||||
}
|
||||
}
|
||||
Condition condition = stats -> {
|
||||
for (Clause clause : clauses) {
|
||||
@@ -155,12 +201,41 @@ final class Achievement {
|
||||
}
|
||||
return true;
|
||||
};
|
||||
return new Achievement(normalizedKey, title, description, condition);
|
||||
return new Achievement(normalizedKey, title, description, condition,
|
||||
resolveColor(tier, cor), refs);
|
||||
}
|
||||
|
||||
/** Raw statistics → the friendly units the conditions are written in. */
|
||||
/** Tier or explicit {@code cor} → the colour of the chat tag. Bad input falls
|
||||
* back to the tier colour, and an unknown tier to a readable white. */
|
||||
private static TextColor resolveColor(String tier, String cor) {
|
||||
if (cor != null && !cor.isBlank()) {
|
||||
String value = cor.trim();
|
||||
TextColor explicit = value.startsWith("#")
|
||||
? TextColor.fromHexString(value)
|
||||
: NamedTextColor.NAMES.value(value.toLowerCase(Locale.ROOT));
|
||||
if (explicit != null) {
|
||||
return explicit;
|
||||
}
|
||||
}
|
||||
return tierColor(tier);
|
||||
}
|
||||
|
||||
/** Default colour for each difficulty tier. Higher tiers read cooler/brighter. */
|
||||
private static TextColor tierColor(String tier) {
|
||||
String name = tier == null ? "" : tier.trim().toLowerCase(Locale.ROOT);
|
||||
return switch (name) {
|
||||
case "incomum" -> NamedTextColor.GREEN;
|
||||
case "raro" -> NamedTextColor.AQUA;
|
||||
case "epico", "épico" -> NamedTextColor.LIGHT_PURPLE;
|
||||
case "lendario", "lendário" -> NamedTextColor.GOLD;
|
||||
default -> NamedTextColor.WHITE; // comum / unset — always legible
|
||||
};
|
||||
}
|
||||
|
||||
/** Raw statistics → the friendly units the conditions are written in. Any
|
||||
* {@link StatRef} counts (matou:*, minerou:*) pass through untouched. */
|
||||
private static Map<String, Long> normalise(Map<String, Long> raw) {
|
||||
Map<String, Long> out = new HashMap<>();
|
||||
Map<String, Long> out = new HashMap<>(raw);
|
||||
out.put("mineracao", raw.getOrDefault("mineracao", 0L));
|
||||
out.put("combate", raw.getOrDefault("combate", 0L));
|
||||
out.put("mortes", raw.getOrDefault("mortes", 0L));
|
||||
@@ -215,7 +290,7 @@ final class Achievement {
|
||||
throw new IllegalArgumentException("condicao mal formada: '" + raw + "'");
|
||||
}
|
||||
String metric = parts[0].toLowerCase(Locale.ROOT);
|
||||
if (!METRICS.contains(metric)) {
|
||||
if (!METRICS.contains(metric) && !StatRef.isValid(metric)) {
|
||||
throw new IllegalArgumentException("metrica desconhecida: " + metric);
|
||||
}
|
||||
Op op = Op.of(parts[1]);
|
||||
@@ -237,7 +312,7 @@ final class Achievement {
|
||||
throw new IllegalArgumentException("divisão por zero: " + target);
|
||||
}
|
||||
}
|
||||
if (!METRICS.contains(rhsMetric)) {
|
||||
if (!METRICS.contains(rhsMetric) && !StatRef.isValid(rhsMetric)) {
|
||||
throw new IllegalArgumentException("metrica desconhecida: " + rhsMetric);
|
||||
}
|
||||
return new Clause(metric, op, rhsMetric, 0, divisor);
|
||||
|
||||
@@ -4,14 +4,12 @@ import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Statistic;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -113,7 +111,14 @@ final class Achievements {
|
||||
|
||||
/** @return true if anything was recorded, so the caller can save once */
|
||||
private boolean check(Player player) {
|
||||
Map<String, Long> stats = snapshot(player);
|
||||
// Read straight off the stats file, the same source /perfil and /conquistas
|
||||
// use, so a title can hinge on any per-mob or per-block counter (matou:creeper)
|
||||
// that Bukkit's typed API would make us enumerate by hand. The file lags a
|
||||
// live session by seconds — invisible for cumulative threshold titles.
|
||||
Map<String, Long> stats = plugin.offlineStats().achievementStats(player.getUniqueId());
|
||||
if (stats == null) {
|
||||
return false; // no stats file written yet — nothing to bank, retry next tick
|
||||
}
|
||||
String base = player.getUniqueId().toString();
|
||||
// A player with no record yet is being seen for the first time: bank
|
||||
// what they have without announcing it.
|
||||
@@ -149,7 +154,7 @@ final class Achievements {
|
||||
.decoration(TextDecoration.BOLD, false))
|
||||
.append(Component.text(" desbloqueou ", NamedTextColor.WHITE)
|
||||
.decoration(TextDecoration.BOLD, false))
|
||||
.append(Component.text(achievement.title(), NamedTextColor.AQUA)
|
||||
.append(Component.text(achievement.title(), achievement.color())
|
||||
.decoration(TextDecoration.BOLD, false))
|
||||
.append(Component.text(" — " + achievement.description(), NamedTextColor.GRAY)
|
||||
.decoration(TextDecoration.BOLD, false)));
|
||||
@@ -168,36 +173,6 @@ final class Achievements {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stat map an {@link Achievement} condition reads, keyed the same way
|
||||
* as the config's category names.
|
||||
*
|
||||
* <p>Statistic constants get renamed between Minecraft releases, so each is
|
||||
* resolved by name through {@link Stats#resolve} rather than referenced
|
||||
* directly — a missing one yields zero instead of failing to load the class.
|
||||
*/
|
||||
static Map<String, Long> snapshot(Player player) {
|
||||
Map<String, Long> stats = new HashMap<>();
|
||||
stats.put("mineracao", total(player, "MINE_BLOCK"));
|
||||
stats.put("tempo", untyped(player, "PLAY_TIME"));
|
||||
stats.put("distancia", untyped(player, "WALK_ONE_CM"));
|
||||
stats.put("mortes", untyped(player, "DEATHS"));
|
||||
stats.put("combate", untyped(player, "MOB_KILLS"));
|
||||
stats.put("pesca", untyped(player, "FISH_CAUGHT"));
|
||||
stats.put("pulos", untyped(player, "JUMP"));
|
||||
return stats;
|
||||
}
|
||||
|
||||
private static long untyped(Player player, String name) {
|
||||
Statistic statistic = Stats.resolve(name);
|
||||
return statistic == null ? 0L : Stats.untyped(player, statistic);
|
||||
}
|
||||
|
||||
private static long total(Player player, String name) {
|
||||
Statistic statistic = Stats.resolve(name);
|
||||
return statistic == null ? 0L : Stats.totalOf(player, statistic);
|
||||
}
|
||||
|
||||
private void save() {
|
||||
try {
|
||||
data.save(file);
|
||||
|
||||
@@ -5,6 +5,7 @@ import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||
import net.kyori.adventure.translation.TranslationStore;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.GameRule;
|
||||
import org.bukkit.Location;
|
||||
@@ -98,6 +99,8 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
private GuessRound guessRound;
|
||||
private Poll poll;
|
||||
private Titles titles;
|
||||
private DeathGift deathGift;
|
||||
private TranslationStore<?> i18n;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -105,6 +108,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
// Ship the editable catalogues; false = never overwrite the operator's copy.
|
||||
saveResource("conquistas-catalogo.yml", false);
|
||||
saveResource("marcos-catalogo.yml", false);
|
||||
i18n = I18n.install(i18n, getLogger());
|
||||
settings = new Settings(this);
|
||||
notes = new Notes(new java.io.File(getDataFolder(), "notas.yml"));
|
||||
mail = new Mail(new java.io.File(getDataFolder(), "recados.yml"));
|
||||
@@ -113,6 +117,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
milestones = new Milestones(this);
|
||||
achievements = new Achievements(this);
|
||||
titles = new Titles(this);
|
||||
deathGift = new DeathGift(this);
|
||||
// Load the achievement catalogue from config, then silently bank any
|
||||
// history the current definitions already imply (both here and for
|
||||
// milestones), so an expanded catalogue never spams returning players.
|
||||
@@ -247,6 +252,12 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
Achievement.load(Achievement.loadFrom(conquistasCatalogo(), getLogger()));
|
||||
milestones.reload();
|
||||
achievements.syncCatalogue();
|
||||
deathGift.reload();
|
||||
}
|
||||
|
||||
/** Reloads the i18n bundles from the jar and re-registers the translator. */
|
||||
void reloadI18n() {
|
||||
i18n = I18n.install(i18n, getLogger());
|
||||
}
|
||||
|
||||
/** The weekly ranking baseline. Never null. */
|
||||
@@ -772,11 +783,12 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
button = button.clickEvent(ClickEvent.runCommand(
|
||||
"/canalhandia reagir " + mourning.id() + " f"));
|
||||
}
|
||||
Component prompt = Lang.tr(bedrock
|
||||
? "canalhandia.morte.luto.digitar"
|
||||
: "canalhandia.morte.luto.prestar",
|
||||
Component.text(name));
|
||||
return Component.text(" ").append(button)
|
||||
.append(Component.text(bedrock
|
||||
? "digite /f para prestar luto por " + name
|
||||
: "prestar luto por " + name,
|
||||
NamedTextColor.GRAY));
|
||||
.append(prompt.color(NamedTextColor.GRAY));
|
||||
}), 2L);
|
||||
|
||||
getServer().getScheduler().runTaskLater(this, () -> {
|
||||
@@ -786,9 +798,10 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
int shown = Math.min(who.size(), settings.summaryNames());
|
||||
String text = String.join(", ", who.subList(0, shown))
|
||||
+ (who.size() > shown ? " +" + (who.size() - shown) : "");
|
||||
Component summary = Lang.tr("canalhandia.morte.luto.resumo",
|
||||
Component.text(text), Component.text(name));
|
||||
Bukkit.broadcast(Component.text(" ")
|
||||
.append(Component.text(text + " prestaram luto por " + name + ".",
|
||||
NamedTextColor.GRAY)));
|
||||
.append(summary.color(NamedTextColor.GRAY)));
|
||||
}
|
||||
if (liveReactions == mourning) {
|
||||
liveReactions = null;
|
||||
@@ -895,6 +908,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
.append(Component.text(tail, NamedTextColor.AQUA));
|
||||
}
|
||||
player.sendMessage(msg);
|
||||
// A comic consolation item, given once they can actually hold it.
|
||||
// Gameplay-neutral by design (a poppy, a wilted bush) — just a laugh.
|
||||
if (deathGift.active()) {
|
||||
deathGift.give(player);
|
||||
}
|
||||
}, 1L);
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
||||
if (admin(sender)) {
|
||||
plugin.reloadConfig();
|
||||
plugin.reloadCatalogo();
|
||||
plugin.reloadI18n();
|
||||
plugin.rescheduleTimer();
|
||||
plugin.rescheduleMilestones();
|
||||
Msg.ok(sender, "Recarregado: " + Achievement.values().length
|
||||
@@ -1298,7 +1299,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
||||
sender.sendMessage(Component.text(has ? tick : blank,
|
||||
has ? NamedTextColor.GREEN : NamedTextColor.DARK_GRAY)
|
||||
.append(Component.text(achievement.title(),
|
||||
has ? NamedTextColor.AQUA : NamedTextColor.GRAY))
|
||||
has ? achievement.color() : NamedTextColor.GRAY))
|
||||
.append(Component.text(" — " + achievement.description(),
|
||||
NamedTextColor.DARK_GRAY)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* A small consolation prize handed to a player when they respawn — a funeral
|
||||
* poppy, a wilted bush, whatever. Comic, never useful: the point is a laugh at
|
||||
* the death, not a leg up, so gifts are cosmetic-tier items given one at a time.
|
||||
*
|
||||
* <p>Config-driven like the achievement catalogue. If {@code mortes.presente} is
|
||||
* absent the built-in list is used, so it works the moment the plugin loads;
|
||||
* operators expand or mute it under that key and {@code /canalhandia reload}
|
||||
* picks it up. Each line is {@code "MATERIAL | Nome | mensagem"}.
|
||||
*/
|
||||
final class DeathGift {
|
||||
|
||||
/** Baked-in default so a fresh server has something without editing config. */
|
||||
private static final List<String> DEFAULTS = List.of(
|
||||
"POPPY | Flor do Velório | Uma florzinha pro seu velório. Sentimos muito.",
|
||||
"DEAD_BUSH | Buquê Murcho | Um buquê à altura do seu último desempenho.",
|
||||
"WET_SPONGE | Esponja das Lágrimas | Toma, pra enxugar as lágrimas.",
|
||||
"BONE | Osso da Sorte | Um ossinho pra você, campeão.",
|
||||
"COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora.",
|
||||
"ROTTEN_FLESH | Carne Podre | É o que tinha sobrado na despensa.");
|
||||
|
||||
/** One gift: an item, the name it wears, and the line shown when it is given. */
|
||||
record Gift(Material material, String name, String message) {
|
||||
}
|
||||
|
||||
private final Canalhandia plugin;
|
||||
private final Random random = new Random();
|
||||
private volatile boolean active;
|
||||
private volatile List<Gift> gifts = List.of();
|
||||
|
||||
DeathGift(Canalhandia plugin) {
|
||||
this.plugin = plugin;
|
||||
reload();
|
||||
}
|
||||
|
||||
/** Re-reads the gift list from config (or the defaults). Driven by reload. */
|
||||
void reload() {
|
||||
ConfigurationSection section = plugin.getConfig().getConfigurationSection("mortes.presente");
|
||||
boolean on = section == null || section.getBoolean("ativo", true);
|
||||
List<String> raw = section == null ? DEFAULTS : section.getStringList("itens");
|
||||
if (raw.isEmpty()) {
|
||||
raw = DEFAULTS;
|
||||
}
|
||||
gifts = parse(raw, plugin.getLogger());
|
||||
active = on && !gifts.isEmpty();
|
||||
}
|
||||
|
||||
/** True when a gift should be handed out on respawn. */
|
||||
boolean active() {
|
||||
return active;
|
||||
}
|
||||
|
||||
/** Hands the player a random gift and a private comic line. Overflow is dropped
|
||||
* at their feet rather than lost, so a full inventory never eats the joke. */
|
||||
void give(Player player) {
|
||||
Gift gift = pick(gifts, random);
|
||||
if (gift == null) {
|
||||
return;
|
||||
}
|
||||
ItemStack item = new ItemStack(gift.material());
|
||||
item.editMeta(meta -> meta.displayName(Component.text(gift.name(), NamedTextColor.LIGHT_PURPLE)
|
||||
.decoration(TextDecoration.ITALIC, false)));
|
||||
Map<Integer, ItemStack> overflow = player.getInventory().addItem(item);
|
||||
for (ItemStack leftover : overflow.values()) {
|
||||
player.getWorld().dropItemNaturally(player.getLocation(), leftover);
|
||||
}
|
||||
player.sendMessage(Msg.tag("Consolação", NamedTextColor.LIGHT_PURPLE)
|
||||
.append(Component.text(gift.message(), NamedTextColor.GRAY)));
|
||||
}
|
||||
|
||||
/** Picks one gift at random, or null if the list is empty. Pure, for tests. */
|
||||
static Gift pick(List<Gift> gifts, Random random) {
|
||||
return gifts.isEmpty() ? null : gifts.get(random.nextInt(gifts.size()));
|
||||
}
|
||||
|
||||
/** Parses {@code "MATERIAL | Nome | mensagem"} lines, skipping bad ones. */
|
||||
static List<Gift> parse(List<String> raw, Logger log) {
|
||||
List<Gift> out = new ArrayList<>();
|
||||
for (String line : raw) {
|
||||
String[] parts = line.split("\\|", 3);
|
||||
if (parts.length != 3) {
|
||||
log.warning("Presente de morte ignorado (formato 'ITEM | Nome | mensagem'): " + line);
|
||||
continue;
|
||||
}
|
||||
Material material = Material.matchMaterial(parts[0].trim().toUpperCase(Locale.ROOT));
|
||||
if (material == null || !material.isItem()) {
|
||||
log.warning("Presente de morte ignorado (item inválido): " + parts[0].trim());
|
||||
continue;
|
||||
}
|
||||
out.add(new Gift(material, parts[1].trim(), parts[2].trim()));
|
||||
}
|
||||
return List.copyOf(out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import net.kyori.adventure.key.Key;
|
||||
import net.kyori.adventure.translation.GlobalTranslator;
|
||||
import net.kyori.adventure.translation.TranslationStore;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
import java.util.PropertyResourceBundle;
|
||||
import java.util.ResourceBundle;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* i18n: registers an Adventure {@link TranslationStore} (key
|
||||
* {@code canalhandia}) into the {@link GlobalTranslator}, populated from the
|
||||
* bundled {@code lang/messages_pt.properties} (source of truth) and
|
||||
* {@code lang/messages_en.properties}.
|
||||
*
|
||||
* <p>Per-viewer rendering is automatic: Paper runs every component sent to an
|
||||
* audience through the {@code GlobalTranslator} in the viewer's own locale, so
|
||||
* one broadcast shows each player their own language. No per-player lookup.
|
||||
*
|
||||
* <p>Language resolution: registry default is {@code en} (the base bundle).
|
||||
* {@code pt} and {@code pt_BR} fall back to the PT bundle; the store does the
|
||||
* locale fallback, unknown locales hit the default. That's the whole rule.
|
||||
*/
|
||||
final class I18n {
|
||||
|
||||
static final Key SOURCE = Key.key("canalhandia");
|
||||
static final Locale DEFAULT = Locale.ENGLISH;
|
||||
/** Escape single quotes so MessageFormat does not swallow apostrophes. */
|
||||
private static final boolean ESCAPE_QUOTES = true;
|
||||
private static final String PT = "lang/messages_pt.properties";
|
||||
private static final String EN = "lang/messages_en.properties";
|
||||
|
||||
private I18n() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads both bundles and registers the store with the global translator.
|
||||
* Removes any previously registered store first, so {@code /canalhandia
|
||||
* reload} does not stack sources.
|
||||
*/
|
||||
static TranslationStore<?> install(TranslationStore<?> previous, Logger logger) {
|
||||
if (previous != null) {
|
||||
GlobalTranslator.translator().removeSource(previous);
|
||||
}
|
||||
TranslationStore.StringBased<java.text.MessageFormat> store = TranslationStore.messageFormat(SOURCE);
|
||||
store.defaultLocale(DEFAULT);
|
||||
load(EN, store, Locale.ENGLISH, logger);
|
||||
load(PT, store, Locale.of("pt"), logger);
|
||||
GlobalTranslator.translator().addSource(store);
|
||||
return store;
|
||||
}
|
||||
|
||||
private static void load(String resource,
|
||||
TranslationStore.StringBased<java.text.MessageFormat> store,
|
||||
Locale locale, Logger logger) {
|
||||
try (InputStream in = I18n.class.getClassLoader().getResourceAsStream(resource)) {
|
||||
if (in == null) {
|
||||
logger.warning("i18n: recurso ausente: " + resource);
|
||||
return;
|
||||
}
|
||||
// PropertyResourceBundle(Reader) honours the reader's encoding; the
|
||||
// InputStream constructor is fixed to ISO-8859-1 and would mojibake
|
||||
// the PT accents.
|
||||
ResourceBundle bundle = new PropertyResourceBundle(
|
||||
new InputStreamReader(in, StandardCharsets.UTF_8));
|
||||
store.registerAll(locale, bundle, ESCAPE_QUOTES);
|
||||
} catch (IOException e) {
|
||||
logger.warning("i18n: falha ao carregar " + resource + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
|
||||
/**
|
||||
* Terse facade over {@link Component#translatable} so call sites read as i18n
|
||||
* rather than as an Adventure call: {@code Lang.tr("canalhandia.morte.luto.prestar", name)}.
|
||||
*
|
||||
* <p>The key is rendered per-viewer by the {@link GlobalTranslator} source that
|
||||
* {@link I18n} registers; args are inserted by {@code MessageFormat} ({@code {0}},
|
||||
* {@code {1}}, …).
|
||||
*/
|
||||
final class Lang {
|
||||
|
||||
private Lang() {
|
||||
}
|
||||
|
||||
static Component tr(String key, Component... args) {
|
||||
return Component.translatable(key, args);
|
||||
}
|
||||
}
|
||||
@@ -125,10 +125,10 @@ final class OfflineStats {
|
||||
|
||||
/**
|
||||
* The full stat map an {@link Achievement} reads, for a player who may be
|
||||
* offline. Keyed by {@link RankingMetric#commandKey()} — the same names the
|
||||
* online {@link Achievements#snapshot} produces — so the identical pure
|
||||
* conditions in {@link Achievement} evaluate the same whether the player is
|
||||
* on- or offline.
|
||||
* offline. Keyed by {@link RankingMetric#commandKey()} plus any {@link StatRef}
|
||||
* the catalogue references (matou:creeper, minerou:obsidian). This is the one
|
||||
* source {@link Achievements} reads for on- and offline players alike, so the
|
||||
* pure conditions in {@link Achievement} evaluate identically either way.
|
||||
*
|
||||
* @return null when there is no stats file for this player (never played, or
|
||||
* the directory is missing), which the caller shows as "sem dados".
|
||||
@@ -142,42 +142,59 @@ final class OfflineStats {
|
||||
if (!file.isFile()) {
|
||||
return null;
|
||||
}
|
||||
JsonObject statsObject = statsObject(file);
|
||||
Map<String, Long> stats = new HashMap<>();
|
||||
for (RankingMetric metric : RankingMetric.values()) {
|
||||
stats.put(metric.commandKey(), read(file, metric));
|
||||
stats.put(metric.commandKey(), valueIn(statsObject, metric.section(), metric.statKey()));
|
||||
}
|
||||
// The catalogue may reach into per-mob/per-block counters (matou:creeper,
|
||||
// minerou:obsidian); fetch exactly the ones some title references.
|
||||
for (String ref : Achievement.referencedStats()) {
|
||||
StatRef resolved = StatRef.of(ref);
|
||||
stats.put(ref, valueIn(statsObject, resolved.section(), resolved.statKey()));
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
private long read(File file, RankingMetric metric) {
|
||||
return valueIn(statsObject(file), metric.section(), metric.statKey());
|
||||
}
|
||||
|
||||
/** The {@code stats} object of a player file, parsed once, or null on any problem. */
|
||||
private JsonObject statsObject(File file) {
|
||||
try (Reader reader = new FileReader(file)) {
|
||||
JsonElement root = JsonParser.parseReader(reader);
|
||||
if (!root.isJsonObject()) {
|
||||
return 0;
|
||||
return null;
|
||||
}
|
||||
JsonElement stats = root.getAsJsonObject().get("stats");
|
||||
if (stats == null || !stats.isJsonObject()) {
|
||||
return stats != null && stats.isJsonObject() ? stats.getAsJsonObject() : null;
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("Não consegui ler " + file.getName() + ": " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** One value out of a parsed stats object. A null {@code statKey} sums the
|
||||
* whole section, e.g. every block ever mined. */
|
||||
private static long valueIn(JsonObject statsObject, String section, String statKey) {
|
||||
if (statsObject == null) {
|
||||
return 0;
|
||||
}
|
||||
JsonElement section = stats.getAsJsonObject().get(metric.section());
|
||||
if (section == null || !section.isJsonObject()) {
|
||||
JsonElement sectionElement = statsObject.get(section);
|
||||
if (sectionElement == null || !sectionElement.isJsonObject()) {
|
||||
return 0;
|
||||
}
|
||||
JsonObject object = section.getAsJsonObject();
|
||||
if (metric.statKey() == null) {
|
||||
// Sum the whole section, e.g. every block ever mined.
|
||||
JsonObject object = sectionElement.getAsJsonObject();
|
||||
if (statKey == null) {
|
||||
long total = 0;
|
||||
for (String key : object.keySet()) {
|
||||
total += object.get(key).getAsLong();
|
||||
}
|
||||
return total;
|
||||
}
|
||||
JsonElement value = object.get(metric.statKey());
|
||||
JsonElement value = object.get(statKey);
|
||||
return value == null ? 0 : value.getAsLong();
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("Não consegui ler " + file.getName() + ": " + e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** UUID to last known name, from usercache.json. */
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A reference to one raw vanilla statistic, written in config as
|
||||
* {@code prefixo:chave} — e.g. {@code matou:creeper} or {@code minerou:obsidian}.
|
||||
*
|
||||
* <p>This is what lets the achievement catalogue reach past the seven headline
|
||||
* metrics into any per-mob or per-block counter Minecraft keeps, without a code
|
||||
* change: a title for "matou 100 creepers" is one line of YAML. The prefix picks
|
||||
* the stats-JSON section; the key becomes {@code minecraft:<key>} inside it.
|
||||
*
|
||||
* <p>Counts are per exact block/entity id — vanilla splits e.g. deepslate ores
|
||||
* from their stone form — so a ref reads one id, not a family. Pure strings, no
|
||||
* Bukkit: {@link Achievement} validates the shape, {@link OfflineStats} reads it.
|
||||
*/
|
||||
final class StatRef {
|
||||
|
||||
/** Friendly prefix → stats-JSON section. */
|
||||
private static final Map<String, String> SECTIONS = Map.of(
|
||||
"matou", "minecraft:killed",
|
||||
"morto-por", "minecraft:killed_by",
|
||||
"minerou", "minecraft:mined",
|
||||
"usou", "minecraft:used",
|
||||
"craftou", "minecraft:crafted",
|
||||
"pegou", "minecraft:picked_up",
|
||||
"largou", "minecraft:dropped",
|
||||
"custom", "minecraft:custom");
|
||||
|
||||
private final String section;
|
||||
private final String statKey;
|
||||
|
||||
private StatRef(String section, String statKey) {
|
||||
this.section = section;
|
||||
this.statKey = statKey;
|
||||
}
|
||||
|
||||
String section() {
|
||||
return section;
|
||||
}
|
||||
|
||||
String statKey() {
|
||||
return statKey;
|
||||
}
|
||||
|
||||
/** True when a metric token is a vanilla reference (has a {@code prefix:key} shape). */
|
||||
static boolean isRef(String token) {
|
||||
return token != null && token.indexOf(':') > 0;
|
||||
}
|
||||
|
||||
/** True when the token is a reference with a known prefix and a clean key. */
|
||||
static boolean isValid(String token) {
|
||||
if (!isRef(token)) {
|
||||
return false;
|
||||
}
|
||||
int colon = token.indexOf(':');
|
||||
String prefix = token.substring(0, colon).toLowerCase(Locale.ROOT);
|
||||
String key = token.substring(colon + 1).toLowerCase(Locale.ROOT);
|
||||
return SECTIONS.containsKey(prefix) && key.matches("[a-z0-9_]+");
|
||||
}
|
||||
|
||||
/** Resolves a validated token to its JSON section and key. */
|
||||
static StatRef of(String token) {
|
||||
int colon = token.indexOf(':');
|
||||
String prefix = token.substring(0, colon).toLowerCase(Locale.ROOT);
|
||||
String key = token.substring(colon + 1).toLowerCase(Locale.ROOT);
|
||||
String section = SECTIONS.get(prefix);
|
||||
if (section == null) {
|
||||
throw new IllegalArgumentException("prefixo de estatística desconhecido: " + prefix);
|
||||
}
|
||||
return new StatRef(section, "minecraft:" + key);
|
||||
}
|
||||
}
|
||||
@@ -44,10 +44,17 @@ final class TitleChatListener implements Listener {
|
||||
tag.append(previous.render(source, sourceDisplayName, message, viewer)));
|
||||
}
|
||||
|
||||
/** The bracketed title chip that sits before the name. Pure, so it is testable. */
|
||||
/** The bracketed title chip that sits before the name, drawn in the title's
|
||||
* own tier colour so a legendary reads gold and a rare aqua. Pure, so testable.
|
||||
*
|
||||
* <p>Rooted on an empty, colourless component on purpose: the chat message is
|
||||
* appended to this in {@link #onChat}, and a coloured root would bleed its
|
||||
* colour into any unstyled message text — which turned title-holders' chat
|
||||
* grey. Empty root → the message falls back to the client default (white). */
|
||||
static Component tag(Achievement achievement) {
|
||||
return Component.text("[", NamedTextColor.DARK_GRAY)
|
||||
.append(Component.text(achievement.title(), NamedTextColor.AQUA))
|
||||
return Component.empty()
|
||||
.append(Component.text("[", NamedTextColor.DARK_GRAY))
|
||||
.append(Component.text(achievement.title(), achievement.color()))
|
||||
.append(Component.text("] ", NamedTextColor.DARK_GRAY))
|
||||
.decoration(TextDecoration.BOLD, false);
|
||||
}
|
||||
|
||||
@@ -329,3 +329,18 @@ ia:
|
||||
- "O servidor se chama Canalhandia e roda Minecraft 26.2 (Paper)."
|
||||
- "Jogadores de Bedrock entram pelo Geyser e o nome deles começa com ponto."
|
||||
- "O servidor tem BlueMap, voice chat e Distant Horizons."
|
||||
|
||||
# Presente de consolação: quando um jogador renasce, ganha um item cômico e
|
||||
# inofensivo (uma flor de velório, um arbusto murcho). Parte do módulo "mortes".
|
||||
# Sem esta seção, uma lista padrão embutida é usada. Rode /canalhandia reload
|
||||
# depois de editar. Cada item é "MATERIAL | Nome | mensagem".
|
||||
mortes:
|
||||
presente:
|
||||
ativo: true
|
||||
itens:
|
||||
- "POPPY | Flor do Velório | Uma florzinha pro seu velório. Sentimos muito."
|
||||
- "DEAD_BUSH | Buquê Murcho | Um buquê à altura do seu último desempenho."
|
||||
- "WET_SPONGE | Esponja das Lágrimas | Toma, pra enxugar as lágrimas."
|
||||
- "BONE | Osso da Sorte | Um ossinho pra você, campeão."
|
||||
- "COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora."
|
||||
- "ROTTEN_FLESH | Carne Podre | É o que tinha sobrado na despensa."
|
||||
|
||||
@@ -4,25 +4,36 @@
|
||||
# /canalhandia reload
|
||||
# e o servidor recarrega tudo sem reiniciar (igual à whitelist).
|
||||
#
|
||||
# Cada conquista tem: titulo, descricao e uma lista de condicoes. TODAS as
|
||||
# condicoes precisam valer para o jogador desbloquear o título.
|
||||
# Cada conquista tem: titulo, descricao, uma lista de condicoes e um tier.
|
||||
# TODAS as condicoes precisam valer para o jogador desbloquear o título.
|
||||
#
|
||||
# Métricas disponíveis (nas unidades abaixo):
|
||||
# mineracao blocos minerados
|
||||
# combate monstros derrotados
|
||||
# mortes mortes
|
||||
# pesca peixes pescados
|
||||
# pulos pulos
|
||||
# distancia quilômetros caminhados
|
||||
# Métricas simples (nas unidades abaixo):
|
||||
# mineracao blocos minerados combate monstros derrotados
|
||||
# mortes mortes pesca peixes pescados
|
||||
# pulos pulos distancia quilômetros caminhados
|
||||
# tempo horas jogadas
|
||||
#
|
||||
# Métricas detalhadas: "prefixo:coisa" alcança qualquer contador do Minecraft,
|
||||
# por mob ou por bloco, sem mexer no código. Ex.: matou:creeper, minerou:obsidian.
|
||||
# prefixos: matou (mobs mortos), morto-por, minerou (blocos), usou, craftou,
|
||||
# pegou, largou, custom
|
||||
# a "coisa" é o id do mob/bloco em minúsculas: creeper, spider, ancient_debris…
|
||||
# OBS: o contador é por id exato — matou:spider não inclui cave_spider, e
|
||||
# minerou:diamond_ore não inclui deepslate_diamond_ore.
|
||||
#
|
||||
# Cada condicao é "metrica operador alvo".
|
||||
# operadores: >= > <= < == !=
|
||||
# alvo pode ser: um número, outra métrica, ou metrica/numero (divisão).
|
||||
# Exemplos:
|
||||
# "mineracao >= 10000" minerou pelo menos 10 mil blocos
|
||||
# "matou:creeper >= 100" derrotou 100 creepers
|
||||
# "mortes > combate" morreu mais do que matou
|
||||
# "mortes > mineracao/100" morreu mais de uma vez a cada 100 blocos
|
||||
#
|
||||
# tier define a cor do título no chat (do mais comum ao mais raro):
|
||||
# comum → branco incomum → verde raro → azul-claro
|
||||
# epico → roxo lendario → dourado
|
||||
# cor (opcional) força uma cor específica, sobrepondo o tier:
|
||||
# um nome do Minecraft (gold, red, aqua…) ou hex "#RRGGBB".
|
||||
|
||||
conquistas:
|
||||
|
||||
@@ -31,127 +42,206 @@ conquistas:
|
||||
titulo: "Pedreiro"
|
||||
descricao: "minerou 10.000 blocos"
|
||||
condicoes: ["mineracao >= 10000"]
|
||||
tier: comum
|
||||
escavadeira:
|
||||
titulo: "Escavadeira Humana"
|
||||
descricao: "minerou 100.000 blocos"
|
||||
condicoes: ["mineracao >= 100000"]
|
||||
tier: raro
|
||||
terraplanagem:
|
||||
titulo: "Terraplanagem"
|
||||
descricao: "minerou 500.000 blocos"
|
||||
condicoes: ["mineracao >= 500000"]
|
||||
tier: epico
|
||||
|
||||
# --- combate ---
|
||||
cacador:
|
||||
titulo: "Caçador"
|
||||
descricao: "derrotou 100 monstros"
|
||||
condicoes: ["combate >= 100"]
|
||||
tier: comum
|
||||
exterminador:
|
||||
titulo: "Exterminador"
|
||||
descricao: "derrotou 1.000 monstros"
|
||||
condicoes: ["combate >= 1000"]
|
||||
tier: raro
|
||||
ceifador:
|
||||
titulo: "Ceifador"
|
||||
descricao: "derrotou 10.000 monstros"
|
||||
condicoes: ["combate >= 10000"]
|
||||
tier: epico
|
||||
|
||||
# --- combate por mob (métricas detalhadas) ---
|
||||
aracnofobia:
|
||||
titulo: "Aracnofobia"
|
||||
descricao: "derrotou 100 aranhas"
|
||||
condicoes: ["matou:spider >= 100"]
|
||||
tier: raro
|
||||
desarmador:
|
||||
titulo: "Desarmador"
|
||||
descricao: "derrotou 100 creepers e viveu para contar"
|
||||
condicoes: ["matou:creeper >= 100"]
|
||||
tier: raro
|
||||
necromante:
|
||||
titulo: "Necromante"
|
||||
descricao: "derrotou 300 zumbis"
|
||||
condicoes: ["matou:zombie >= 300"]
|
||||
tier: incomum
|
||||
pontaria:
|
||||
titulo: "Pontaria de Ferro"
|
||||
descricao: "derrotou 200 esqueletos"
|
||||
condicoes: ["matou:skeleton >= 200"]
|
||||
tier: raro
|
||||
encara-o-vazio:
|
||||
titulo: "Encara o Vazio"
|
||||
descricao: "derrotou 60 endermen"
|
||||
condicoes: ["matou:enderman >= 60"]
|
||||
tier: raro
|
||||
apaga-fogo:
|
||||
titulo: "Apaga-Fogo"
|
||||
descricao: "derrotou 50 blazes"
|
||||
condicoes: ["matou:blaze >= 50"]
|
||||
tier: raro
|
||||
insone:
|
||||
titulo: "Insone"
|
||||
descricao: "derrotou 50 phantoms"
|
||||
condicoes: ["matou:phantom >= 50"]
|
||||
tier: incomum
|
||||
|
||||
# --- viagem ---
|
||||
maratonista:
|
||||
titulo: "Maratonista"
|
||||
descricao: "caminhou 42 km (uma maratona)"
|
||||
condicoes: ["distancia >= 42"]
|
||||
tier: comum
|
||||
andarilho:
|
||||
titulo: "Andarilho"
|
||||
descricao: "caminhou 100 km"
|
||||
condicoes: ["distancia >= 100"]
|
||||
tier: incomum
|
||||
explorador:
|
||||
titulo: "Explorador"
|
||||
descricao: "caminhou 500 km"
|
||||
condicoes: ["distancia >= 500"]
|
||||
tier: raro
|
||||
volta-ao-mundo:
|
||||
titulo: "Volta ao Mundo"
|
||||
descricao: "caminhou 1.000 km"
|
||||
condicoes: ["distancia >= 1000"]
|
||||
tier: epico
|
||||
|
||||
# --- tempo ---
|
||||
residente:
|
||||
titulo: "Residente"
|
||||
descricao: "passou de 50 horas jogadas"
|
||||
condicoes: ["tempo >= 50"]
|
||||
tier: comum
|
||||
veterano:
|
||||
titulo: "Veterano"
|
||||
descricao: "passou de 200 horas jogadas"
|
||||
condicoes: ["tempo >= 200"]
|
||||
tier: raro
|
||||
morador-fixo:
|
||||
titulo: "Morador Fixo"
|
||||
descricao: "passou de 500 horas jogadas"
|
||||
condicoes: ["tempo >= 500"]
|
||||
tier: epico
|
||||
lenda-viva:
|
||||
titulo: "Lenda Viva"
|
||||
descricao: "passou de 1.000 horas jogadas"
|
||||
condicoes: ["tempo >= 1000"]
|
||||
tier: lendario
|
||||
|
||||
# --- pesca ---
|
||||
pescador-amador:
|
||||
titulo: "Pescador Amador"
|
||||
descricao: "pescou 100 peixes"
|
||||
condicoes: ["pesca >= 100"]
|
||||
tier: comum
|
||||
pescador:
|
||||
titulo: "Pescador Profissional"
|
||||
descricao: "pescou 500 peixes"
|
||||
condicoes: ["pesca >= 500"]
|
||||
tier: incomum
|
||||
mestre-da-vara:
|
||||
titulo: "Mestre da Vara"
|
||||
descricao: "pescou 2.000 peixes"
|
||||
condicoes: ["pesca >= 2000"]
|
||||
tier: raro
|
||||
|
||||
# --- pulos ---
|
||||
pula-pula:
|
||||
titulo: "Pula-Pula"
|
||||
descricao: "deu 10.000 pulos"
|
||||
condicoes: ["pulos >= 10000"]
|
||||
tier: comum
|
||||
saltitante:
|
||||
titulo: "Saltitante"
|
||||
descricao: "deu 50.000 pulos"
|
||||
condicoes: ["pulos >= 50000"]
|
||||
tier: incomum
|
||||
canguru:
|
||||
titulo: "Canguru"
|
||||
descricao: "deu 100.000 pulos"
|
||||
condicoes: ["pulos >= 100000"]
|
||||
tier: raro
|
||||
|
||||
# --- blocos raros (métricas detalhadas) ---
|
||||
escavador-de-obsidiana:
|
||||
titulo: "Escavador de Obsidiana"
|
||||
descricao: "minerou 64 obsidianas"
|
||||
condicoes: ["minerou:obsidian >= 64"]
|
||||
tier: epico
|
||||
netherita-bruta:
|
||||
titulo: "Netherita Bruta"
|
||||
descricao: "minerou 16 restos antigos"
|
||||
condicoes: ["minerou:ancient_debris >= 16"]
|
||||
tier: lendario
|
||||
|
||||
# --- mortes e as engraçadas ---
|
||||
gato-sete-vidas:
|
||||
titulo: "Gato de Sete Vidas"
|
||||
descricao: "morreu 50 vezes e continua tentando"
|
||||
condicoes: ["mortes >= 50"]
|
||||
tier: incomum
|
||||
vida-dura:
|
||||
titulo: "Vida Dura"
|
||||
descricao: "morreu 100 vezes"
|
||||
condicoes: ["mortes >= 100"]
|
||||
tier: raro
|
||||
casca-grossa:
|
||||
titulo: "Casca Grossa"
|
||||
descricao: "passou de 50 horas com menos de 10 mortes"
|
||||
condicoes: ["tempo >= 50", "mortes < 10"]
|
||||
tier: raro
|
||||
intocavel:
|
||||
titulo: "Intocável"
|
||||
descricao: "passou de 100 horas sem morrer nenhuma vez"
|
||||
condicoes: ["tempo >= 100", "mortes == 0"]
|
||||
tier: lendario
|
||||
cor: "#ff5555"
|
||||
turista:
|
||||
titulo: "Turista"
|
||||
descricao: "passou de 100 horas jogadas sem minerar 5.000 blocos"
|
||||
condicoes: ["tempo >= 100", "mineracao < 5000"]
|
||||
tier: incomum
|
||||
imortal-as-avessas:
|
||||
titulo: "Imortal às Avessas"
|
||||
descricao: "morreu mais de uma vez a cada 100 blocos minerados"
|
||||
condicoes: ["mineracao >= 2000", "mortes > mineracao/100"]
|
||||
tier: epico
|
||||
kamikaze:
|
||||
titulo: "Kamikaze"
|
||||
descricao: "morreu mais vezes do que derrotou monstros"
|
||||
condicoes: ["combate >= 100", "mortes > combate"]
|
||||
descricao: "derrotou 20 monstros mas morreu mais vezes ainda"
|
||||
condicoes: ["combate >= 20", "mortes > combate"]
|
||||
tier: epico
|
||||
rato-de-caverna:
|
||||
titulo: "Rato de Caverna"
|
||||
descricao: "minerou 50.000 blocos sem caminhar 10 km"
|
||||
condicoes: ["mineracao >= 50000", "distancia < 10"]
|
||||
descricao: "minerou 20.000 blocos sem caminhar 50 km"
|
||||
condicoes: ["mineracao >= 20000", "distancia < 50"]
|
||||
tier: raro
|
||||
nomade:
|
||||
titulo: "Nômade"
|
||||
descricao: "caminhou 100 km sem minerar 1.000 blocos"
|
||||
condicoes: ["distancia >= 100", "mineracao < 1000"]
|
||||
tier: raro
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Canalhandia — English (translated from messages_pt.properties).
|
||||
# Never alter {0}/{1} placeholders or MiniMessage <...> tags.
|
||||
|
||||
# Mourning (Canalhandia.onDeath) — F button under each death message.
|
||||
canalhandia.morte.luto.prestar=pay respects for {0}
|
||||
canalhandia.morte.luto.digitar=type /f to pay respects for {0}
|
||||
canalhandia.morte.luto.resumo={0} paid respects for {1}.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Canalhandia — portugues (fonte de verdade). Padroes MessageFormat: {0}, {1}, ...
|
||||
# NUNCA altere os placeholders {0}/{1} nem as tags MiniMessage <...>.
|
||||
|
||||
# Luto (Canalhandia.onDeath) — botao F sob cada mensagem de morte.
|
||||
canalhandia.morte.luto.prestar=prestar luto por {0}
|
||||
canalhandia.morte.luto.digitar=digite /f para prestar luto por {0}
|
||||
canalhandia.morte.luto.resumo={0} prestaram luto por {1}.
|
||||
@@ -1,10 +1,14 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextColor;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -123,6 +127,54 @@ class AchievementTest {
|
||||
() -> Achievement.parse("k", "T", "d", List.of()));
|
||||
}
|
||||
|
||||
// --- colours and tiers -------------------------------------------------
|
||||
|
||||
@Test
|
||||
void tierPicksTheColourAndCorOverridesIt() {
|
||||
assertEquals(NamedTextColor.GOLD,
|
||||
Achievement.parse("l", "L", "d", List.of("tempo >= 1"), "lendario", null).color());
|
||||
assertEquals(NamedTextColor.LIGHT_PURPLE,
|
||||
Achievement.parse("e", "E", "d", List.of("tempo >= 1"), "epico", null).color());
|
||||
// A missing or unknown tier stays legible white — never the dark tone
|
||||
// that started this: the default must always read on chat.
|
||||
assertEquals(NamedTextColor.WHITE,
|
||||
Achievement.parse("c", "C", "d", List.of("tempo >= 1"), null, null).color());
|
||||
// An explicit cor wins over the tier, by name or by hex.
|
||||
assertEquals(NamedTextColor.RED,
|
||||
Achievement.parse("n", "N", "d", List.of("tempo >= 1"), "comum", "red").color());
|
||||
assertEquals(TextColor.fromHexString("#ff5555"),
|
||||
Achievement.parse("o", "O", "d", List.of("tempo >= 1"), "comum", "#ff5555").color());
|
||||
// Garbage cor falls back to the tier colour rather than blowing up.
|
||||
assertEquals(NamedTextColor.AQUA,
|
||||
Achievement.parse("b", "B", "d", List.of("tempo >= 1"), "raro", "notacolor").color());
|
||||
}
|
||||
|
||||
// --- detailed per-mob / per-block metrics ------------------------------
|
||||
|
||||
@Test
|
||||
void statRefMetricsReadRawCounts() {
|
||||
Achievement spiders = Achievement.parse("a", "A", "d", List.of("matou:spider >= 100"));
|
||||
Map<String, Long> s = raw();
|
||||
s.put("matou:spider", 99L);
|
||||
assertFalse(spiders.met(s));
|
||||
s.put("matou:spider", 100L);
|
||||
assertTrue(spiders.met(s));
|
||||
}
|
||||
|
||||
@Test
|
||||
void referencedStatsListsEveryRefTheCatalogueUses() {
|
||||
Achievement.load(List.of(
|
||||
Achievement.parse("a", "A", "d", List.of("matou:creeper >= 1")),
|
||||
Achievement.parse("b", "B", "d", List.of("minerou:obsidian >= 1", "tempo >= 1"))));
|
||||
assertEquals(Set.of("matou:creeper", "minerou:obsidian"), Achievement.referencedStats());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownStatPrefix() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> Achievement.parse("x", "X", "d", List.of("voou:creeper >= 1")));
|
||||
}
|
||||
|
||||
// --- the shipped catalogue loads and is sane ---------------------------
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.bukkit.Material;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The consolation-gift parsing and pick. Deliberately avoids the happy path of
|
||||
* {@link DeathGift#parse}, which calls {@code Material.isItem()} — that throws in
|
||||
* a unit JVM without a bootstrapped registry (same limit RecipeBookTest notes),
|
||||
* so the item-resolution branch is verified live instead.
|
||||
*/
|
||||
class DeathGiftTest {
|
||||
|
||||
private static final Logger LOG = Logger.getAnonymousLogger();
|
||||
|
||||
@Test
|
||||
void skipsMalformedAndUnknownLines() {
|
||||
assertTrue(DeathGift.parse(List.of("sem as barras certas"), LOG).isEmpty());
|
||||
assertTrue(DeathGift.parse(List.of("SÓ | DUAS_PARTES"), LOG).isEmpty());
|
||||
// Unknown material name is rejected at matchMaterial, before isItem().
|
||||
assertTrue(DeathGift.parse(List.of("ITEM_QUE_NAO_EXISTE_XYZ | Nome | msg"), LOG).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pickIsNullOnEmptyAndAMemberOtherwise() {
|
||||
assertNull(DeathGift.pick(List.of(), new Random()));
|
||||
DeathGift.Gift only = new DeathGift.Gift(Material.POPPY, "Flor do Velório", "oi");
|
||||
assertSame(only, DeathGift.pick(List.of(only), new Random()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import net.kyori.adventure.key.Key;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||
import net.kyori.adventure.translation.GlobalTranslator;
|
||||
import net.kyori.adventure.translation.TranslationStore;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.MessageFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.PropertyResourceBundle;
|
||||
import java.util.ResourceBundle;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
/**
|
||||
* The two contracts the i18n bundles must hold: identical key sets across PT
|
||||
* and EN, and one translatable rendering differently per locale. The store's
|
||||
* own {@code translate(key, locale)} is an exact-locale lookup (no fallback);
|
||||
* the fallback chain runs in {@link GlobalTranslator#render}, which is what
|
||||
* the locale-resolution tests exercise.
|
||||
*/
|
||||
class I18nTest {
|
||||
|
||||
private TranslationStore.StringBased<MessageFormat> store;
|
||||
|
||||
@BeforeEach
|
||||
void registerStore() throws IOException {
|
||||
store = TranslationStore.messageFormat(Key.key("canalhandia"));
|
||||
store.defaultLocale(Locale.ENGLISH);
|
||||
store.registerAll(Locale.ENGLISH, bundle("lang/messages_en.properties"), true);
|
||||
store.registerAll(Locale.of("pt"), bundle("lang/messages_pt.properties"), true);
|
||||
GlobalTranslator.translator().addSource(store);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void unregisterStore() {
|
||||
GlobalTranslator.translator().removeSource(store);
|
||||
}
|
||||
|
||||
private static ResourceBundle bundle(String resource) throws IOException {
|
||||
try (var in = I18nTest.class.getClassLoader().getResourceAsStream(resource)) {
|
||||
assertNotNull(in, "bundle ausente no classpath: " + resource);
|
||||
return new PropertyResourceBundle(new InputStreamReader(in, StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private static String plain(Component c) {
|
||||
return PlainTextComponentSerializer.plainText().serialize(c);
|
||||
}
|
||||
|
||||
/** Every key in one bundle must exist in the other, or a locale shows the raw key. */
|
||||
@Test
|
||||
void bothBundlesHaveTheSameKeys() throws IOException {
|
||||
var pt = new TreeSet<>(bundle("lang/messages_pt.properties").keySet());
|
||||
var en = new TreeSet<>(bundle("lang/messages_en.properties").keySet());
|
||||
assertEquals(pt, en,
|
||||
"chaves divergentes — so no PT: " + only(pt, en) + ", so no EN: " + only(en, pt));
|
||||
}
|
||||
|
||||
private static java.util.Set<String> only(java.util.Set<String> a, java.util.Set<String> b) {
|
||||
var diff = new TreeSet<>(a);
|
||||
diff.removeAll(b);
|
||||
return diff;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pt client and an en client see different text from one component.
|
||||
* Rendering runs through {@link GlobalTranslator}, the same path Paper uses
|
||||
* on send — the store itself only resolves a key to a {@link MessageFormat}.
|
||||
*/
|
||||
@Test
|
||||
void rendersDifferentlyPerLocale() {
|
||||
Component translatable = Component.translatable("canalhandia.morte.luto.prestar",
|
||||
Component.text("Steve"));
|
||||
|
||||
Component pt = GlobalTranslator.render(translatable, Locale.of("pt"));
|
||||
Component en = GlobalTranslator.render(translatable, Locale.ENGLISH);
|
||||
assertEquals("prestar luto por Steve", plain(pt));
|
||||
assertEquals("pay respects for Steve", plain(en));
|
||||
}
|
||||
|
||||
/** pt_BR falls back to pt via the GlobalTranslator chain. */
|
||||
@Test
|
||||
void ptBrFallsBackToPt() {
|
||||
Component rendered = GlobalTranslator.render(
|
||||
Component.translatable("canalhandia.morte.luto.resumo",
|
||||
Component.text("Ana, Bob"), Component.text("Steve")),
|
||||
Locale.forLanguageTag("pt-BR"));
|
||||
assertEquals("Ana, Bob prestaram luto por Steve.", plain(rendered));
|
||||
}
|
||||
|
||||
/** An unknown locale renders in the default (en), not as the raw key. */
|
||||
@Test
|
||||
void unknownLocaleFallsBackToDefault() {
|
||||
Component rendered = GlobalTranslator.render(
|
||||
Component.translatable("canalhandia.morte.luto.prestar", Component.text("Steve")),
|
||||
Locale.forLanguageTag("ja"));
|
||||
assertEquals("pay respects for Steve", plain(rendered));
|
||||
}
|
||||
}
|
||||
@@ -54,4 +54,11 @@ class TitlesTest {
|
||||
void tagCarriesTheTitle() {
|
||||
assertNotNull(TitleChatListener.tag(Achievement.byKey("pedreiro")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tagRootIsColourlessSoMessageStaysWhite() {
|
||||
// The chat message is appended to this tag; a coloured root would bleed
|
||||
// into unstyled message text and grey it out. Root must carry no colour.
|
||||
assertNull(TitleChatListener.tag(Achievement.byKey("pedreiro")).color());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user