i18n: per-player EN/PT via Adventure GlobalTranslator #1

Merged
masi merged 20 commits from feat/ia-grounding into main 2026-08-12 15:53:28 +00:00
12 changed files with 1186 additions and 4 deletions
Showing only changes of commit bc711a48c2 - Show all commits
+16 -3
View File
@@ -49,16 +49,29 @@ else
fi fi
# Every command the code registers must exist in plugin.yml, or register() # Every command the code registers must exist in plugin.yml, or register()
# logs a warning and the command silently does nothing in game. # logs a warning and the command silently does nothing in game.
# Kept in step with Canalhandia.onEnable's register() list. A command that is
# registered in code but absent here logs a warning at boot and then silently
# does nothing in game, which is a hard failure to diagnose from inside.
CMDS="canalhandia curiosidade adivinha enquete ranking reagir reacoes \
palpite votar legal wow top f ia iap errado nota save"
missing="" missing=""
for cmd in canalhandia curiosidade adivinha enquete ranking reagir reacoes \ for cmd in $CMDS; do
palpite votar legal wow top f ia iap errado; do
unzip -p "$JAR" plugin.yml 2>/dev/null | grep -qE "^ ${cmd}:" || missing="$missing $cmd" unzip -p "$JAR" plugin.yml 2>/dev/null | grep -qE "^ ${cmd}:" || missing="$missing $cmd"
done done
if [ -z "$missing" ]; then if [ -z "$missing" ]; then
pass "all 16 commands declared in plugin.yml" pass "all $(echo $CMDS | wc -w) commands declared in plugin.yml"
else else
bad "commands missing from plugin.yml:$missing" bad "commands missing from plugin.yml:$missing"
fi fi
# Permissions the new features gate on. An undeclared Bukkit permission falls
# back to op-only, which would silently stop normal players writing notes.
for perm in canalhandia.nota canalhandia.nota.publica; do
if unzip -p "$JAR" plugin.yml 2>/dev/null | grep -q " ${perm}:"; then
pass "permission ${perm} declared"
else
bad "permission ${perm} MISSING — would default to op-only"
fi
done
# config.yml ships defaults; a jar without it means saveDefaultConfig() writes # config.yml ships defaults; a jar without it means saveDefaultConfig() writes
# nothing and every setting silently falls back to the hardcoded default. # nothing and every setting silently falls back to the hardcoded default.
if unzip -p "$JAR" config.yml >/dev/null 2>&1; then if unzip -p "$JAR" config.yml >/dev/null 2>&1; then
@@ -350,6 +350,17 @@ final class Ai {
if (liveState != null && !liveState.isBlank()) { if (liveState != null && !liveState.isBlank()) {
messages.add(new MiniMax.Turn("system", liveState)); messages.add(new MiniMax.Turn("system", liveState));
} }
// Public notes only — Notes.publicSummary never returns a private one,
// and that filter lives there rather than here so no future caller can
// leak personal text to a third-party API by accident.
if (settings.moduleEnabled(Module.NOTAS)) {
String notes = plugin.notes().publicSummary(settings.aiNotes());
if (notes != null) {
messages.add(new MiniMax.Turn("system",
"Anotações públicas que os jogadores deixaram no servidor. "
+ "Use como fatos ao responder sobre lugares e combinados:\n" + notes));
}
}
if (chatContext != null && !chatContext.isBlank()) { if (chatContext != null && !chatContext.isBlank()) {
messages.add(new MiniMax.Turn("system", messages.add(new MiniMax.Turn("system",
"Últimas mensagens do chat público, da mais antiga para a mais recente. " "Últimas mensagens do chat público, da mais antiga para a mais recente. "
@@ -65,6 +65,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
private final ChatLog chatLog = new ChatLog(); private final ChatLog chatLog = new ChatLog();
private Settings settings; private Settings settings;
private Notes notes;
private OfflineStats offlineStats; private OfflineStats offlineStats;
private Milestones milestones; private Milestones milestones;
private Ai ai; private Ai ai;
@@ -82,6 +83,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
public void onEnable() { public void onEnable() {
saveDefaultConfig(); saveDefaultConfig();
settings = new Settings(this); settings = new Settings(this);
notes = new Notes(new java.io.File(getDataFolder(), "notas.yml"));
offlineStats = new OfflineStats(this); offlineStats = new OfflineStats(this);
milestones = new Milestones(this); milestones = new Milestones(this);
ai = new Ai(this); ai = new Ai(this);
@@ -95,7 +97,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
CanalhandiaCommand root = new CanalhandiaCommand(this); CanalhandiaCommand root = new CanalhandiaCommand(this);
for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking", for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking",
"reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia", "iap", "reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia", "iap",
"errado")) { "errado", "nota", "save")) {
register(name, root); register(name, root);
} }
@@ -150,6 +152,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
return chatLog; return chatLog;
} }
/** Player notes, public and private. Never null. */
Notes notes() {
return notes;
}
// --- scheduling --------------------------------------------------------- // --- scheduling ---------------------------------------------------------
/** Starts, stops or restarts the repeating curiosity task to match the mode. */ /** Starts, stops or restarts the repeating curiosity task to match the mode. */
@@ -4,6 +4,7 @@ import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
@@ -49,6 +50,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
case "reacoes" -> whoReacted(sender); case "reacoes" -> whoReacted(sender);
case "ia" -> ia(sender, args, false); case "ia" -> ia(sender, args, false);
case "iap" -> ia(sender, args, true); case "iap" -> ia(sender, args, true);
case "nota" -> nota(sender, args);
// Quick shortcut: "/save" pins where you are, "/save <texto>" pins
// it with a note. Private by default — the safe default for a
// one-word command nobody reads the help for first.
case "save" -> saveShortcut(sender, args);
// Typed twin of the [F] mourning button — "f" is not a configured // Typed twin of the [F] mourning button — "f" is not a configured
// reaction (the mourning set is hardcoded), so it can't be found via // reaction (the mourning set is hardcoded), so it can't be found via
// reactionForCommand; route it directly. Acts on the latest message, // reactionForCommand; route it directly. Acts on the latest message,
@@ -774,6 +780,12 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
+ " · estado do servidor " + (settings.aiServerState() ? "on" : "off") + " · estado do servidor " + (settings.aiServerState() ? "on" : "off")
+ " · estatísticas " + (settings.aiPlayerStats() ? "on" : "off") + " · estatísticas " + (settings.aiPlayerStats() ? "on" : "off")
+ " · estilo " + (settings.aiFancy() ? "rico" : "simples")); + " · estilo " + (settings.aiFancy() ? "rico" : "simples"));
Msg.line(sender, "notas", plugin.notes().size() + " no total"
+ (sender instanceof Player player
? " · " + plugin.notes().countBy(player.getUniqueId().toString())
+ " suas (máx. " + Notes.MAX_PER_PLAYER + ")"
: "")
+ " · " + settings.aiNotes() + " públicas vão para a IA");
} }
private String enabledModules() { private String enabledModules() {
@@ -975,6 +987,255 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
Msg.ok(sender, "Personalidade da IA: " + persona.key() + "" + persona.description()); Msg.ok(sender, "Personalidade da IA: " + persona.key() + "" + persona.description());
} }
// --- /nota and /save ----------------------------------------------------
/**
* {@code /nota <sub>} — the full note interface.
*
* <p>Two scopes. A <b>private</b> note is visible only to its author and
* everyone may write one ({@code canalhandia.nota}, default true). A
* <b>public</b> note is broadcast and readable by all, and writing one needs
* {@code canalhandia.nota.publica} (default op), so public notes stay a
* curated board rather than a graffiti wall.
*/
private boolean nota(CommandSender sender, String[] args) {
if (!plugin.settings().moduleEnabled(Module.NOTAS)) {
Msg.error(sender, "O módulo de anotações está desligado.");
return true;
}
if (!sender.hasPermission("canalhandia.nota")) {
return denied(sender);
}
if (args.length == 0) {
return notaHelp(sender);
}
String[] rest = Arrays.copyOfRange(args, 1, args.length);
switch (args[0].toLowerCase(Locale.ROOT)) {
case "add", "adicionar", "nova" -> notaAdd(sender, rest, Note.Scope.PRIVADA);
case "publica", "publico" -> notaAdd(sender, rest, Note.Scope.PUBLICA);
case "privada", "privado" -> notaAdd(sender, rest, Note.Scope.PRIVADA);
case "listar", "lista", "ls" -> notaList(sender, rest);
case "ver" -> notaShow(sender, rest);
case "buscar", "procurar" -> notaSearch(sender, rest);
case "remover", "apagar", "rm" -> notaRemove(sender, rest);
default -> notaHelp(sender);
}
return true;
}
/**
* {@code /save} — pin the spot you are standing on, privately.
*
* <p>{@code /save} and {@code /save coords} store the location with a
* generated label; {@code /save <texto>} stores it with that text. Private
* on purpose: this is the command someone types without reading help first,
* and the safe default for that is the one that cannot surprise anyone by
* broadcasting. {@code /nota publica <texto>} is the explicit way to share.
*/
private boolean saveShortcut(CommandSender sender, String[] args) {
if (!plugin.settings().moduleEnabled(Module.NOTAS)) {
Msg.error(sender, "O módulo de anotações está desligado.");
return true;
}
if (!sender.hasPermission("canalhandia.nota")) {
return denied(sender);
}
if (!(sender instanceof Player player)) {
Msg.error(sender, "Só jogadores podem anotar (a anotação guarda onde você está).");
return true;
}
// "/save" and "/save coords" mean the same thing: just the place. The
// word is accepted so the command is discoverable ("/save coords" is
// what people guess) without becoming a note whose text is "coords".
boolean placeOnly = args.length == 0
|| (args.length == 1 && args[0].equalsIgnoreCase("coords"));
String text = placeOnly
? "Local salvo em " + ServerState.worldLabel(player.getWorld())
: String.join(" ", args);
store(player, Note.Scope.PRIVADA, text);
return true;
}
private void notaAdd(CommandSender sender, String[] args, Note.Scope scope) {
if (!(sender instanceof Player player)) {
Msg.error(sender, "Só jogadores podem anotar (a anotação guarda onde você está).");
return;
}
if (scope == Note.Scope.PUBLICA && !sender.hasPermission("canalhandia.nota.publica")) {
Msg.error(sender, "Você não pode criar anotações públicas. Use /nota add <texto> "
+ "para uma anotação só sua.");
return;
}
if (args.length == 0) {
Msg.error(sender, "Uso: /nota " + scope.key() + " <texto>");
return;
}
store(player, scope, String.join(" ", args));
}
/** Shared tail of every create path: clean, store, confirm, broadcast. */
private void store(Player player, Note.Scope scope, String rawText) {
String text = Note.cleanText(rawText);
if (text == null) {
Msg.error(player, "A anotação está vazia.");
return;
}
Location at = player.getLocation();
Note note = plugin.notes().add(scope, player.getName(), player.getUniqueId().toString(),
text, ServerState.worldLabel(player.getWorld()),
at.getBlockX(), at.getBlockY(), at.getBlockZ());
if (note == null) {
Msg.error(player, "Você já tem " + Notes.MAX_PER_PLAYER
+ " anotações. Apague alguma com /nota remover <n>.");
return;
}
Msg.ok(player, "Anotação #" + note.id() + " salva (" + scope.label() + ") em "
+ note.place() + ".");
if (scope == Note.Scope.PUBLICA) {
// Public notes are announced, because a board nobody is told about
// is a board nobody reads.
plugin.broadcastPerPlatform(bedrock -> Msg.tag("Nota", NamedTextColor.GREEN)
.append(Component.text(player.getName() + ": ", NamedTextColor.GRAY))
.append(noteBody(note, bedrock)));
} else {
player.sendMessage(Msg.tag("Nota", NamedTextColor.GREEN).append(noteBody(note, false)));
}
}
private void notaList(CommandSender sender, String[] args) {
Note.Scope scope = args.length > 0 ? Note.Scope.byKey(args[0]) : null;
if (args.length > 0 && scope == null) {
Msg.error(sender, "Uso: /nota listar [publicas|privadas]");
return;
}
show(sender, plugin.notes().visibleTo(viewerId(sender), scope, null),
scope == null ? "Suas anotações e as públicas" : "Anotações " + scope.key() + "s");
}
private void notaSearch(CommandSender sender, String[] args) {
if (args.length == 0) {
Msg.error(sender, "Uso: /nota buscar <texto>");
return;
}
String query = String.join(" ", args);
show(sender, plugin.notes().visibleTo(viewerId(sender), null, query),
"Anotações com \"" + query + "\"");
}
private void notaShow(CommandSender sender, String[] args) {
Note note = args.length == 0 ? null : plugin.notes().byId(parseLong(args[0]));
// A note the viewer cannot see is reported as missing rather than as
// forbidden: saying "that one is private" would confirm it exists, which
// is itself a leak about someone else's note.
if (note == null || !note.visibleTo(viewerId(sender))) {
Msg.error(sender, "Anotação não encontrada.");
return;
}
Msg.header(sender, "Anotação #" + note.id());
sender.sendMessage(noteBody(note, isBedrock(sender)));
Msg.line(sender, "autor", note.author());
Msg.line(sender, "escopo", note.scope().key() + " (" + note.scope().label() + ")");
Msg.line(sender, "lugar", note.place());
}
private void notaRemove(CommandSender sender, String[] args) {
if (args.length == 0) {
Msg.error(sender, "Uso: /nota remover <n>");
return;
}
Note note = plugin.notes().byId(parseLong(args[0]));
if (note == null || !note.visibleTo(viewerId(sender))) {
Msg.error(sender, "Anotação não encontrada.");
return;
}
if (!note.deletableBy(viewerId(sender), sender.hasPermission(ADMIN))) {
Msg.error(sender, "Essa anotação é de " + note.author() + ".");
return;
}
plugin.notes().remove(note.id());
Msg.ok(sender, "Anotação #" + note.id() + " apagada.");
}
private void show(CommandSender sender, List<Note> notes, String title) {
if (notes.isEmpty()) {
Msg.error(sender, "Nenhuma anotação.");
return;
}
Msg.header(sender, title + " (" + notes.size() + ")");
boolean bedrock = isBedrock(sender);
// Capped so a long list cannot push everything else out of the chat
// window; the rest are reachable with /nota buscar.
int shown = Math.min(notes.size(), 10);
for (int i = 0; i < shown; i++) {
sender.sendMessage(noteBody(notes.get(i), bedrock));
}
if (notes.size() > shown) {
Msg.line(sender, "", "e mais " + (notes.size() - shown)
+ ". Use /nota buscar <texto> para filtrar.");
}
}
/**
* One note as a chat line: id, scope colour, text, and the place.
*
* <p>On Java the coordinates are click-to-copy, the same affordance the
* death-coords message uses. Bedrock renders no click event, so it gets the
* same text plainly rather than a dead link.
*/
private Component noteBody(Note note, boolean bedrock) {
NamedTextColor colour = note.scope() == Note.Scope.PUBLICA
? NamedTextColor.GREEN : NamedTextColor.LIGHT_PURPLE;
Component place = Component.text(note.place(), NamedTextColor.GRAY);
if (!bedrock) {
place = place.clickEvent(ClickEvent.copyToClipboard(note.coords()))
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
Component.text("Clique para copiar as coordenadas",
NamedTextColor.DARK_GRAY)));
}
return Component.text(" #" + note.id() + " ", colour)
.append(Component.text(note.text(), NamedTextColor.WHITE))
.append(Component.text("", NamedTextColor.DARK_GRAY))
.append(place);
}
private boolean notaHelp(CommandSender sender) {
Msg.header(sender, "Anotações");
Map<String, String> commands = new LinkedHashMap<>();
commands.put("/save", "salva onde você está (privado)");
commands.put("/save coords", "o mesmo, escrito por extenso");
commands.put("/save <texto>", "salva o lugar com um texto");
commands.put("/nota add <texto>", "anotação privada, só você vê");
if (sender.hasPermission("canalhandia.nota.publica")) {
commands.put("/nota publica <texto>", "anotação pública, todos veem");
}
commands.put("/nota listar [publicas|privadas]", "lista o que você pode ver");
commands.put("/nota buscar <texto>", "procura no texto das anotações");
commands.put("/nota ver <n>", "mostra uma anotação inteira");
commands.put("/nota remover <n>", "apaga uma anotação sua");
commands.forEach((cmd, description) -> sender.sendMessage(
Component.text(" " + cmd, NamedTextColor.AQUA)
.append(Component.text("" + description, NamedTextColor.GRAY))));
return true;
}
/** The viewer's UUID as stored on notes, or null for the console. */
private String viewerId(CommandSender sender) {
return sender instanceof Player player ? player.getUniqueId().toString() : null;
}
private boolean isBedrock(CommandSender sender) {
return sender instanceof Player player && Platform.isBedrock(player);
}
/** Parses a note id, returning -1 (never a valid id) on junk input. */
private long parseLong(String raw) {
try {
return Long.parseLong(raw.trim().replace("#", ""));
} catch (NumberFormatException e) {
return -1;
}
}
private void iaCorrect(CommandSender sender, String[] args) { private void iaCorrect(CommandSender sender, String[] args) {
if (!sender.hasPermission("canalhandia.ia.corrigir")) { if (!sender.hasPermission("canalhandia.ia.corrigir")) {
denied(sender); denied(sender);
@@ -1034,6 +1295,33 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
if (name.equals("enquete") && args.length == 1) { if (name.equals("enquete") && args.length == 1) {
return filter(List.of("encerrar"), args[0]); return filter(List.of("encerrar"), args[0]);
} }
if (name.equals("nota")) {
if (args.length == 1) {
List<String> options = new ArrayList<>(
List.of("add", "listar", "buscar", "ver", "remover"));
if (sender.hasPermission("canalhandia.nota.publica")) {
options.add("publica");
}
return filter(options, args[0]);
}
if (args.length == 2 && args[0].equalsIgnoreCase("listar")) {
return filter(List.of("publicas", "privadas"), args[1]);
}
// Completing note ids for ver/remover: only the ones this player is
// allowed to see, so completion cannot enumerate someone else's.
if (args.length == 2
&& (args[0].equalsIgnoreCase("ver") || args[0].equalsIgnoreCase("remover"))) {
List<String> ids = new ArrayList<>();
for (Note note : plugin.notes().visibleTo(viewerId(sender), null, null)) {
ids.add(String.valueOf(note.id()));
}
return filter(ids, args[1]);
}
return List.of();
}
if (name.equals("save")) {
return args.length == 1 ? filter(List.of("coords"), args[0]) : List.of();
}
if (name.equals("ia") || name.equals("iap")) { if (name.equals("ia") || name.equals("iap")) {
// Only the tuning subcommands are suggested — the rest of /ia is // Only the tuning subcommands are suggested — the rest of /ia is
// free text, and completing a question would be noise. // free text, and completing a question would be noise.
@@ -11,6 +11,7 @@ enum Module {
MARCOS("marcos", "Marcos e conquistas"), MARCOS("marcos", "Marcos e conquistas"),
MORTES("mortes", "Mortes com humor e coordenadas"), MORTES("mortes", "Mortes com humor e coordenadas"),
ZOACAO("zoacao", "Zoa de quem manda só 'f' no chat"), ZOACAO("zoacao", "Zoa de quem manda só 'f' no chat"),
NOTAS("notas", "Anotações públicas e privadas no chat"),
IA("ia", "Perguntas para a IA"); IA("ia", "Perguntas para a IA");
private final String key; private final String key;
@@ -0,0 +1,163 @@
package dev.marcospaulo.canalhandia;
import java.util.Locale;
/**
* One note: a line of text a player pinned somewhere in the world.
*
* <p>A plain immutable record with no Bukkit types, so the whole model — text
* limits, visibility rules, coordinate formatting — is testable without a
* server. {@link Notes} owns storage; this owns what a note <em>is</em>.
*
* <p>The coordinates are part of the note rather than optional metadata,
* because on a Minecraft server a note is nearly always about a <em>place</em>:
* where the base is, where the mob spawner was found, where someone left a
* chest. A note without them would answer the wrong half of the question.
*/
record Note(long id, Scope scope, String author, String authorId, String text,
String world, int x, int y, int z, long createdAt) {
/** Who can see a note. */
enum Scope {
/**
* Only the author. Never broadcast, never listed to anyone else — and
* deliberately never sent to the AI, because a private note is personal
* text and the AI call leaves the server.
*/
PRIVADA("privada", "só você vê"),
/**
* Everyone can read. Creating one needs a permission, so public notes
* do not become a graffiti wall.
*/
PUBLICA("publica", "todos veem");
private final String key;
private final String label;
Scope(String key, String label) {
this.key = key;
this.label = label;
}
String key() {
return key;
}
String label() {
return label;
}
static Scope byKey(String key) {
if (key == null) {
return null;
}
String wanted = key.trim().toLowerCase(Locale.ROOT);
// The masculine forms are accepted too: people type "publico" as
// often as "publica", and rejecting it reads as a bug. Spelled out
// rather than derived, because a blanket a→o rewrite turns
// "privada" into "privodo".
if (wanted.equals("publico") || wanted.equals("publicas") || wanted.equals("publicos")) {
return PUBLICA;
}
if (wanted.equals("privado") || wanted.equals("privadas") || wanted.equals("privados")) {
return PRIVADA;
}
for (Scope scope : values()) {
if (scope.key.equals(wanted)
|| scope.name().toLowerCase(Locale.ROOT).equals(wanted)) {
return scope;
}
}
return null;
}
static boolean isValid(String key) {
return byKey(key) != null;
}
}
/**
* Longest note text kept. Long enough for a real sentence, short enough
* that one note cannot flood chat when a list is printed.
*/
static final int MAX_TEXT = 256;
/**
* Trims and caps note text, returning {@code null} when there is nothing
* usable left.
*
* <p>Control characters and the section sign go: a note is echoed back into
* chat, and a note containing colour codes could otherwise forge a line that
* looks like it came from the server.
*/
static String cleanText(String raw) {
if (raw == null) {
return null;
}
StringBuilder out = new StringBuilder(raw.length());
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
if (c == '§' || Character.isISOControl(c)) {
continue;
}
out.append(c);
}
String text = out.toString().strip();
if (text.isEmpty()) {
return null;
}
return text.length() > MAX_TEXT ? text.substring(0, MAX_TEXT).strip() + "" : text;
}
/** "10, 64, -20 (Mundo normal)" — the form used for click-to-copy. */
String coords() {
return x + ", " + y + ", " + z;
}
String place() {
return coords() + (world == null || world.isBlank() ? "" : " (" + world + ")");
}
/** True if {@code viewerId} is allowed to read this note. */
boolean visibleTo(String viewerId) {
return scope == Scope.PUBLICA || (authorId != null && authorId.equals(viewerId));
}
/**
* True if {@code viewerId} may delete this note. Authors delete their own;
* an admin deletes any, which is the only way to clear a public note left
* by someone who has since stopped playing.
*/
boolean deletableBy(String viewerId, boolean admin) {
return admin || (authorId != null && authorId.equals(viewerId));
}
/**
* True if the note's text contains every one of the search terms, case- and
* accent-insensitively. Accent folding matters: nobody types "após" into a
* chat search, and a search that misses because of a missing acute reads as
* broken.
*/
boolean matches(String query) {
if (query == null || query.isBlank()) {
return true;
}
String haystack = fold(text);
for (String term : query.trim().split("\\s+")) {
if (!haystack.contains(fold(term))) {
return false;
}
}
return true;
}
/** Lowercase with the combining accents stripped. */
static String fold(String text) {
if (text == null) {
return "";
}
return java.text.Normalizer.normalize(text, java.text.Normalizer.Form.NFD)
.replaceAll("\\p{M}+", "")
.toLowerCase(Locale.ROOT);
}
}
@@ -0,0 +1,231 @@
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;
/**
* Storage for {@link Note}s, persisted to {@code notas.yml}.
*
* <p>Follows {@link Corrections}: an in-memory list guarded by its own monitor,
* rewritten to YAML on every change. Notes are written from chat commands on the
* main thread and read from there too, but the lock costs nothing and keeps the
* class safe if a future caller reads from the async AI path — which
* {@link #publicSummary} is built for.
*
* <p>Rewriting the whole file per change is deliberate. Notes are typed by hand,
* so the file stays small, and a full rewrite cannot leave a half-updated file
* behind the way an append-and-patch scheme can.
*/
final class Notes {
/**
* A hard ceiling per player, so one person cannot grow the file without
* bound. Generous enough that nobody legitimately writing notes will hit it.
*/
static final int MAX_PER_PLAYER = 100;
private final File file;
private final List<Note> notes = new ArrayList<>();
/** Monotonic id, so a note keeps its number even after others are deleted. */
private long nextId = 1;
Notes(File file) {
this.file = file;
load();
}
void load() {
YamlConfiguration yaml = file.exists() ? YamlConfiguration.loadConfiguration(file) : null;
synchronized (notes) {
notes.clear();
nextId = 1;
if (yaml == null) {
return;
}
for (String key : yaml.getKeys(false)) {
String text = yaml.getString(key + ".texto");
String authorId = yaml.getString(key + ".autor-id");
if (text == null || authorId == null) {
continue;
}
Note.Scope scope = Note.Scope.byKey(yaml.getString(key + ".escopo"));
long id = yaml.getLong(key + ".id", 0);
Note note = new Note(
id,
scope == null ? Note.Scope.PRIVADA : scope,
yaml.getString(key + ".autor", "?"),
authorId,
text,
yaml.getString(key + ".mundo", ""),
yaml.getInt(key + ".x"),
yaml.getInt(key + ".y"),
yaml.getInt(key + ".z"),
yaml.getLong(key + ".em", 0));
notes.add(note);
nextId = Math.max(nextId, id + 1);
}
}
}
/**
* Stores a note and returns it, or {@code null} when the author is already
* at {@link #MAX_PER_PLAYER}.
*
* <p>The caller has already cleaned the text with {@link Note#cleanText}.
*/
Note add(Note.Scope scope, String author, String authorId, String text,
String world, int x, int y, int z) {
Note note;
synchronized (notes) {
if (countBy(authorId) >= MAX_PER_PLAYER) {
return null;
}
note = new Note(nextId++, scope, author, authorId, text, world, x, y, z,
System.currentTimeMillis());
notes.add(note);
}
save();
return note;
}
/** Removes a note by id. False if there was no such note. */
boolean remove(long id) {
boolean removed;
synchronized (notes) {
removed = notes.removeIf(note -> note.id() == id);
}
if (removed) {
save();
}
return removed;
}
/** The note with this id, or null. */
Note byId(long id) {
synchronized (notes) {
for (Note note : notes) {
if (note.id() == id) {
return note;
}
}
}
return null;
}
/**
* Every note {@code viewerId} may read, newest first, optionally filtered by
* scope and by a text query.
*
* @param scope null for both scopes
*/
List<Note> visibleTo(String viewerId, Note.Scope scope, String query) {
List<Note> out = new ArrayList<>();
synchronized (notes) {
for (Note note : notes) {
if (!note.visibleTo(viewerId)) {
continue;
}
if (scope != null && note.scope() != scope) {
continue;
}
if (!note.matches(query)) {
continue;
}
out.add(note);
}
}
out.sort(Comparator.comparingLong(Note::id).reversed());
return out;
}
/** How many notes this player has stored, both scopes. */
int countBy(String authorId) {
int count = 0;
synchronized (notes) {
for (Note note : notes) {
if (note.authorId() != null && note.authorId().equals(authorId)) {
count++;
}
}
}
return count;
}
int size() {
synchronized (notes) {
return notes.size();
}
}
/**
* Public notes rendered for the AI's context, newest first, or {@code null}
* when there are none.
*
* <p><b>Public only, never private.</b> A private note is personal text and
* the AI call leaves this server for a third-party API; sending one there
* would be a disclosure the author never agreed to. The filter is here, in
* the only method the AI path calls, rather than at the call site, so a
* future caller cannot get it wrong by accident.
*/
String publicSummary(int max) {
if (max <= 0) {
return null;
}
List<Note> out = new ArrayList<>();
synchronized (notes) {
for (Note note : notes) {
if (note.scope() == Note.Scope.PUBLICA) {
out.add(note);
}
}
}
if (out.isEmpty()) {
return null;
}
out.sort(Comparator.comparingLong(Note::id).reversed());
return format(out.subList(0, Math.min(max, out.size())));
}
/** Pure rendering of a note list for the AI, so the text is testable. */
static String format(List<Note> notes) {
if (notes == null || notes.isEmpty()) {
return null;
}
StringBuilder out = new StringBuilder();
for (Note note : notes) {
out.append("- ").append(note.text())
.append(" (anotado por ").append(note.author())
.append(" em ").append(note.place()).append(")\n");
}
return out.toString().strip();
}
private void save() {
YamlConfiguration yaml = new YamlConfiguration();
synchronized (notes) {
for (int i = 0; i < notes.size(); i++) {
Note note = notes.get(i);
String key = "n" + i;
yaml.set(key + ".id", note.id());
yaml.set(key + ".escopo", note.scope().key());
yaml.set(key + ".autor", note.author());
yaml.set(key + ".autor-id", note.authorId());
yaml.set(key + ".texto", note.text());
yaml.set(key + ".mundo", note.world());
yaml.set(key + ".x", note.x());
yaml.set(key + ".y", note.y());
yaml.set(key + ".z", note.z());
yaml.set(key + ".em", note.createdAt());
}
}
try {
yaml.save(file);
} catch (Exception e) {
throw new IllegalStateException("não consegui gravar " + file, e);
}
}
}
@@ -397,6 +397,22 @@ final class Settings {
set("ia.estilo-rico", value); set("ia.estilo-rico", value);
} }
/**
* How many <b>public</b> notes are sent to the AI as context, so it can
* answer "onde fica a base?" from what players actually wrote down. Zero
* disables it.
*
* <p>Private notes are never sent, at any setting: {@link Notes#publicSummary}
* filters them out at the source. See the note there for why.
*/
int aiNotes() {
return Math.max(0, Math.min(50, plugin.getConfig().getInt("ia.contexto-notas", 10)));
}
void aiNotes(int max) {
set("ia.contexto-notas", Math.max(0, Math.min(50, max)));
}
// --- zoacao (f-gag) ----------------------------------------------------- // --- zoacao (f-gag) -----------------------------------------------------
/** /**
+10
View File
@@ -19,6 +19,9 @@ modulos:
mortes: true mortes: true
# Quem manda só "f" no chat (sem mais nada) leva uma zoada no lugar da mensagem. # Quem manda só "f" no chat (sem mais nada) leva uma zoada no lugar da mensagem.
zoacao: true zoacao: true
# Anotações no chat: /save e /nota. Privadas (só o autor vê) para todos;
# públicas só para quem tiver canalhandia.nota.publica.
notas: true
ia: true # /ia <pergunta> — só para quem tem canalhandia.ia ia: true # /ia <pergunta> — só para quem tem canalhandia.ia
# --- Curiosidades ------------------------------------------------------------ # --- Curiosidades ------------------------------------------------------------
@@ -232,6 +235,13 @@ ia:
# nem clique. # nem clique.
estilo-rico: true estilo-rico: true
# Quantas anotações PÚBLICAS vão junto com a pergunta, para a IA responder
# "onde fica a base?" com o que os jogadores anotaram. 0 desliga.
#
# Anotação PRIVADA nunca é enviada, em nenhuma configuração: é texto pessoal e
# a chamada da IA sai deste servidor para uma API de terceiros.
contexto-notas: 10
# ECONOMICO pula a consulta à wiki (resposta rápida, sem fonte). # ECONOMICO pula a consulta à wiki (resposta rápida, sem fonte).
# PRECISO consulta a wiki (mais lento, mais correto). Troque em jogo com # PRECISO consulta a wiki (mais lento, mais correto). Troque em jogo com
# /ia perfil <nome>. # /ia perfil <nome>.
+14
View File
@@ -67,6 +67,14 @@ commands:
errado: errado:
description: Reage com "errado" à última mensagem (resposta da IA). description: Reage com "errado" à última mensagem (resposta da IA).
usage: /errado usage: /errado
nota:
description: Anotações públicas e privadas no chat.
usage: /nota ajuda
aliases: [notas, anotacao, anotacoes]
save:
description: Atalho para anotar rapidamente onde você está.
usage: /save [coords|<texto>]
aliases: [anotar]
permissions: permissions:
# Declared explicitly: an undeclared Bukkit permission falls back to op-only, # Declared explicitly: an undeclared Bukkit permission falls back to op-only,
@@ -98,6 +106,12 @@ permissions:
canalhandia.ia.perfil: canalhandia.ia.perfil:
description: Permite trocar o perfil da IA entre economico e preciso. description: Permite trocar o perfil da IA entre economico e preciso.
default: op default: op
canalhandia.nota:
description: Permite criar e listar anotações privadas.
default: true
canalhandia.nota.publica:
description: Permite criar anotações públicas, que todos veem. Padrão op; o LuckPerms pode conceder a outros.
default: op
canalhandia.isento: canalhandia.isento:
description: Quem tem isto nunca é sorteado como assunto. description: Quem tem isto nunca é sorteado como assunto.
default: false default: false
@@ -0,0 +1,171 @@
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class NoteTest {
private static Note note(Note.Scope scope, String authorId, String text) {
return new Note(1, scope, "ana", authorId, text, "Mundo normal", 10, 64, -20, 0L);
}
// --- Scope --------------------------------------------------------------
@Test
void scopeByKeyParsesBothForms() {
assertEquals(Note.Scope.PUBLICA, Note.Scope.byKey("publica"));
assertEquals(Note.Scope.PRIVADA, Note.Scope.byKey("privada"));
// People type the masculine form as often as the feminine one.
assertEquals(Note.Scope.PUBLICA, Note.Scope.byKey("publico"));
assertEquals(Note.Scope.PRIVADA, Note.Scope.byKey("privado"));
}
@Test
void scopeByKeyParsesThePluralsTabCompletionSuggests() {
// "/nota listar publicas" is exactly what the completion offers, so the
// plural has to parse or the suggested command fails.
assertEquals(Note.Scope.PUBLICA, Note.Scope.byKey("publicas"));
assertEquals(Note.Scope.PRIVADA, Note.Scope.byKey("privadas"));
assertEquals(Note.Scope.PUBLICA, Note.Scope.byKey("publicos"));
assertEquals(Note.Scope.PRIVADA, Note.Scope.byKey("privados"));
}
@Test
void scopeByKeyIsCaseInsensitiveAndTrims() {
assertEquals(Note.Scope.PUBLICA, Note.Scope.byKey(" PUBLICA "));
}
@Test
void scopeByKeyRejectsJunk() {
assertNull(Note.Scope.byKey("secreta"));
assertNull(Note.Scope.byKey(""));
assertNull(Note.Scope.byKey(null));
assertFalse(Note.Scope.isValid("secreta"));
}
// --- visibility ---------------------------------------------------------
@Test
void privateNoteIsVisibleOnlyToItsAuthor() {
Note n = note(Note.Scope.PRIVADA, "uuid-ana", "minha base");
assertTrue(n.visibleTo("uuid-ana"));
assertFalse(n.visibleTo("uuid-bia"));
assertFalse(n.visibleTo(null), "the console must not read private notes");
}
@Test
void publicNoteIsVisibleToEveryone() {
Note n = note(Note.Scope.PUBLICA, "uuid-ana", "spawn fica aqui");
assertTrue(n.visibleTo("uuid-ana"));
assertTrue(n.visibleTo("uuid-bia"));
assertTrue(n.visibleTo(null));
}
@Test
void aNoteWithNoAuthorIdIsNotPrivatelyVisible() {
// Corrupt/hand-edited YAML must fail closed, not open.
Note n = new Note(1, Note.Scope.PRIVADA, "ana", null, "x", "w", 0, 0, 0, 0L);
assertFalse(n.visibleTo("uuid-ana"));
assertFalse(n.visibleTo(null));
}
// --- deletion -----------------------------------------------------------
@Test
void authorCanDeleteTheirOwn() {
Note n = note(Note.Scope.PUBLICA, "uuid-ana", "x");
assertTrue(n.deletableBy("uuid-ana", false));
assertFalse(n.deletableBy("uuid-bia", false));
}
@Test
void adminCanDeleteAnyone() {
Note n = note(Note.Scope.PUBLICA, "uuid-ana", "x");
assertTrue(n.deletableBy("uuid-bia", true));
}
// --- text cleaning ------------------------------------------------------
@Test
void cleanTextTrims() {
assertEquals("base do caio", Note.cleanText(" base do caio "));
}
@Test
void cleanTextRejectsEmpty() {
assertNull(Note.cleanText(null));
assertNull(Note.cleanText(""));
assertNull(Note.cleanText(" "));
assertNull(Note.cleanText("\n\t"));
}
@Test
void cleanTextStripsColourCodesAndControls() {
// A note is echoed into chat; colour codes would let one forge a line
// that looks like it came from the server.
assertEquals("cSERVIDOR: banido", Note.cleanText("§cSERVIDOR: banido"));
assertEquals("uma linha só", Note.cleanText("uma linha"));
}
@Test
void cleanTextCapsLongInput() {
String text = Note.cleanText("x".repeat(Note.MAX_TEXT + 100));
assertEquals(Note.MAX_TEXT + 1, text.length(), "cap plus the ellipsis");
assertTrue(text.endsWith(""));
}
@Test
void cleanTextKeepsAccentsAndEmojiText() {
assertEquals("caverna após o rio", Note.cleanText("caverna após o rio"));
}
// --- place --------------------------------------------------------------
@Test
void coordsAndPlace() {
Note n = note(Note.Scope.PUBLICA, "uuid-ana", "x");
assertEquals("10, 64, -20", n.coords());
assertEquals("10, 64, -20 (Mundo normal)", n.place());
}
@Test
void placeWithoutAWorldOmitsTheParentheses() {
Note n = new Note(1, Note.Scope.PUBLICA, "ana", "id", "x", "", 1, 2, 3, 0L);
assertEquals("1, 2, 3", n.place());
}
// --- search -------------------------------------------------------------
@Test
void matchesIsCaseAndAccentInsensitive() {
Note n = note(Note.Scope.PUBLICA, "id", "Caverna após o rio");
assertTrue(n.matches("caverna"));
assertTrue(n.matches("APOS"));
assertTrue(n.matches("após"));
}
@Test
void matchesRequiresEveryTerm() {
Note n = note(Note.Scope.PUBLICA, "id", "base do caio no deserto");
assertTrue(n.matches("base deserto"));
assertFalse(n.matches("base oceano"));
}
@Test
void emptyQueryMatchesEverything() {
Note n = note(Note.Scope.PUBLICA, "id", "qualquer coisa");
assertTrue(n.matches(null));
assertTrue(n.matches(""));
assertTrue(n.matches(" "));
}
@Test
void foldStripsAccents() {
assertEquals("apos o rio", Note.fold("APÓS o rio"));
assertEquals("", Note.fold(null));
}
}
@@ -0,0 +1,257 @@
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.File;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class NotesTest {
@TempDir
Path dir;
private Notes fresh() {
return new Notes(new File(dir.toFile(), "notas.yml"));
}
private static Note add(Notes notes, Note.Scope scope, String who, String text) {
return notes.add(scope, who, "uuid-" + who, text, "Mundo normal", 1, 2, 3);
}
// --- storage ------------------------------------------------------------
@Test
void addStoresAndNumbersNotes() {
Notes notes = fresh();
Note first = add(notes, Note.Scope.PRIVADA, "ana", "minha base");
Note second = add(notes, Note.Scope.PUBLICA, "ana", "spawn");
assertEquals(1, first.id());
assertEquals(2, second.id());
assertEquals(2, notes.size());
}
@Test
void idsAreNotReusedAfterDeletion() {
// A recycled id would make "/nota ver 2" point at a different note than
// the one someone wrote down a minute ago.
Notes notes = fresh();
add(notes, Note.Scope.PRIVADA, "ana", "um");
Note second = add(notes, Note.Scope.PRIVADA, "ana", "dois");
notes.remove(second.id());
assertEquals(3, add(notes, Note.Scope.PRIVADA, "ana", "três").id());
}
@Test
void removeReportsWhetherAnythingWasRemoved() {
Notes notes = fresh();
Note note = add(notes, Note.Scope.PRIVADA, "ana", "x");
assertTrue(notes.remove(note.id()));
assertFalse(notes.remove(note.id()));
assertFalse(notes.remove(9999));
}
@Test
void byIdFindsOrReturnsNull() {
Notes notes = fresh();
Note note = add(notes, Note.Scope.PRIVADA, "ana", "x");
assertEquals(note, notes.byId(note.id()));
assertNull(notes.byId(404));
}
@Test
void perPlayerLimitIsEnforced() {
Notes notes = fresh();
for (int i = 0; i < Notes.MAX_PER_PLAYER; i++) {
assertNotNull(add(notes, Note.Scope.PRIVADA, "ana", "nota " + i));
}
assertNull(add(notes, Note.Scope.PRIVADA, "ana", "uma a mais"),
"should refuse past the cap");
// The cap is per player, not global.
assertNotNull(add(notes, Note.Scope.PRIVADA, "bia", "a minha"));
}
@Test
void countByCountsBothScopesForThatPlayerOnly() {
Notes notes = fresh();
add(notes, Note.Scope.PRIVADA, "ana", "a");
add(notes, Note.Scope.PUBLICA, "ana", "b");
add(notes, Note.Scope.PUBLICA, "bia", "c");
assertEquals(2, notes.countBy("uuid-ana"));
assertEquals(1, notes.countBy("uuid-bia"));
assertEquals(0, notes.countBy("uuid-caio"));
}
// --- visibility ---------------------------------------------------------
@Test
void visibleToHidesOtherPeoplesPrivateNotes() {
Notes notes = fresh();
add(notes, Note.Scope.PRIVADA, "ana", "segredo da ana");
add(notes, Note.Scope.PRIVADA, "bia", "segredo da bia");
add(notes, Note.Scope.PUBLICA, "bia", "aviso geral");
List<Note> forAna = notes.visibleTo("uuid-ana", null, null);
assertEquals(2, forAna.size());
for (Note note : forAna) {
assertFalse(note.text().equals("segredo da bia"));
}
}
@Test
void visibleToSortsNewestFirst() {
Notes notes = fresh();
add(notes, Note.Scope.PUBLICA, "ana", "primeira");
add(notes, Note.Scope.PUBLICA, "ana", "segunda");
assertEquals("segunda", notes.visibleTo("uuid-ana", null, null).get(0).text());
}
@Test
void visibleToFiltersByScope() {
Notes notes = fresh();
add(notes, Note.Scope.PRIVADA, "ana", "priv");
add(notes, Note.Scope.PUBLICA, "ana", "pub");
assertEquals(1, notes.visibleTo("uuid-ana", Note.Scope.PUBLICA, null).size());
assertEquals(1, notes.visibleTo("uuid-ana", Note.Scope.PRIVADA, null).size());
}
@Test
void visibleToFiltersByQuery() {
Notes notes = fresh();
add(notes, Note.Scope.PUBLICA, "ana", "caverna do diamante");
add(notes, Note.Scope.PUBLICA, "ana", "vila dos aldeões");
assertEquals(1, notes.visibleTo("uuid-ana", null, "caverna").size());
assertEquals(0, notes.visibleTo("uuid-ana", null, "oceano").size());
}
@Test
void searchNeverReachesAnotherPlayersPrivateNote() {
// Search must not become a way to probe for private text.
Notes notes = fresh();
add(notes, Note.Scope.PRIVADA, "bia", "senha do bau é 1234");
assertEquals(0, notes.visibleTo("uuid-ana", null, "senha").size());
assertEquals(1, notes.visibleTo("uuid-bia", null, "senha").size());
}
// --- the AI boundary ----------------------------------------------------
@Test
void publicSummaryNeverIncludesPrivateNotes() {
// The load-bearing privacy test: the AI call leaves this server for a
// third-party API, so a private note reaching it is a disclosure the
// author never agreed to.
Notes notes = fresh();
add(notes, Note.Scope.PRIVADA, "ana", "SEGREDO-NAO-VAZAR");
add(notes, Note.Scope.PUBLICA, "ana", "spawn fica no norte");
String summary = notes.publicSummary(10);
assertNotNull(summary);
assertTrue(summary.contains("spawn fica no norte"));
assertFalse(summary.contains("SEGREDO-NAO-VAZAR"),
"a private note must never reach the AI context");
}
@Test
void publicSummaryIsNullWithOnlyPrivateNotes() {
Notes notes = fresh();
add(notes, Note.Scope.PRIVADA, "ana", "só minha");
assertNull(notes.publicSummary(10));
}
@Test
void publicSummaryIsNullWhenDisabledOrEmpty() {
Notes notes = fresh();
add(notes, Note.Scope.PUBLICA, "ana", "x");
assertNull(notes.publicSummary(0), "0 must disable it");
assertNull(notes.publicSummary(-1));
}
@Test
void publicSummaryIsNullWithNoNotesAtAll() {
// Its own file: fresh() shares the @TempDir, so reusing it here would
// read back the notes the other test just wrote.
assertNull(new Notes(new File(dir.toFile(), "vazio.yml")).publicSummary(10));
}
@Test
void publicSummaryRespectsTheCapAndTakesTheNewest() {
Notes notes = fresh();
for (int i = 1; i <= 5; i++) {
add(notes, Note.Scope.PUBLICA, "ana", "nota " + i);
}
String summary = notes.publicSummary(2);
assertTrue(summary.contains("nota 5"));
assertTrue(summary.contains("nota 4"));
assertFalse(summary.contains("nota 1"));
assertEquals(2, summary.lines().count());
}
@Test
void formatNamesTheAuthorAndThePlace() {
String text = Notes.format(List.of(
new Note(1, Note.Scope.PUBLICA, "ana", "id", "base aqui", "Nether", 5, 6, 7, 0L)));
assertEquals("- base aqui (anotado por ana em 5, 6, 7 (Nether))", text);
}
@Test
void formatOfNothingIsNull() {
assertNull(Notes.format(List.of()));
assertNull(Notes.format(null));
}
// --- persistence --------------------------------------------------------
@Test
void notesSurviveAReload() {
File file = new File(dir.toFile(), "notas.yml");
Notes first = new Notes(file);
first.add(Note.Scope.PUBLICA, "ana", "uuid-ana", "base do norte",
"Mundo normal", 10, 64, -20);
first.add(Note.Scope.PRIVADA, "bia", "uuid-bia", "meu esconderijo",
"Nether", 1, 2, 3);
Notes reloaded = new Notes(file);
assertEquals(2, reloaded.size());
Note pub = reloaded.byId(1);
assertEquals("base do norte", pub.text());
assertEquals(Note.Scope.PUBLICA, pub.scope());
assertEquals("ana", pub.author());
assertEquals("Mundo normal", pub.world());
assertEquals(10, pub.x());
assertEquals(-20, pub.z());
// Scope must survive the round trip, or a private note would come back
// public after a restart.
assertEquals(Note.Scope.PRIVADA, reloaded.byId(2).scope());
}
@Test
void idsKeepCountingAfterAReload() {
File file = new File(dir.toFile(), "notas.yml");
Notes first = new Notes(file);
first.add(Note.Scope.PUBLICA, "ana", "uuid-ana", "a", "w", 0, 0, 0);
first.add(Note.Scope.PUBLICA, "ana", "uuid-ana", "b", "w", 0, 0, 0);
Notes reloaded = new Notes(file);
assertEquals(3, reloaded.add(Note.Scope.PUBLICA, "ana", "uuid-ana", "c", "w", 0, 0, 0).id());
}
@Test
void deletionSurvivesAReload() {
File file = new File(dir.toFile(), "notas.yml");
Notes first = new Notes(file);
Note note = first.add(Note.Scope.PUBLICA, "ana", "uuid-ana", "some", "w", 0, 0, 0);
first.remove(note.id());
assertEquals(0, new Notes(file).size());
}
@Test
void aMissingFileLoadsAsEmpty() {
assertEquals(0, new Notes(new File(dir.toFile(), "nao-existe.yml")).size());
}
}