diff --git a/preflight.sh b/preflight.sh index 872b65a..0d673f7 100755 --- a/preflight.sh +++ b/preflight.sh @@ -49,16 +49,29 @@ else fi # Every command the code registers must exist in plugin.yml, or register() # 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="" - for cmd in canalhandia curiosidade adivinha enquete ranking reagir reacoes \ - palpite votar legal wow top f ia iap errado; do + for cmd in $CMDS; do unzip -p "$JAR" plugin.yml 2>/dev/null | grep -qE "^ ${cmd}:" || missing="$missing $cmd" done if [ -z "$missing" ]; then - pass "all 16 commands declared in plugin.yml" + pass "all $(echo $CMDS | wc -w) commands declared in plugin.yml" else bad "commands missing from plugin.yml:$missing" 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 # nothing and every setting silently falls back to the hardcoded default. if unzip -p "$JAR" config.yml >/dev/null 2>&1; then diff --git a/src/main/java/dev/marcospaulo/canalhandia/Ai.java b/src/main/java/dev/marcospaulo/canalhandia/Ai.java index df0a13e..65b6ecd 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Ai.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Ai.java @@ -350,6 +350,17 @@ final class Ai { if (liveState != null && !liveState.isBlank()) { 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()) { messages.add(new MiniMax.Turn("system", "Últimas mensagens do chat público, da mais antiga para a mais recente. " diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java index 546f1e4..bd20d63 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -65,6 +65,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { private final ChatLog chatLog = new ChatLog(); private Settings settings; + private Notes notes; private OfflineStats offlineStats; private Milestones milestones; private Ai ai; @@ -82,6 +83,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { public void onEnable() { saveDefaultConfig(); settings = new Settings(this); + notes = new Notes(new java.io.File(getDataFolder(), "notas.yml")); offlineStats = new OfflineStats(this); milestones = new Milestones(this); ai = new Ai(this); @@ -95,7 +97,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { CanalhandiaCommand root = new CanalhandiaCommand(this); for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking", "reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia", "iap", - "errado")) { + "errado", "nota", "save")) { register(name, root); } @@ -150,6 +152,11 @@ public final class Canalhandia extends JavaPlugin implements Listener { return chatLog; } + /** Player notes, public and private. Never null. */ + Notes notes() { + return notes; + } + // --- scheduling --------------------------------------------------------- /** Starts, stops or restarts the repeating curiosity task to match the mode. */ diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index de20a7f..2a2ad70 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -4,6 +4,7 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; +import org.bukkit.Location; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; @@ -49,6 +50,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { case "reacoes" -> whoReacted(sender); case "ia" -> ia(sender, args, false); case "iap" -> ia(sender, args, true); + case "nota" -> nota(sender, args); + // Quick shortcut: "/save" pins where you are, "/save " 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 // reaction (the mourning set is hardcoded), so it can't be found via // 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") + " · estatísticas " + (settings.aiPlayerStats() ? "on" : "off") + " · 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() { @@ -975,6 +987,255 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { Msg.ok(sender, "Personalidade da IA: " + persona.key() + " — " + persona.description()); } + // --- /nota and /save ---------------------------------------------------- + + /** + * {@code /nota } — the full note interface. + * + *

Two scopes. A private note is visible only to its author and + * everyone may write one ({@code canalhandia.nota}, default true). A + * public 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. + * + *

{@code /save} and {@code /save coords} store the location with a + * generated label; {@code /save } 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 } 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 " + + "para uma anotação só sua."); + return; + } + if (args.length == 0) { + Msg.error(sender, "Uso: /nota " + scope.key() + " "); + 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 ."); + 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 "); + 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 "); + 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 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 para filtrar."); + } + } + + /** + * One note as a chat line: id, scope colour, text, and the place. + * + *

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 commands = new LinkedHashMap<>(); + commands.put("/save", "salva onde você está (privado)"); + commands.put("/save coords", "o mesmo, escrito por extenso"); + commands.put("/save ", "salva o lugar com um texto"); + commands.put("/nota add ", "anotação privada, só você vê"); + if (sender.hasPermission("canalhandia.nota.publica")) { + commands.put("/nota publica ", "anotação pública, todos veem"); + } + commands.put("/nota listar [publicas|privadas]", "lista o que você pode ver"); + commands.put("/nota buscar ", "procura no texto das anotações"); + commands.put("/nota ver ", "mostra uma anotação inteira"); + commands.put("/nota remover ", "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) { if (!sender.hasPermission("canalhandia.ia.corrigir")) { denied(sender); @@ -1034,6 +1295,33 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { if (name.equals("enquete") && args.length == 1) { return filter(List.of("encerrar"), args[0]); } + if (name.equals("nota")) { + if (args.length == 1) { + List 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 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")) { // Only the tuning subcommands are suggested — the rest of /ia is // free text, and completing a question would be noise. diff --git a/src/main/java/dev/marcospaulo/canalhandia/Module.java b/src/main/java/dev/marcospaulo/canalhandia/Module.java index 3c599ed..a077c7e 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Module.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Module.java @@ -11,6 +11,7 @@ enum Module { MARCOS("marcos", "Marcos e conquistas"), MORTES("mortes", "Mortes com humor e coordenadas"), 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"); private final String key; diff --git a/src/main/java/dev/marcospaulo/canalhandia/Note.java b/src/main/java/dev/marcospaulo/canalhandia/Note.java new file mode 100644 index 0000000..13ee926 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/Note.java @@ -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. + * + *

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 is. + * + *

The coordinates are part of the note rather than optional metadata, + * because on a Minecraft server a note is nearly always about a place: + * 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. + * + *

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); + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/Notes.java b/src/main/java/dev/marcospaulo/canalhandia/Notes.java new file mode 100644 index 0000000..730e982 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/Notes.java @@ -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}. + * + *

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. + * + *

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 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}. + * + *

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 visibleTo(String viewerId, Note.Scope scope, String query) { + List 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. + * + *

Public only, never private. 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 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 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); + } + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/Settings.java b/src/main/java/dev/marcospaulo/canalhandia/Settings.java index d457f25..7367c00 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Settings.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Settings.java @@ -397,6 +397,22 @@ final class Settings { set("ia.estilo-rico", value); } + /** + * How many public 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. + * + *

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) ----------------------------------------------------- /** diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 2698ec9..eb0a5ef 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -19,6 +19,9 @@ modulos: mortes: true # Quem manda só "f" no chat (sem mais nada) leva uma zoada no lugar da mensagem. 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 — só para quem tem canalhandia.ia # --- Curiosidades ------------------------------------------------------------ @@ -232,6 +235,13 @@ ia: # nem clique. 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). # PRECISO consulta a wiki (mais lento, mais correto). Troque em jogo com # /ia perfil . diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 056cfdd..712ece6 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -67,6 +67,14 @@ commands: errado: description: Reage com "errado" à última mensagem (resposta da IA). 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|] + aliases: [anotar] permissions: # Declared explicitly: an undeclared Bukkit permission falls back to op-only, @@ -98,6 +106,12 @@ permissions: canalhandia.ia.perfil: description: Permite trocar o perfil da IA entre economico e preciso. 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: description: Quem tem isto nunca é sorteado como assunto. default: false diff --git a/src/test/java/dev/marcospaulo/canalhandia/NoteTest.java b/src/test/java/dev/marcospaulo/canalhandia/NoteTest.java new file mode 100644 index 0000000..c2b9fe3 --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/NoteTest.java @@ -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 só")); + } + + @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)); + } +} diff --git a/src/test/java/dev/marcospaulo/canalhandia/NotesTest.java b/src/test/java/dev/marcospaulo/canalhandia/NotesTest.java new file mode 100644 index 0000000..9834f68 --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/NotesTest.java @@ -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 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()); + } +}