From bc711a48c2ddba4f1f95748428b0090a6623a4ed Mon Sep 17 00:00:00 2001 From: marcos Date: Fri, 7 Aug 2026 23:46:55 +0000 Subject: [PATCH] Add chat notes: /save and /nota, public and private MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Players had nowhere to write anything down. Coordinates got pasted into Discord, or lost. This adds notes to chat, with two scopes. A private note is visible only to its author and everyone may write one (canalhandia.nota, default true). A public note is broadcast and readable by all, and writing one needs canalhandia.nota.publica (default op) — public notes are a curated board, not a graffiti wall. Both permissions are declared in plugin.yml: an undeclared Bukkit permission silently falls back to op-only, which would have stopped normal players writing anything. Every note stores where it was written. On a Minecraft server a note is nearly always about a place — where the base is, where the spawner was found — so coordinates are part of the model rather than optional metadata. Java players get click-to-copy on them, the same affordance the death-coords message uses; Bedrock renders no click event and gets the plain text. No teleport: the plugin does not touch gameplay. /save is the quick path. /save and /save coords pin the spot; /save pins it with a note. Private on purpose — it is the command someone types without reading help first, and the safe default there is the one that cannot surprise anyone by broadcasting. /nota publica is the explicit way to share. Private notes are never sent to the AI, at any setting. The AI call leaves this server for a third-party API, so a private note reaching it would be a disclosure the author never agreed to. The filter lives inside Notes.publicSummary — the only method the AI path calls — rather than at the call site, so a future caller cannot get it wrong by accident. Public notes are sent (ia.contexto-notas, default 10), which is what lets the AI answer "onde fica a base?" from what players actually wrote down. 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. Search runs through the same visibility filter, so it cannot become a way to probe for someone else's text. Ids are never reused after a deletion, or "/nota ver 2" would point at a different note than the one someone wrote down a minute ago. Text is stripped of the section sign and control characters, because a note is echoed into chat and could otherwise forge a line that looks like it came from the server. Scope parsing accepts the masculine and plural forms ("publico", "publicas") alongside the canonical ones. Spelled out rather than derived: a blanket a-to-o rewrite turns "privada" into "privodo", and the plural is exactly what tab completion suggests, so it has to parse or the suggested command fails. preflight.sh now checks all 18 commands and both new permissions. 218 tests, up from 176. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh --- preflight.sh | 19 +- .../java/dev/marcospaulo/canalhandia/Ai.java | 11 + .../marcospaulo/canalhandia/Canalhandia.java | 9 +- .../canalhandia/CanalhandiaCommand.java | 288 ++++++++++++++++++ .../dev/marcospaulo/canalhandia/Module.java | 1 + .../dev/marcospaulo/canalhandia/Note.java | 163 ++++++++++ .../dev/marcospaulo/canalhandia/Notes.java | 231 ++++++++++++++ .../dev/marcospaulo/canalhandia/Settings.java | 16 + src/main/resources/config.yml | 10 + src/main/resources/plugin.yml | 14 + .../dev/marcospaulo/canalhandia/NoteTest.java | 171 +++++++++++ .../marcospaulo/canalhandia/NotesTest.java | 257 ++++++++++++++++ 12 files changed, 1186 insertions(+), 4 deletions(-) create mode 100644 src/main/java/dev/marcospaulo/canalhandia/Note.java create mode 100644 src/main/java/dev/marcospaulo/canalhandia/Notes.java create mode 100644 src/test/java/dev/marcospaulo/canalhandia/NoteTest.java create mode 100644 src/test/java/dev/marcospaulo/canalhandia/NotesTest.java 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()); + } +}