Add chat notes: /save and /nota, public and private

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 <texto>
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 <texto> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
This commit is contained in:
marcos
2026-08-07 23:46:55 +00:00
parent 5653836262
commit bc711a48c2
12 changed files with 1186 additions and 4 deletions
+16 -3
View File
@@ -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
@@ -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. "
@@ -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. */
@@ -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 <texto>" pins
// it with a note. Private by default — the safe default for a
// one-word command nobody reads the help for first.
case "save" -> saveShortcut(sender, args);
// Typed twin of the [F] mourning button — "f" is not a configured
// 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 <sub>} — the full note interface.
*
* <p>Two scopes. A <b>private</b> note is visible only to its author and
* everyone may write one ({@code canalhandia.nota}, default true). A
* <b>public</b> note is broadcast and readable by all, and writing one needs
* {@code canalhandia.nota.publica} (default op), so public notes stay a
* curated board rather than a graffiti wall.
*/
private boolean nota(CommandSender sender, String[] args) {
if (!plugin.settings().moduleEnabled(Module.NOTAS)) {
Msg.error(sender, "O módulo de anotações está desligado.");
return true;
}
if (!sender.hasPermission("canalhandia.nota")) {
return denied(sender);
}
if (args.length == 0) {
return notaHelp(sender);
}
String[] rest = Arrays.copyOfRange(args, 1, args.length);
switch (args[0].toLowerCase(Locale.ROOT)) {
case "add", "adicionar", "nova" -> notaAdd(sender, rest, Note.Scope.PRIVADA);
case "publica", "publico" -> notaAdd(sender, rest, Note.Scope.PUBLICA);
case "privada", "privado" -> notaAdd(sender, rest, Note.Scope.PRIVADA);
case "listar", "lista", "ls" -> notaList(sender, rest);
case "ver" -> notaShow(sender, rest);
case "buscar", "procurar" -> notaSearch(sender, rest);
case "remover", "apagar", "rm" -> notaRemove(sender, rest);
default -> notaHelp(sender);
}
return true;
}
/**
* {@code /save} — pin the spot you are standing on, privately.
*
* <p>{@code /save} and {@code /save coords} store the location with a
* generated label; {@code /save <texto>} stores it with that text. Private
* on purpose: this is the command someone types without reading help first,
* and the safe default for that is the one that cannot surprise anyone by
* broadcasting. {@code /nota publica <texto>} is the explicit way to share.
*/
private boolean saveShortcut(CommandSender sender, String[] args) {
if (!plugin.settings().moduleEnabled(Module.NOTAS)) {
Msg.error(sender, "O módulo de anotações está desligado.");
return true;
}
if (!sender.hasPermission("canalhandia.nota")) {
return denied(sender);
}
if (!(sender instanceof Player player)) {
Msg.error(sender, "Só jogadores podem anotar (a anotação guarda onde você está).");
return true;
}
// "/save" and "/save coords" mean the same thing: just the place. The
// word is accepted so the command is discoverable ("/save coords" is
// what people guess) without becoming a note whose text is "coords".
boolean placeOnly = args.length == 0
|| (args.length == 1 && args[0].equalsIgnoreCase("coords"));
String text = placeOnly
? "Local salvo em " + ServerState.worldLabel(player.getWorld())
: String.join(" ", args);
store(player, Note.Scope.PRIVADA, text);
return true;
}
private void notaAdd(CommandSender sender, String[] args, Note.Scope scope) {
if (!(sender instanceof Player player)) {
Msg.error(sender, "Só jogadores podem anotar (a anotação guarda onde você está).");
return;
}
if (scope == Note.Scope.PUBLICA && !sender.hasPermission("canalhandia.nota.publica")) {
Msg.error(sender, "Você não pode criar anotações públicas. Use /nota add <texto> "
+ "para uma anotação só sua.");
return;
}
if (args.length == 0) {
Msg.error(sender, "Uso: /nota " + scope.key() + " <texto>");
return;
}
store(player, scope, String.join(" ", args));
}
/** Shared tail of every create path: clean, store, confirm, broadcast. */
private void store(Player player, Note.Scope scope, String rawText) {
String text = Note.cleanText(rawText);
if (text == null) {
Msg.error(player, "A anotação está vazia.");
return;
}
Location at = player.getLocation();
Note note = plugin.notes().add(scope, player.getName(), player.getUniqueId().toString(),
text, ServerState.worldLabel(player.getWorld()),
at.getBlockX(), at.getBlockY(), at.getBlockZ());
if (note == null) {
Msg.error(player, "Você já tem " + Notes.MAX_PER_PLAYER
+ " anotações. Apague alguma com /nota remover <n>.");
return;
}
Msg.ok(player, "Anotação #" + note.id() + " salva (" + scope.label() + ") em "
+ note.place() + ".");
if (scope == Note.Scope.PUBLICA) {
// Public notes are announced, because a board nobody is told about
// is a board nobody reads.
plugin.broadcastPerPlatform(bedrock -> Msg.tag("Nota", NamedTextColor.GREEN)
.append(Component.text(player.getName() + ": ", NamedTextColor.GRAY))
.append(noteBody(note, bedrock)));
} else {
player.sendMessage(Msg.tag("Nota", NamedTextColor.GREEN).append(noteBody(note, false)));
}
}
private void notaList(CommandSender sender, String[] args) {
Note.Scope scope = args.length > 0 ? Note.Scope.byKey(args[0]) : null;
if (args.length > 0 && scope == null) {
Msg.error(sender, "Uso: /nota listar [publicas|privadas]");
return;
}
show(sender, plugin.notes().visibleTo(viewerId(sender), scope, null),
scope == null ? "Suas anotações e as públicas" : "Anotações " + scope.key() + "s");
}
private void notaSearch(CommandSender sender, String[] args) {
if (args.length == 0) {
Msg.error(sender, "Uso: /nota buscar <texto>");
return;
}
String query = String.join(" ", args);
show(sender, plugin.notes().visibleTo(viewerId(sender), null, query),
"Anotações com \"" + query + "\"");
}
private void notaShow(CommandSender sender, String[] args) {
Note note = args.length == 0 ? null : plugin.notes().byId(parseLong(args[0]));
// A note the viewer cannot see is reported as missing rather than as
// forbidden: saying "that one is private" would confirm it exists, which
// is itself a leak about someone else's note.
if (note == null || !note.visibleTo(viewerId(sender))) {
Msg.error(sender, "Anotação não encontrada.");
return;
}
Msg.header(sender, "Anotação #" + note.id());
sender.sendMessage(noteBody(note, isBedrock(sender)));
Msg.line(sender, "autor", note.author());
Msg.line(sender, "escopo", note.scope().key() + " (" + note.scope().label() + ")");
Msg.line(sender, "lugar", note.place());
}
private void notaRemove(CommandSender sender, String[] args) {
if (args.length == 0) {
Msg.error(sender, "Uso: /nota remover <n>");
return;
}
Note note = plugin.notes().byId(parseLong(args[0]));
if (note == null || !note.visibleTo(viewerId(sender))) {
Msg.error(sender, "Anotação não encontrada.");
return;
}
if (!note.deletableBy(viewerId(sender), sender.hasPermission(ADMIN))) {
Msg.error(sender, "Essa anotação é de " + note.author() + ".");
return;
}
plugin.notes().remove(note.id());
Msg.ok(sender, "Anotação #" + note.id() + " apagada.");
}
private void show(CommandSender sender, List<Note> notes, String title) {
if (notes.isEmpty()) {
Msg.error(sender, "Nenhuma anotação.");
return;
}
Msg.header(sender, title + " (" + notes.size() + ")");
boolean bedrock = isBedrock(sender);
// Capped so a long list cannot push everything else out of the chat
// window; the rest are reachable with /nota buscar.
int shown = Math.min(notes.size(), 10);
for (int i = 0; i < shown; i++) {
sender.sendMessage(noteBody(notes.get(i), bedrock));
}
if (notes.size() > shown) {
Msg.line(sender, "", "e mais " + (notes.size() - shown)
+ ". Use /nota buscar <texto> para filtrar.");
}
}
/**
* One note as a chat line: id, scope colour, text, and the place.
*
* <p>On Java the coordinates are click-to-copy, the same affordance the
* death-coords message uses. Bedrock renders no click event, so it gets the
* same text plainly rather than a dead link.
*/
private Component noteBody(Note note, boolean bedrock) {
NamedTextColor colour = note.scope() == Note.Scope.PUBLICA
? NamedTextColor.GREEN : NamedTextColor.LIGHT_PURPLE;
Component place = Component.text(note.place(), NamedTextColor.GRAY);
if (!bedrock) {
place = place.clickEvent(ClickEvent.copyToClipboard(note.coords()))
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
Component.text("Clique para copiar as coordenadas",
NamedTextColor.DARK_GRAY)));
}
return Component.text(" #" + note.id() + " ", colour)
.append(Component.text(note.text(), NamedTextColor.WHITE))
.append(Component.text("", NamedTextColor.DARK_GRAY))
.append(place);
}
private boolean notaHelp(CommandSender sender) {
Msg.header(sender, "Anotações");
Map<String, String> commands = new LinkedHashMap<>();
commands.put("/save", "salva onde você está (privado)");
commands.put("/save coords", "o mesmo, escrito por extenso");
commands.put("/save <texto>", "salva o lugar com um texto");
commands.put("/nota add <texto>", "anotação privada, só você vê");
if (sender.hasPermission("canalhandia.nota.publica")) {
commands.put("/nota publica <texto>", "anotação pública, todos veem");
}
commands.put("/nota listar [publicas|privadas]", "lista o que você pode ver");
commands.put("/nota buscar <texto>", "procura no texto das anotações");
commands.put("/nota ver <n>", "mostra uma anotação inteira");
commands.put("/nota remover <n>", "apaga uma anotação sua");
commands.forEach((cmd, description) -> sender.sendMessage(
Component.text(" " + cmd, NamedTextColor.AQUA)
.append(Component.text("" + description, NamedTextColor.GRAY))));
return true;
}
/** The viewer's UUID as stored on notes, or null for the console. */
private String viewerId(CommandSender sender) {
return sender instanceof Player player ? player.getUniqueId().toString() : null;
}
private boolean isBedrock(CommandSender sender) {
return sender instanceof Player player && Platform.isBedrock(player);
}
/** Parses a note id, returning -1 (never a valid id) on junk input. */
private long parseLong(String raw) {
try {
return Long.parseLong(raw.trim().replace("#", ""));
} catch (NumberFormatException e) {
return -1;
}
}
private void iaCorrect(CommandSender sender, String[] args) {
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<String> options = new ArrayList<>(
List.of("add", "listar", "buscar", "ver", "remover"));
if (sender.hasPermission("canalhandia.nota.publica")) {
options.add("publica");
}
return filter(options, args[0]);
}
if (args.length == 2 && args[0].equalsIgnoreCase("listar")) {
return filter(List.of("publicas", "privadas"), args[1]);
}
// Completing note ids for ver/remover: only the ones this player is
// allowed to see, so completion cannot enumerate someone else's.
if (args.length == 2
&& (args[0].equalsIgnoreCase("ver") || args[0].equalsIgnoreCase("remover"))) {
List<String> ids = new ArrayList<>();
for (Note note : plugin.notes().visibleTo(viewerId(sender), null, null)) {
ids.add(String.valueOf(note.id()));
}
return filter(ids, args[1]);
}
return List.of();
}
if (name.equals("save")) {
return args.length == 1 ? filter(List.of("coords"), args[0]) : List.of();
}
if (name.equals("ia") || name.equals("iap")) {
// Only the tuning subcommands are suggested — the rest of /ia is
// free text, and completing a question would be noise.
@@ -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;
@@ -0,0 +1,163 @@
package dev.marcospaulo.canalhandia;
import java.util.Locale;
/**
* One note: a line of text a player pinned somewhere in the world.
*
* <p>A plain immutable record with no Bukkit types, so the whole model — text
* limits, visibility rules, coordinate formatting — is testable without a
* server. {@link Notes} owns storage; this owns what a note <em>is</em>.
*
* <p>The coordinates are part of the note rather than optional metadata,
* because on a Minecraft server a note is nearly always about a <em>place</em>:
* where the base is, where the mob spawner was found, where someone left a
* chest. A note without them would answer the wrong half of the question.
*/
record Note(long id, Scope scope, String author, String authorId, String text,
String world, int x, int y, int z, long createdAt) {
/** Who can see a note. */
enum Scope {
/**
* Only the author. Never broadcast, never listed to anyone else — and
* deliberately never sent to the AI, because a private note is personal
* text and the AI call leaves the server.
*/
PRIVADA("privada", "só você vê"),
/**
* Everyone can read. Creating one needs a permission, so public notes
* do not become a graffiti wall.
*/
PUBLICA("publica", "todos veem");
private final String key;
private final String label;
Scope(String key, String label) {
this.key = key;
this.label = label;
}
String key() {
return key;
}
String label() {
return label;
}
static Scope byKey(String key) {
if (key == null) {
return null;
}
String wanted = key.trim().toLowerCase(Locale.ROOT);
// The masculine forms are accepted too: people type "publico" as
// often as "publica", and rejecting it reads as a bug. Spelled out
// rather than derived, because a blanket a→o rewrite turns
// "privada" into "privodo".
if (wanted.equals("publico") || wanted.equals("publicas") || wanted.equals("publicos")) {
return PUBLICA;
}
if (wanted.equals("privado") || wanted.equals("privadas") || wanted.equals("privados")) {
return PRIVADA;
}
for (Scope scope : values()) {
if (scope.key.equals(wanted)
|| scope.name().toLowerCase(Locale.ROOT).equals(wanted)) {
return scope;
}
}
return null;
}
static boolean isValid(String key) {
return byKey(key) != null;
}
}
/**
* Longest note text kept. Long enough for a real sentence, short enough
* that one note cannot flood chat when a list is printed.
*/
static final int MAX_TEXT = 256;
/**
* Trims and caps note text, returning {@code null} when there is nothing
* usable left.
*
* <p>Control characters and the section sign go: a note is echoed back into
* chat, and a note containing colour codes could otherwise forge a line that
* looks like it came from the server.
*/
static String cleanText(String raw) {
if (raw == null) {
return null;
}
StringBuilder out = new StringBuilder(raw.length());
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
if (c == '§' || Character.isISOControl(c)) {
continue;
}
out.append(c);
}
String text = out.toString().strip();
if (text.isEmpty()) {
return null;
}
return text.length() > MAX_TEXT ? text.substring(0, MAX_TEXT).strip() + "" : text;
}
/** "10, 64, -20 (Mundo normal)" — the form used for click-to-copy. */
String coords() {
return x + ", " + y + ", " + z;
}
String place() {
return coords() + (world == null || world.isBlank() ? "" : " (" + world + ")");
}
/** True if {@code viewerId} is allowed to read this note. */
boolean visibleTo(String viewerId) {
return scope == Scope.PUBLICA || (authorId != null && authorId.equals(viewerId));
}
/**
* True if {@code viewerId} may delete this note. Authors delete their own;
* an admin deletes any, which is the only way to clear a public note left
* by someone who has since stopped playing.
*/
boolean deletableBy(String viewerId, boolean admin) {
return admin || (authorId != null && authorId.equals(viewerId));
}
/**
* True if the note's text contains every one of the search terms, case- and
* accent-insensitively. Accent folding matters: nobody types "após" into a
* chat search, and a search that misses because of a missing acute reads as
* broken.
*/
boolean matches(String query) {
if (query == null || query.isBlank()) {
return true;
}
String haystack = fold(text);
for (String term : query.trim().split("\\s+")) {
if (!haystack.contains(fold(term))) {
return false;
}
}
return true;
}
/** Lowercase with the combining accents stripped. */
static String fold(String text) {
if (text == null) {
return "";
}
return java.text.Normalizer.normalize(text, java.text.Normalizer.Form.NFD)
.replaceAll("\\p{M}+", "")
.toLowerCase(Locale.ROOT);
}
}
@@ -0,0 +1,231 @@
package dev.marcospaulo.canalhandia;
import org.bukkit.configuration.file.YamlConfiguration;
import java.io.File;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* Storage for {@link Note}s, persisted to {@code notas.yml}.
*
* <p>Follows {@link Corrections}: an in-memory list guarded by its own monitor,
* rewritten to YAML on every change. Notes are written from chat commands on the
* main thread and read from there too, but the lock costs nothing and keeps the
* class safe if a future caller reads from the async AI path — which
* {@link #publicSummary} is built for.
*
* <p>Rewriting the whole file per change is deliberate. Notes are typed by hand,
* so the file stays small, and a full rewrite cannot leave a half-updated file
* behind the way an append-and-patch scheme can.
*/
final class Notes {
/**
* A hard ceiling per player, so one person cannot grow the file without
* bound. Generous enough that nobody legitimately writing notes will hit it.
*/
static final int MAX_PER_PLAYER = 100;
private final File file;
private final List<Note> notes = new ArrayList<>();
/** Monotonic id, so a note keeps its number even after others are deleted. */
private long nextId = 1;
Notes(File file) {
this.file = file;
load();
}
void load() {
YamlConfiguration yaml = file.exists() ? YamlConfiguration.loadConfiguration(file) : null;
synchronized (notes) {
notes.clear();
nextId = 1;
if (yaml == null) {
return;
}
for (String key : yaml.getKeys(false)) {
String text = yaml.getString(key + ".texto");
String authorId = yaml.getString(key + ".autor-id");
if (text == null || authorId == null) {
continue;
}
Note.Scope scope = Note.Scope.byKey(yaml.getString(key + ".escopo"));
long id = yaml.getLong(key + ".id", 0);
Note note = new Note(
id,
scope == null ? Note.Scope.PRIVADA : scope,
yaml.getString(key + ".autor", "?"),
authorId,
text,
yaml.getString(key + ".mundo", ""),
yaml.getInt(key + ".x"),
yaml.getInt(key + ".y"),
yaml.getInt(key + ".z"),
yaml.getLong(key + ".em", 0));
notes.add(note);
nextId = Math.max(nextId, id + 1);
}
}
}
/**
* Stores a note and returns it, or {@code null} when the author is already
* at {@link #MAX_PER_PLAYER}.
*
* <p>The caller has already cleaned the text with {@link Note#cleanText}.
*/
Note add(Note.Scope scope, String author, String authorId, String text,
String world, int x, int y, int z) {
Note note;
synchronized (notes) {
if (countBy(authorId) >= MAX_PER_PLAYER) {
return null;
}
note = new Note(nextId++, scope, author, authorId, text, world, x, y, z,
System.currentTimeMillis());
notes.add(note);
}
save();
return note;
}
/** Removes a note by id. False if there was no such note. */
boolean remove(long id) {
boolean removed;
synchronized (notes) {
removed = notes.removeIf(note -> note.id() == id);
}
if (removed) {
save();
}
return removed;
}
/** The note with this id, or null. */
Note byId(long id) {
synchronized (notes) {
for (Note note : notes) {
if (note.id() == id) {
return note;
}
}
}
return null;
}
/**
* Every note {@code viewerId} may read, newest first, optionally filtered by
* scope and by a text query.
*
* @param scope null for both scopes
*/
List<Note> visibleTo(String viewerId, Note.Scope scope, String query) {
List<Note> out = new ArrayList<>();
synchronized (notes) {
for (Note note : notes) {
if (!note.visibleTo(viewerId)) {
continue;
}
if (scope != null && note.scope() != scope) {
continue;
}
if (!note.matches(query)) {
continue;
}
out.add(note);
}
}
out.sort(Comparator.comparingLong(Note::id).reversed());
return out;
}
/** How many notes this player has stored, both scopes. */
int countBy(String authorId) {
int count = 0;
synchronized (notes) {
for (Note note : notes) {
if (note.authorId() != null && note.authorId().equals(authorId)) {
count++;
}
}
}
return count;
}
int size() {
synchronized (notes) {
return notes.size();
}
}
/**
* Public notes rendered for the AI's context, newest first, or {@code null}
* when there are none.
*
* <p><b>Public only, never private.</b> A private note is personal text and
* the AI call leaves this server for a third-party API; sending one there
* would be a disclosure the author never agreed to. The filter is here, in
* the only method the AI path calls, rather than at the call site, so a
* future caller cannot get it wrong by accident.
*/
String publicSummary(int max) {
if (max <= 0) {
return null;
}
List<Note> out = new ArrayList<>();
synchronized (notes) {
for (Note note : notes) {
if (note.scope() == Note.Scope.PUBLICA) {
out.add(note);
}
}
}
if (out.isEmpty()) {
return null;
}
out.sort(Comparator.comparingLong(Note::id).reversed());
return format(out.subList(0, Math.min(max, out.size())));
}
/** Pure rendering of a note list for the AI, so the text is testable. */
static String format(List<Note> notes) {
if (notes == null || notes.isEmpty()) {
return null;
}
StringBuilder out = new StringBuilder();
for (Note note : notes) {
out.append("- ").append(note.text())
.append(" (anotado por ").append(note.author())
.append(" em ").append(note.place()).append(")\n");
}
return out.toString().strip();
}
private void save() {
YamlConfiguration yaml = new YamlConfiguration();
synchronized (notes) {
for (int i = 0; i < notes.size(); i++) {
Note note = notes.get(i);
String key = "n" + i;
yaml.set(key + ".id", note.id());
yaml.set(key + ".escopo", note.scope().key());
yaml.set(key + ".autor", note.author());
yaml.set(key + ".autor-id", note.authorId());
yaml.set(key + ".texto", note.text());
yaml.set(key + ".mundo", note.world());
yaml.set(key + ".x", note.x());
yaml.set(key + ".y", note.y());
yaml.set(key + ".z", note.z());
yaml.set(key + ".em", note.createdAt());
}
}
try {
yaml.save(file);
} catch (Exception e) {
throw new IllegalStateException("não consegui gravar " + file, e);
}
}
}
@@ -397,6 +397,22 @@ final class Settings {
set("ia.estilo-rico", value);
}
/**
* How many <b>public</b> notes are sent to the AI as context, so it can
* answer "onde fica a base?" from what players actually wrote down. Zero
* disables it.
*
* <p>Private notes are never sent, at any setting: {@link Notes#publicSummary}
* filters them out at the source. See the note there for why.
*/
int aiNotes() {
return Math.max(0, Math.min(50, plugin.getConfig().getInt("ia.contexto-notas", 10)));
}
void aiNotes(int max) {
set("ia.contexto-notas", Math.max(0, Math.min(50, max)));
}
// --- zoacao (f-gag) -----------------------------------------------------
/**
+10
View File
@@ -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 <pergunta> — 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 <nome>.
+14
View File
@@ -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|<texto>]
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
@@ -0,0 +1,171 @@
package dev.marcospaulo.canalhandia;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class NoteTest {
private static Note note(Note.Scope scope, String authorId, String text) {
return new Note(1, scope, "ana", authorId, text, "Mundo normal", 10, 64, -20, 0L);
}
// --- Scope --------------------------------------------------------------
@Test
void scopeByKeyParsesBothForms() {
assertEquals(Note.Scope.PUBLICA, Note.Scope.byKey("publica"));
assertEquals(Note.Scope.PRIVADA, Note.Scope.byKey("privada"));
// People type the masculine form as often as the feminine one.
assertEquals(Note.Scope.PUBLICA, Note.Scope.byKey("publico"));
assertEquals(Note.Scope.PRIVADA, Note.Scope.byKey("privado"));
}
@Test
void scopeByKeyParsesThePluralsTabCompletionSuggests() {
// "/nota listar publicas" is exactly what the completion offers, so the
// plural has to parse or the suggested command fails.
assertEquals(Note.Scope.PUBLICA, Note.Scope.byKey("publicas"));
assertEquals(Note.Scope.PRIVADA, Note.Scope.byKey("privadas"));
assertEquals(Note.Scope.PUBLICA, Note.Scope.byKey("publicos"));
assertEquals(Note.Scope.PRIVADA, Note.Scope.byKey("privados"));
}
@Test
void scopeByKeyIsCaseInsensitiveAndTrims() {
assertEquals(Note.Scope.PUBLICA, Note.Scope.byKey(" PUBLICA "));
}
@Test
void scopeByKeyRejectsJunk() {
assertNull(Note.Scope.byKey("secreta"));
assertNull(Note.Scope.byKey(""));
assertNull(Note.Scope.byKey(null));
assertFalse(Note.Scope.isValid("secreta"));
}
// --- visibility ---------------------------------------------------------
@Test
void privateNoteIsVisibleOnlyToItsAuthor() {
Note n = note(Note.Scope.PRIVADA, "uuid-ana", "minha base");
assertTrue(n.visibleTo("uuid-ana"));
assertFalse(n.visibleTo("uuid-bia"));
assertFalse(n.visibleTo(null), "the console must not read private notes");
}
@Test
void publicNoteIsVisibleToEveryone() {
Note n = note(Note.Scope.PUBLICA, "uuid-ana", "spawn fica aqui");
assertTrue(n.visibleTo("uuid-ana"));
assertTrue(n.visibleTo("uuid-bia"));
assertTrue(n.visibleTo(null));
}
@Test
void aNoteWithNoAuthorIdIsNotPrivatelyVisible() {
// Corrupt/hand-edited YAML must fail closed, not open.
Note n = new Note(1, Note.Scope.PRIVADA, "ana", null, "x", "w", 0, 0, 0, 0L);
assertFalse(n.visibleTo("uuid-ana"));
assertFalse(n.visibleTo(null));
}
// --- deletion -----------------------------------------------------------
@Test
void authorCanDeleteTheirOwn() {
Note n = note(Note.Scope.PUBLICA, "uuid-ana", "x");
assertTrue(n.deletableBy("uuid-ana", false));
assertFalse(n.deletableBy("uuid-bia", false));
}
@Test
void adminCanDeleteAnyone() {
Note n = note(Note.Scope.PUBLICA, "uuid-ana", "x");
assertTrue(n.deletableBy("uuid-bia", true));
}
// --- text cleaning ------------------------------------------------------
@Test
void cleanTextTrims() {
assertEquals("base do caio", Note.cleanText(" base do caio "));
}
@Test
void cleanTextRejectsEmpty() {
assertNull(Note.cleanText(null));
assertNull(Note.cleanText(""));
assertNull(Note.cleanText(" "));
assertNull(Note.cleanText("\n\t"));
}
@Test
void cleanTextStripsColourCodesAndControls() {
// A note is echoed into chat; colour codes would let one forge a line
// that looks like it came from the server.
assertEquals("cSERVIDOR: banido", Note.cleanText("§cSERVIDOR: banido"));
assertEquals("uma linha só", Note.cleanText("uma linha"));
}
@Test
void cleanTextCapsLongInput() {
String text = Note.cleanText("x".repeat(Note.MAX_TEXT + 100));
assertEquals(Note.MAX_TEXT + 1, text.length(), "cap plus the ellipsis");
assertTrue(text.endsWith(""));
}
@Test
void cleanTextKeepsAccentsAndEmojiText() {
assertEquals("caverna após o rio", Note.cleanText("caverna após o rio"));
}
// --- place --------------------------------------------------------------
@Test
void coordsAndPlace() {
Note n = note(Note.Scope.PUBLICA, "uuid-ana", "x");
assertEquals("10, 64, -20", n.coords());
assertEquals("10, 64, -20 (Mundo normal)", n.place());
}
@Test
void placeWithoutAWorldOmitsTheParentheses() {
Note n = new Note(1, Note.Scope.PUBLICA, "ana", "id", "x", "", 1, 2, 3, 0L);
assertEquals("1, 2, 3", n.place());
}
// --- search -------------------------------------------------------------
@Test
void matchesIsCaseAndAccentInsensitive() {
Note n = note(Note.Scope.PUBLICA, "id", "Caverna após o rio");
assertTrue(n.matches("caverna"));
assertTrue(n.matches("APOS"));
assertTrue(n.matches("após"));
}
@Test
void matchesRequiresEveryTerm() {
Note n = note(Note.Scope.PUBLICA, "id", "base do caio no deserto");
assertTrue(n.matches("base deserto"));
assertFalse(n.matches("base oceano"));
}
@Test
void emptyQueryMatchesEverything() {
Note n = note(Note.Scope.PUBLICA, "id", "qualquer coisa");
assertTrue(n.matches(null));
assertTrue(n.matches(""));
assertTrue(n.matches(" "));
}
@Test
void foldStripsAccents() {
assertEquals("apos o rio", Note.fold("APÓS o rio"));
assertEquals("", Note.fold(null));
}
}
@@ -0,0 +1,257 @@
package dev.marcospaulo.canalhandia;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.File;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class NotesTest {
@TempDir
Path dir;
private Notes fresh() {
return new Notes(new File(dir.toFile(), "notas.yml"));
}
private static Note add(Notes notes, Note.Scope scope, String who, String text) {
return notes.add(scope, who, "uuid-" + who, text, "Mundo normal", 1, 2, 3);
}
// --- storage ------------------------------------------------------------
@Test
void addStoresAndNumbersNotes() {
Notes notes = fresh();
Note first = add(notes, Note.Scope.PRIVADA, "ana", "minha base");
Note second = add(notes, Note.Scope.PUBLICA, "ana", "spawn");
assertEquals(1, first.id());
assertEquals(2, second.id());
assertEquals(2, notes.size());
}
@Test
void idsAreNotReusedAfterDeletion() {
// A recycled id would make "/nota ver 2" point at a different note than
// the one someone wrote down a minute ago.
Notes notes = fresh();
add(notes, Note.Scope.PRIVADA, "ana", "um");
Note second = add(notes, Note.Scope.PRIVADA, "ana", "dois");
notes.remove(second.id());
assertEquals(3, add(notes, Note.Scope.PRIVADA, "ana", "três").id());
}
@Test
void removeReportsWhetherAnythingWasRemoved() {
Notes notes = fresh();
Note note = add(notes, Note.Scope.PRIVADA, "ana", "x");
assertTrue(notes.remove(note.id()));
assertFalse(notes.remove(note.id()));
assertFalse(notes.remove(9999));
}
@Test
void byIdFindsOrReturnsNull() {
Notes notes = fresh();
Note note = add(notes, Note.Scope.PRIVADA, "ana", "x");
assertEquals(note, notes.byId(note.id()));
assertNull(notes.byId(404));
}
@Test
void perPlayerLimitIsEnforced() {
Notes notes = fresh();
for (int i = 0; i < Notes.MAX_PER_PLAYER; i++) {
assertNotNull(add(notes, Note.Scope.PRIVADA, "ana", "nota " + i));
}
assertNull(add(notes, Note.Scope.PRIVADA, "ana", "uma a mais"),
"should refuse past the cap");
// The cap is per player, not global.
assertNotNull(add(notes, Note.Scope.PRIVADA, "bia", "a minha"));
}
@Test
void countByCountsBothScopesForThatPlayerOnly() {
Notes notes = fresh();
add(notes, Note.Scope.PRIVADA, "ana", "a");
add(notes, Note.Scope.PUBLICA, "ana", "b");
add(notes, Note.Scope.PUBLICA, "bia", "c");
assertEquals(2, notes.countBy("uuid-ana"));
assertEquals(1, notes.countBy("uuid-bia"));
assertEquals(0, notes.countBy("uuid-caio"));
}
// --- visibility ---------------------------------------------------------
@Test
void visibleToHidesOtherPeoplesPrivateNotes() {
Notes notes = fresh();
add(notes, Note.Scope.PRIVADA, "ana", "segredo da ana");
add(notes, Note.Scope.PRIVADA, "bia", "segredo da bia");
add(notes, Note.Scope.PUBLICA, "bia", "aviso geral");
List<Note> forAna = notes.visibleTo("uuid-ana", null, null);
assertEquals(2, forAna.size());
for (Note note : forAna) {
assertFalse(note.text().equals("segredo da bia"));
}
}
@Test
void visibleToSortsNewestFirst() {
Notes notes = fresh();
add(notes, Note.Scope.PUBLICA, "ana", "primeira");
add(notes, Note.Scope.PUBLICA, "ana", "segunda");
assertEquals("segunda", notes.visibleTo("uuid-ana", null, null).get(0).text());
}
@Test
void visibleToFiltersByScope() {
Notes notes = fresh();
add(notes, Note.Scope.PRIVADA, "ana", "priv");
add(notes, Note.Scope.PUBLICA, "ana", "pub");
assertEquals(1, notes.visibleTo("uuid-ana", Note.Scope.PUBLICA, null).size());
assertEquals(1, notes.visibleTo("uuid-ana", Note.Scope.PRIVADA, null).size());
}
@Test
void visibleToFiltersByQuery() {
Notes notes = fresh();
add(notes, Note.Scope.PUBLICA, "ana", "caverna do diamante");
add(notes, Note.Scope.PUBLICA, "ana", "vila dos aldeões");
assertEquals(1, notes.visibleTo("uuid-ana", null, "caverna").size());
assertEquals(0, notes.visibleTo("uuid-ana", null, "oceano").size());
}
@Test
void searchNeverReachesAnotherPlayersPrivateNote() {
// Search must not become a way to probe for private text.
Notes notes = fresh();
add(notes, Note.Scope.PRIVADA, "bia", "senha do bau é 1234");
assertEquals(0, notes.visibleTo("uuid-ana", null, "senha").size());
assertEquals(1, notes.visibleTo("uuid-bia", null, "senha").size());
}
// --- the AI boundary ----------------------------------------------------
@Test
void publicSummaryNeverIncludesPrivateNotes() {
// The load-bearing privacy test: the AI call leaves this server for a
// third-party API, so a private note reaching it is a disclosure the
// author never agreed to.
Notes notes = fresh();
add(notes, Note.Scope.PRIVADA, "ana", "SEGREDO-NAO-VAZAR");
add(notes, Note.Scope.PUBLICA, "ana", "spawn fica no norte");
String summary = notes.publicSummary(10);
assertNotNull(summary);
assertTrue(summary.contains("spawn fica no norte"));
assertFalse(summary.contains("SEGREDO-NAO-VAZAR"),
"a private note must never reach the AI context");
}
@Test
void publicSummaryIsNullWithOnlyPrivateNotes() {
Notes notes = fresh();
add(notes, Note.Scope.PRIVADA, "ana", "só minha");
assertNull(notes.publicSummary(10));
}
@Test
void publicSummaryIsNullWhenDisabledOrEmpty() {
Notes notes = fresh();
add(notes, Note.Scope.PUBLICA, "ana", "x");
assertNull(notes.publicSummary(0), "0 must disable it");
assertNull(notes.publicSummary(-1));
}
@Test
void publicSummaryIsNullWithNoNotesAtAll() {
// Its own file: fresh() shares the @TempDir, so reusing it here would
// read back the notes the other test just wrote.
assertNull(new Notes(new File(dir.toFile(), "vazio.yml")).publicSummary(10));
}
@Test
void publicSummaryRespectsTheCapAndTakesTheNewest() {
Notes notes = fresh();
for (int i = 1; i <= 5; i++) {
add(notes, Note.Scope.PUBLICA, "ana", "nota " + i);
}
String summary = notes.publicSummary(2);
assertTrue(summary.contains("nota 5"));
assertTrue(summary.contains("nota 4"));
assertFalse(summary.contains("nota 1"));
assertEquals(2, summary.lines().count());
}
@Test
void formatNamesTheAuthorAndThePlace() {
String text = Notes.format(List.of(
new Note(1, Note.Scope.PUBLICA, "ana", "id", "base aqui", "Nether", 5, 6, 7, 0L)));
assertEquals("- base aqui (anotado por ana em 5, 6, 7 (Nether))", text);
}
@Test
void formatOfNothingIsNull() {
assertNull(Notes.format(List.of()));
assertNull(Notes.format(null));
}
// --- persistence --------------------------------------------------------
@Test
void notesSurviveAReload() {
File file = new File(dir.toFile(), "notas.yml");
Notes first = new Notes(file);
first.add(Note.Scope.PUBLICA, "ana", "uuid-ana", "base do norte",
"Mundo normal", 10, 64, -20);
first.add(Note.Scope.PRIVADA, "bia", "uuid-bia", "meu esconderijo",
"Nether", 1, 2, 3);
Notes reloaded = new Notes(file);
assertEquals(2, reloaded.size());
Note pub = reloaded.byId(1);
assertEquals("base do norte", pub.text());
assertEquals(Note.Scope.PUBLICA, pub.scope());
assertEquals("ana", pub.author());
assertEquals("Mundo normal", pub.world());
assertEquals(10, pub.x());
assertEquals(-20, pub.z());
// Scope must survive the round trip, or a private note would come back
// public after a restart.
assertEquals(Note.Scope.PRIVADA, reloaded.byId(2).scope());
}
@Test
void idsKeepCountingAfterAReload() {
File file = new File(dir.toFile(), "notas.yml");
Notes first = new Notes(file);
first.add(Note.Scope.PUBLICA, "ana", "uuid-ana", "a", "w", 0, 0, 0);
first.add(Note.Scope.PUBLICA, "ana", "uuid-ana", "b", "w", 0, 0, 0);
Notes reloaded = new Notes(file);
assertEquals(3, reloaded.add(Note.Scope.PUBLICA, "ana", "uuid-ana", "c", "w", 0, 0, 0).id());
}
@Test
void deletionSurvivesAReload() {
File file = new File(dir.toFile(), "notas.yml");
Notes first = new Notes(file);
Note note = first.add(Note.Scope.PUBLICA, "ana", "uuid-ana", "some", "w", 0, 0, 0);
first.remove(note.id());
assertEquals(0, new Notes(file).size());
}
@Test
void aMissingFileLoadsAsEmpty() {
assertEquals(0, new Notes(new File(dir.toFile(), "nao-existe.yml")).size());
}
}