i18n: per-player EN/PT via Adventure GlobalTranslator #1
+16
-3
@@ -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) -----------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>.
|
||||
|
||||
@@ -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 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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user