diff --git a/src/main/java/dev/marcospaulo/canalhandia/Ai.java b/src/main/java/dev/marcospaulo/canalhandia/Ai.java index 4249733..5693d52 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Ai.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Ai.java @@ -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) diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index 8124141..c5dd749 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -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 ) // 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 subs = new ArrayList<>(List.of("persona", "personalidade", "status", "esquecer")); + List subs = new ArrayList<>(List.of("persona", "personalidade", "status", "lembrar", "esquecer")); if (sender.hasPermission("canalhandia.ia.perfil")) { subs.addAll(List.of("perfil", "eventos", "saudacao", "corrigir")); } diff --git a/src/main/java/dev/marcospaulo/canalhandia/PlayerMemory.java b/src/main/java/dev/marcospaulo/canalhandia/PlayerMemory.java index 0451874..dac22b5 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/PlayerMemory.java +++ b/src/main/java/dev/marcospaulo/canalhandia/PlayerMemory.java @@ -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 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 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 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. */ @@ -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 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) { diff --git a/src/test/java/dev/marcospaulo/canalhandia/AiTagTest.java b/src/test/java/dev/marcospaulo/canalhandia/AiTagTest.java new file mode 100644 index 0000000..4b374d8 --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/AiTagTest.java @@ -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]")); + } +} diff --git a/src/test/java/dev/marcospaulo/canalhandia/EventTest.java b/src/test/java/dev/marcospaulo/canalhandia/EventTest.java new file mode 100644 index 0000000..dd284d8 --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/EventTest.java @@ -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)); + } +} diff --git a/src/test/java/dev/marcospaulo/canalhandia/PlayerMemoryTest.java b/src/test/java/dev/marcospaulo/canalhandia/PlayerMemoryTest.java index 91ca7d2..a3b0d3f 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/PlayerMemoryTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/PlayerMemoryTest.java @@ -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 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")); + } }