feat(ia): add Judite & Narrador personas, per-player AI selection, persistent memory, and event reactivity #3

Merged
masi merged 3 commits from feat/ia-personalities-and-memory into main 2026-08-18 22:52:03 +00:00
3 changed files with 54 additions and 4 deletions
Showing only changes of commit 2220f11e64 - Show all commits
@@ -437,6 +437,7 @@ final class Ai {
return;
}
String clean = String.join(" ", segments);
lastAnswer = new Answered(askerId, question, clean);
// Only remember if the asker is still online: a PlayerQuitEvent forgets
// the player's history (carry-forward #6), and re-adding here after the
// quit would resurrect it. lastAnswer stays regardless, so /ia corrigir
@@ -444,10 +445,13 @@ final class Ai {
if (asker != null) {
conversations.remember(askerId, question, clean);
if (plugin.playerMemory() != null) {
masi marked this conversation as resolved
Review

[MEDIUM] recordTurn (which does a synchronous YAML save to disk) runs before lastAnswer is set and before the answer is delivered; if save() throws IllegalStateException on a disk error, the already-paid-for answer is never sent to the player and /ia corrigir state is lost.

Fix: Move recordTurn after the answer is delivered and lastAnswer is set, or wrap it in a try/catch so memory persistence cannot swallow the chat reply.

if (plugin.playerMemory() != null) {
                try {
                    plugin.playerMemory().recordTurn(askerId, asker.getName(), question, clean);
                } catch (RuntimeException ignored) {
                    plugin.getLogger().warning("Falha ao gravar memória da IA para " + asker.getName());
                }
            }

🪙 ~792 tok (40% · attributed output)

**[MEDIUM]** recordTurn (which does a synchronous YAML save to disk) runs before lastAnswer is set and before the answer is delivered; if save() throws IllegalStateException on a disk error, the already-paid-for answer is never sent to the player and /ia corrigir state is lost. Fix: Move recordTurn after the answer is delivered and lastAnswer is set, or wrap it in a try/catch so memory persistence cannot swallow the chat reply. ```java if (plugin.playerMemory() != null) { try { plugin.playerMemory().recordTurn(askerId, asker.getName(), question, clean); } catch (RuntimeException ignored) { plugin.getLogger().warning("Falha ao gravar memória da IA para " + asker.getName()); } } ``` 🪙 ~792 tok (40% · attributed output)
plugin.playerMemory().recordTurn(askerId, asker.getName(), question, clean);
try {
plugin.playerMemory().recordTurn(askerId, asker.getName(), question, clean);
masi marked this conversation as resolved
Review

[MEDIUM] recordTurn() -> PlayerMemory.save() performs a synchronous full ia-memoria.yml write on the main server thread for every /ia answer (deliver runs via runTask), blocking the tick loop; cost scales with total players in the file.

Fix: Move the save off the main thread (async scheduler task, or a dirty-flag + periodic flush like Notes/DeathLog), or batch writes so a single answer doesn't rewrite every player's entry.

📎 ref: https://bukkit.fandom.com/wiki/Scheduler_Programming

🪙 ~4360 tok (32% · attributed output)

**[MEDIUM]** recordTurn() -> PlayerMemory.save() performs a synchronous full ia-memoria.yml write on the main server thread for every /ia answer (deliver runs via runTask), blocking the tick loop; cost scales with total players in the file. Fix: Move the save off the main thread (async scheduler task, or a dirty-flag + periodic flush like Notes/DeathLog), or batch writes so a single answer doesn't rewrite every player's entry. 📎 ref: https://bukkit.fandom.com/wiki/Scheduler_Programming 🪙 ~4360 tok (32% · attributed output)
} catch (RuntimeException e) {
plugin.getLogger().warning("Falha ao gravar memória da IA para " + asker.getName() + ": " + e);
}
}
}
lastAnswer = new Answered(askerId, question, clean);
if (isPrivate || !settings.aiPublic()) {
if (asker != null) {
1
@@ -146,7 +146,20 @@ final class Tools {
}
String uuidStr = who.uuid();
List<DeathLog.Entry> deaths = plugin.deathLog().forPlayer(uuidStr);
List<Note> notes = plugin.notes().visibleTo(uuidStr, null, "");
List<Note> notes = plugin.notes().visibleTo(uuidStr, Note.Scope.PUBLICA, "");
masi marked this conversation as resolved Outdated
Outdated
Review

[HIGH] lugares_jogador calls notes.visibleTo(targetUuid, null, ""), which returns the target player's PRIVATE notes (visibleTo is PUBLICA || authorId==viewer), and that text is returned to the model whose context leaves the server for MiniMax — a direct violation of the Notes.publicSummary invariant that private notes must never reach the third-party AI.

Fix: Restrict to PUBLICA notes authored by the target player only, so no private text ever enters the tool response.

List<Note> notes = new java.util.ArrayList<>();
        for (Note n : plugin.notes().visibleTo(uuidStr, null, "")) {
            if (n.scope() == Note.Scope.PUBLICA
                    && n.authorId() != null && n.authorId().equals(uuidStr)) {
                notes.add(n);
            }
        }

🪙 ~792 tok (40% · attributed output)

**[HIGH]** lugares_jogador calls notes.visibleTo(targetUuid, null, ""), which returns the target player's PRIVATE notes (visibleTo is PUBLICA || authorId==viewer), and that text is returned to the model whose context leaves the server for MiniMax — a direct violation of the Notes.publicSummary invariant that private notes must never reach the third-party AI. Fix: Restrict to PUBLICA notes authored by the target player only, so no private text ever enters the tool response. ```java List<Note> notes = new java.util.ArrayList<>(); for (Note n : plugin.notes().visibleTo(uuidStr, null, "")) { if (n.scope() == Note.Scope.PUBLICA && n.authorId() != null && n.authorId().equals(uuidStr)) { notes.add(n); } } ``` 🪙 ~792 tok (40% · attributed output)
return formatPlayerPlaces(who, deaths, notes);
}
String formatPlayerPlaces(OfflineStats.Known who, List<DeathLog.Entry> deaths, List<Note> visibleNotes) {
String uuidStr = who.uuid();
List<Note> notes = new java.util.ArrayList<>();
if (visibleNotes != null) {
for (Note n : visibleNotes) {
if (n.scope() == Note.Scope.PUBLICA && n.authorId() != null && n.authorId().equals(uuidStr)) {
notes.add(n);
}
}
}
StringBuilder sb = new StringBuilder();
sb.append("Lugares conhecidos de ").append(who.name()).append(":\n");
@@ -164,7 +177,7 @@ final class Tools {
sb.append("\n");
}
if (deaths.isEmpty()) {
if (deaths == null || deaths.isEmpty()) {
sb.append("- Mortes recentes: nenhum registro de morte recente.");
} else {
sb.append("- Mortes recentes: ");
@@ -3,6 +3,8 @@ package dev.marcospaulo.canalhandia;
import com.google.gson.JsonArray;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
1
@@ -42,4 +44,35 @@ class ToolsTest {
String res = tools.run("wiki", "malformed json");
assertTrue(res.contains("argumentos inválidos"));
}
@Test
void formatPlayerPlacesFiltersOutPrivateNotesAndOtherAuthors() {
Tools tools = new Tools(null, null, null, s -> {});
OfflineStats.Known who = new OfflineStats.Known("uuid-ana", "ana");
Note privateNote = new Note(1, Note.Scope.PRIVADA, "ana", "uuid-ana", "segredo", "world", 10, 20, 30, 1000L);
Note publicNote = new Note(2, Note.Scope.PUBLICA, "ana", "uuid-ana", "vila do spawn", "world", 100, 64, 200, 1000L);
Note otherAuthorPublicNote = new Note(3, Note.Scope.PUBLICA, "bob", "uuid-bob", "base do bob", "world", 500, 64, 500, 1000L);
String formatted = tools.formatPlayerPlaces(who, List.of(), List.of(privateNote, publicNote, otherAuthorPublicNote));
assertTrue(formatted.contains("vila do spawn"));
assertFalse(formatted.contains("segredo"), "Private notes must never be included in places output");
assertFalse(formatted.contains("base do bob"), "Notes of other players must not be included");
assertTrue(formatted.contains("nenhum registro de morte recente"));
}
@Test
void formatPlayerPlacesIncludesDeathsAndFormatsPlaces() {
Tools tools = new Tools(null, null, null, s -> {});
OfflineStats.Known who = new OfflineStats.Known("uuid-ana", "ana");
DeathLog.Entry d = new DeathLog.Entry("uuid-ana", "abraçou um Creeper", "Mundo normal", 12, 64, -80, 1000L);
String formatted = tools.formatPlayerPlaces(who, List.of(d), List.of());
assertTrue(formatted.contains("Lugares conhecidos de ana:"));
assertTrue(formatted.contains("nenhuma base salva"));
assertTrue(formatted.contains("12, 64, -80 (Mundo normal)"));
assertTrue(formatted.contains("abraçou um Creeper"));
}
}