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
6 changed files with 261 additions and 21 deletions
Showing only changes of commit 998757d610 - Show all commits
2
@@ -457,7 +457,7 @@ final class Ai {
if (asker != null) {
boolean bedrock = Platform.isBedrock(asker);
for (int i = 0; i < segments.size(); i++) {
asker.sendMessage(style(segments.get(i), question, persona, settings, bedrock, i == 0));
asker.sendMessage(style(segments.get(i), question, persona, settings.aiFancy(), bedrock, i == 0));
}
}
return;
@@ -469,7 +469,7 @@ final class Ai {
for (int i = 0; i < segments.size(); i++) {
String segment = segments.get(i);
boolean first = i == 0;
plugin.broadcastPerPlatform(bedrock -> style(segment, question, persona, settings, bedrock, first));
plugin.broadcastPerPlatform(bedrock -> style(segment, question, persona, settings.aiFancy(), bedrock, first));
}
plugin.openAiReactions(askerId);
}
@@ -477,10 +477,10 @@ final class Ai {
/**
* Renders one answer for chat.
*/
private Component style(String answer, String question, Persona persona, Settings settings, boolean bedrock, boolean firstLine) {
static Component style(String answer, String question, Persona persona, boolean fancy, boolean bedrock, boolean firstLine) {
Component body = Component.text(answer, NamedTextColor.WHITE)
.decoration(TextDecoration.BOLD, false);
if (!bedrock && settings.aiFancy()) {
if (!bedrock && fancy) {
body = body
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
Component.text("Pergunta: ", NamedTextColor.GRAY)
@@ -975,6 +975,14 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
iaForget(sender);
return true;
}
if (sub.equals("lembrar") && args.length >= 2) {
String fact = String.join(" ", Arrays.copyOfRange(args, 1, args.length)).trim();
if (!fact.isBlank() && plugin.playerMemory() != null) {
plugin.playerMemory().addFact(player.getUniqueId(), player.getName(), fact);
Msg.ok(sender, "Fato gravado na memória da sua IA: \"" + fact + "\"");
return true;
}
}
// Personalities: player can switch for themselves (/ia persona <nome>)
// or reset to default (/ia persona padrao).
if ((sub.equals("personalidade") || sub.equals("persona"))
@@ -1840,7 +1848,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
if (name.equals("ia") || name.equals("iap")) {
if (args.length == 1) {
List<String> subs = new ArrayList<>(List.of("persona", "personalidade", "status", "esquecer"));
List<String> subs = new ArrayList<>(List.of("persona", "personalidade", "status", "lembrar", "esquecer"));
if (sender.hasPermission("canalhandia.ia.perfil")) {
subs.addAll(List.of("perfil", "eventos", "saudacao", "corrigir"));
}
@@ -7,8 +7,12 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* Persistent per-player memory and preferences for the AI companion.
@@ -54,6 +58,11 @@ final class PlayerMemory {
private final File file;
private final Map<UUID, Profile> profiles = new HashMap<>();
private final ExecutorService io = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "canalhandia-player-memory-io");
t.setDaemon(true);
return t;
});
PlayerMemory(File file) {
this.file = file;
@@ -108,7 +117,25 @@ final class PlayerMemory {
}
}
String summary(UUID uuid) {
synchronized (profiles) {
Profile p = profiles.get(uuid);
return p != null ? p.summary() : null;
}
}
List<String> facts(UUID uuid) {
synchronized (profiles) {
Profile p = profiles.get(uuid);
return p != null ? Collections.unmodifiableList(p.facts()) : List.of();
}
}
void setPersona(UUID uuid, String name, Persona persona) {
if (persona == null) {
resetPersona(uuid);
return;
}
synchronized (profiles) {
Profile p = profiles.computeIfAbsent(uuid, k -> new Profile(k, name, null, "", new ArrayList<>(), System.currentTimeMillis()));
profiles.put(uuid, p.withPersona(persona));
@@ -126,20 +153,6 @@ final class PlayerMemory {
save();
}
String summary(UUID uuid) {
synchronized (profiles) {
Profile p = profiles.get(uuid);
return p != null ? p.summary() : null;
}
}
List<String> facts(UUID uuid) {
synchronized (profiles) {
Profile p = profiles.get(uuid);
return p != null ? Collections.unmodifiableList(new ArrayList<>(p.facts())) : List.of();
}
}
/**
* Condenses a conversation turn into the player's persistent summary.
*/
1
@@ -178,7 +191,39 @@ final class PlayerMemory {
}
profiles.put(uuid, p.withSummary(updated));
}
save();
// Automatic heuristic fact extraction from player statements
String heuristicFact = extractHeuristicFact(question);
if (heuristicFact != null) {
addFact(uuid, name, heuristicFact);
} else {
save();
}
}
static String extractHeuristicFact(String text) {
if (text == null) {
return null;
}
String lower = text.toLowerCase(Locale.ROOT).trim();
String[] triggers = {
"minha base", "meu spawn", "minha casa", "estou construindo",
"meu plano", "meu objetivo", "sou especialista em", "moro em"
};
for (String trigger : triggers) {
int idx = lower.indexOf(trigger);
if (idx >= 0) {
String candidate = text.substring(idx).trim();
candidate = candidate.replaceAll("[?!.]+$", "").trim();
if (candidate.length() > 60) {
candidate = candidate.substring(0, 60) + "";
}
if (candidate.length() >= 8) {
return candidate;
}
}
}
return null;
}
void addFact(UUID uuid, String name, String fact) {
@@ -250,7 +295,15 @@ final class PlayerMemory {
}
}
private void save() {
/** Flushes any pending background writes to disk (useful for shutdown or tests). */
void flush() {
try {
io.submit(() -> {}).get(2, TimeUnit.SECONDS);
} catch (Exception ignored) {
}
}
private YamlConfiguration buildYaml() {
YamlConfiguration yaml = new YamlConfiguration();
synchronized (profiles) {
for (Map.Entry<UUID, Profile> entry : profiles.entrySet()) {
@@ -265,6 +318,21 @@ final class PlayerMemory {
yaml.set(key + ".atualizado_em", p.updatedAt());
}
}
return yaml;
}
private void save() {
YamlConfiguration yaml = buildYaml();
io.execute(() -> {
try {
yaml.save(file);
} catch (Exception ignored) {
}
});
}
void saveSync() {
YamlConfiguration yaml = buildYaml();
try {
yaml.save(file);
} catch (Exception e) {
@@ -0,0 +1,47 @@
package dev.marcospaulo.canalhandia;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AiTagTest {
@Test
void allPersonasRenderDynamicTagsCorrectly() {
for (Persona persona : Persona.values()) {
Component c = Ai.style("Minha resposta", "Qualquer pergunta", persona, true, false, true);
String plain = PlainTextComponentSerializer.plainText().serialize(c);
assertTrue(plain.contains("[" + persona.displayTag() + "]"),
"Rendered component must contain tag for " + persona);
assertTrue(plain.contains("Minha resposta"));
}
}
@Test
void continuationLinesUseArrowPrefix() {
Component c = Ai.style("Segunda linha", "Pergunta", Persona.JUDITE, true, false, false);
String plain = PlainTextComponentSerializer.plainText().serialize(c);
assertTrue(plain.contains("»"));
assertTrue(plain.contains("Segunda linha"));
}
@Test
void bedrockDisablesHoverEvent() {
Component c = Ai.style("Resposta Bedrock", "Pergunta", Persona.JUDITE, true, true, true);
assertNull(c.children().isEmpty() ? c.hoverEvent() : c.children().get(c.children().size() - 1).hoverEvent());
}
@Test
void javaFancyIncludesHoverWithPersonaDetails() {
Component c = Ai.style("Resposta Java", "Pergunta de teste", Persona.NARRADOR, true, false, true);
assertNotNull(c);
String plain = PlainTextComponentSerializer.plainText().serialize(c);
assertTrue(plain.contains("[Narrador]"));
}
}
@@ -0,0 +1,82 @@
package dev.marcospaulo.canalhandia;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.nio.file.Path;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class EventTest {
@TempDir
Path tempDir;
private PlayerMemory memory;
@BeforeEach
void setUp() {
File file = tempDir.resolve("ia-memoria.yml").toFile();
memory = new PlayerMemory(file);
}
@Test
void eventResolvesPlayerCustomPersonaWhenSet() {
UUID id = UUID.randomUUID();
Persona defaultPersona = Persona.ZOEIRO;
// Default fallback
assertEquals(Persona.ZOEIRO, memory.persona(id, defaultPersona));
// Player custom choice
memory.setPersona(id, "Marcos", Persona.JUDITE);
assertEquals(Persona.JUDITE, memory.persona(id, defaultPersona));
// Switch to Narrador
memory.setPersona(id, "Marcos", Persona.NARRADOR);
assertEquals(Persona.NARRADOR, memory.persona(id, defaultPersona));
}
@Test
void joinWelcomePromptContainsPlayerAndStats() {
String name = "Marcos";
String stats = "100 diamantes minerados, 2 mortes";
String prompt = "O jogador " + name + " acabou de entrar no servidor."
+ " Estatísticas dele: " + stats
+ " Dê as boas-vindas do seu jeito e na sua personalidade, em uma frase curta.";
assertTrue(prompt.contains("Marcos"));
assertTrue(prompt.contains("100 diamantes"));
assertTrue(prompt.contains("personalidade"));
}
@Test
void deathStreakPromptContainsCountAndFlavor() {
String name = "Marcos";
int count = 4;
String flavor = "abraçou um Creeper";
String prompt = "O jogador " + name + " morreu " + count
+ " vezes seguidas em poucos minutos. A última foi assim: " + flavor
+ ". Comente na sua personalidade, sem ofender de verdade.";
assertTrue(prompt.contains("morreu 4 vezes seguidas"));
assertTrue(prompt.contains("abraçou um Creeper"));
}
@Test
void achievementUnlockPromptContainsTitleAndDescription() {
String title = "Mestre dos Diamantes";
String desc = "Minerou 1000 diamantes";
String prompt = "O jogador Marcos desbloqueou a conquista \""
+ title + "\" (" + desc
+ "). Faça um breve comentário na sua personalidade.";
assertTrue(prompt.contains(title));
assertTrue(prompt.contains(desc));
}
}
@@ -29,6 +29,13 @@ class PlayerMemoryTest {
memory = new PlayerMemory(file);
}
@org.junit.jupiter.api.AfterEach
void tearDown() {
if (memory != null) {
memory.flush();
}
}
@Test
void startsEmpty() {
assertEquals(0, memory.size());
@@ -45,12 +52,14 @@ class PlayerMemoryTest {
assertEquals(Persona.JUDITE, memory.rawPersona(id));
// Reload from disk
memory.flush();
PlayerMemory reloaded = new PlayerMemory(file);
assertEquals(Persona.JUDITE, reloaded.persona(id, Persona.ZOEIRO));
assertEquals(Persona.JUDITE, reloaded.rawPersona(id));
// Reset
reloaded.resetPersona(id);
reloaded.flush();
assertNull(reloaded.rawPersona(id));
assertEquals(Persona.ZOEIRO, reloaded.persona(id, Persona.ZOEIRO));
}
@@ -121,4 +130,30 @@ class PlayerMemoryTest {
assertEquals(0, memory.size());
assertNull(memory.rawPersona(id));
}
@Test
void extractsHeuristicFactsAutomatically() {
UUID id = UUID.randomUUID();
memory.recordTurn(id, "Marcos", "Minha base fica nas montanhas nevadas!", "Que lugar bonito!");
List<String> facts = memory.facts(id);
assertFalse(facts.isEmpty());
assertTrue(facts.get(0).toLowerCase().contains("minha base"));
String fact = PlayerMemory.extractHeuristicFact("Eu estou construindo uma pirâmide gigante?");
assertNotNull(fact);
assertTrue(fact.contains("estou construindo uma pirâmide gigante"));
assertNull(PlayerMemory.extractHeuristicFact("quantos blocos tem o mundo?"));
}
@Test
void flushesToDiskCorrectly() {
UUID id = UUID.randomUUID();
memory.setPersona(id, "Marcos", Persona.JUDITE);
memory.recordTurn(id, "Marcos", "Preciso de ajuda", "Aguarde na linha");
memory.flush();
PlayerMemory disk = new PlayerMemory(file);
assertEquals(Persona.JUDITE, disk.persona(id, Persona.ZOEIRO));
assertTrue(disk.summary(id).contains("ajuda"));
}
}