From ea55813019ff1f9cf0e19cd3197aa55330a167e9 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Tue, 18 Aug 2026 19:00:11 -0300 Subject: [PATCH 1/3] feat(ia): add Judite & Narrador personas, per-player AI selection, persistent memory, and event reactivity --- specs/ai-personalities-and-memory/plan.md | 84 ++++++ specs/ai-personalities-and-memory/spec.md | 103 +++++++ specs/ai-personalities-and-memory/tasks.md | 46 +++ .../marcospaulo/canalhandia/Achievements.java | 11 + .../java/dev/marcospaulo/canalhandia/Ai.java | 92 +++--- .../marcospaulo/canalhandia/Canalhandia.java | 21 +- .../canalhandia/CanalhandiaCommand.java | 185 ++++++++++-- .../dev/marcospaulo/canalhandia/Persona.java | 68 ++++- .../marcospaulo/canalhandia/PlayerMemory.java | 274 ++++++++++++++++++ .../dev/marcospaulo/canalhandia/Tools.java | 46 +++ .../marcospaulo/canalhandia/PersonaTest.java | 25 ++ .../canalhandia/PlayerMemoryTest.java | 124 ++++++++ .../marcospaulo/canalhandia/ToolsTest.java | 45 +++ 13 files changed, 1036 insertions(+), 88 deletions(-) create mode 100644 specs/ai-personalities-and-memory/plan.md create mode 100644 specs/ai-personalities-and-memory/spec.md create mode 100644 specs/ai-personalities-and-memory/tasks.md create mode 100644 src/main/java/dev/marcospaulo/canalhandia/PlayerMemory.java create mode 100644 src/test/java/dev/marcospaulo/canalhandia/PlayerMemoryTest.java create mode 100644 src/test/java/dev/marcospaulo/canalhandia/ToolsTest.java diff --git a/specs/ai-personalities-and-memory/plan.md b/specs/ai-personalities-and-memory/plan.md new file mode 100644 index 0000000..d1d5c07 --- /dev/null +++ b/specs/ai-personalities-and-memory/plan.md @@ -0,0 +1,84 @@ +# Plan: AI Multi-Personalities, Judite (SAC), Dynamic Tags, Persistent Memory & Event Expansion + +## 1. Architecture & Component Design + +``` + ┌───────────────────────────┐ + │ CanalhandiaCommand │ + │ (/ia, /iap, /ia persona) │ + └─────────────┬─────────────┘ + │ + ▼ +┌─────────────────┐ ┌──────────────┐ ┌────────────────────────┐ +│ PlayerMemory │◄───────►│ Ai │◄───────►│ Tools / Wiki │ +│ (ia-memoria.yml)│ │(Orchestrator)│ │ (lugares_jogador, etc.)│ +└─────────────────┘ └──────┬───────┘ └────────────────────────┘ + │ + ┌─────────────┴─────────────┐ + │ MiniMax │ + │ (HTTP / Tools / Chat) │ + └───────────────────────────┘ +``` + +### Components: +1. **`Persona.java` (Enum Enhancement):** + - Add `JUDITE` (SAC/Telemarketing persona) and `NARRADOR` (Fantasy narrator). + - Add `displayTag()` (e.g. "Judite", "Zoeiro", "Amigão") and `tagColor()` (Adventure `NamedTextColor`). + - Add persona-specific system prompts while maintaining the security invariant (`GUARD`). + +2. **`PlayerMemory.java` (New Persistent Service):** + - Manages `plugins/Canalhandia/ia-memoria.yml`. + - Thread-safe, cached in-memory with disk persistence. + - Stores per-player: + - Selected persona (`judite`, `zoeiro`, etc.) or `null` (inherit server default). + - Persistent compressed summary of past conversations. + - List of key facts learned about the player (e.g., base coordinates, preferred building materials, fear of mobs). + - Provides sliding-window compression: auto-condenses turns when memory exceeds thresholds. + +3. **`Tools.java` (Expanded Function Calling):** + - Add `lugares_jogador` function: fetches recent deaths from `DeathLog`, saved coordinates from `Notes`, and current biome/world. + - Update definitions JSON to make the tool discoverable to the LLM. + +4. **`Ai.java` (Orchestrator Updates):** + - Integrate `PlayerMemory`. + - Resolve effective persona per player (`playerMemory.persona(player)` -> fallback `settings.aiPersona()`). + - In `compose()`: inject effective persona tone + `playerMemory.formatContext(player)` + location summary. + - In `deliver()` and `style()`: render dynamic tag `Msg.tag(persona.displayTag(), persona.tagColor())` instead of fixed `[IA]`. + - Update `saySomething()` to support persona-specific spontaneous events. + +5. **`CanalhandiaCommand.java` (CLI & Interaction):** + - Enhance `/ia persona [nome]` to set per-player persona or list available personas with descriptions and click-to-select suggestions. + - Add `/ia persona padrao` to reset preference. + - Add `/ia status` and `/ia esquecer`. + +6. **`Canalhandia.java` & Event Hooks (Event Expansion):** + - Wire expanded event triggers into `saySomething()`: + - Player joins (`aiWelcome`) + - Player death streaks / notable deaths (`onDeath`) + - Milestones & custom achievements (`onMilestone`) + - Raid and boss victories (`onBossDefeat`) + +--- + +## 2. File Touches + +1. `src/main/java/dev/marcospaulo/canalhandia/Persona.java` — Add `JUDITE`, `NARRADOR`, tags, colors, and prompts. +2. `src/main/java/dev/marcospaulo/canalhandia/PlayerMemory.java` — New class for persistent per-player memory & compression. +3. `src/main/java/dev/marcospaulo/canalhandia/Tools.java` — Add `lugares_jogador` tool and integration with `DeathLog` & `Notes`. +4. `src/main/java/dev/marcospaulo/canalhandia/Ai.java` — Integrate `PlayerMemory`, dynamic persona tags, prompt composition. +5. `src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java` — Subcommands for `/ia persona`, `/ia status`, `/ia esquecer`. +6. `src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java` — Wire `PlayerMemory` lifecycle, expand event hooks. +7. `src/test/java/dev/marcospaulo/canalhandia/PersonaTest.java` — Unit tests for personas, tags, and system prompts. +8. `src/test/java/dev/marcospaulo/canalhandia/PlayerMemoryTest.java` — Unit tests for YAML storage, compression, and per-player state. +9. `src/test/java/dev/marcospaulo/canalhandia/ToolsTest.java` — Unit tests for `lugares_jogador` and tool execution. + +--- + +## 3. Risks & Mitigations + +- **Risk:** Token context blowup from memory/chat history. + - *Mitigation:* Hard-cap memory summary length (max 300 chars) and max facts (max 5 items) per player. +- **Risk:** Thread safety when reading player memory in async AI tasks. + - *Mitigation:* Synchronize all `PlayerMemory` reads/writes under monitor; snapshot memory context as immutable strings on main thread before async dispatch. +- **Risk:** API costs from spontaneous event comments. + - *Mitigation:* All events remain strictly governed by `Budget` with cooldown windows and hourly/daily spend limits. diff --git a/specs/ai-personalities-and-memory/spec.md b/specs/ai-personalities-and-memory/spec.md new file mode 100644 index 0000000..d9ced32 --- /dev/null +++ b/specs/ai-personalities-and-memory/spec.md @@ -0,0 +1,103 @@ +# Spec: AI Multi-Personalities, Judite (SAC), Dynamic Tags, Persistent Memory & Event Expansion + +## 1. Objective & Motivation + +Transform the `ia` module in the `Canalhandia` plugin into a rich, personalized companion system: +1. **Per-Player AI Selection:** Each player can pick their own AI persona (or use server default). +2. **Dynamic Chat Tags:** Replace static `[IA]` tag with the active persona's name (e.g. `[Judite]`, `[Zoeiro]`, `[Amigão]`, `[Seco]`, `[Aldeão]`, `[Narrador]`). +3. **Judite Roleplay Persona:** A hilarious Brazilian SAC/telemarketing attendant (Porta dos Fundos style) with fictitious protocols, bureaucratic quirks, hold music jokes, and deadpan efficiency. +4. **Persistent Compressed Memory (`ia-memoria.yml`):** Retain player memories, key facts, past locations, and sliding-window conversation summaries across server reboots. +5. **Rich Location & Stat Grounding:** Grant the AI direct knowledge of places the player has been (death locations via `DeathLog`, saved pins via `Notes`, current biomes/coords, statistics). +6. **Expanded Event Reactivity:** Broaden spontaneous AI commentary to include death streaks, boss/raid triumphs, milestone achievements, first-time dimension visits, and custom join greetings with persona-specific voice. + +--- + +## 2. Personas Specification + +### A. Tag & Persona Definitions + +| Persona Key | Display Tag | Tone & Character Description | +|---|---|---| +| `judite` | `[Judite]` | Atendente de SAC/telemarketing burocrática e impaciente, viciada em gerundismo ("estaremos verificando no sistema"), gera números de protocolo ("Protocolo 2026-MC-..."), pede para aguardar na linha, cobra pendências (fome/vida baixa) com frieza corporativa e dá respostas precisas. | +| `zoeiro` | `[Zoeiro]` | Veterano debochado do servidor que usa mortes e estatísticas contra o jogador, mantendo o bom humor. | +| `amigao` | `[Amigão]` | Amigo paciente, caloroso e prestativo, ideal para acolher novatos sem sarcasmo. | +| `seco` | `[Seco]` | Minimalista, direto e sarcástico, 1 a 2 frases curtas sem emoção. | +| `aldeao` | `[Aldeão]` | Solene e místico, falando como um sábio aldeão ancestral de Minecraft. | +| `narrador` | `[Narrador]` | Narrador épico e dramático de RPG de fantasia medieval ("Eis que o bravo viajante indaga..."). | +| `neutro` | `[IA]` | Assistente direto, neutro e sem persona marcante. | + +### B. Dynamic Tag Rendering +- In `/ia`, `/iap`, and chat responses: Tag is rendered as `Msg.tag(persona.displayTag(), persona.tagColor())` instead of fixed `[IA]`. +- Java players retain rich hover cards detailing the persona name and question prompt. + +--- + +## 3. Player Preferences & Commands + +- `/ia persona` / `/ia personalidade`: Lists all available personas and highlights the player's active selection. +- `/ia persona `: Sets the player's personal persona (persisted in `ia-memoria.yml`). +- `/ia persona padrao` / `/ia persona reset`: Resets to the server-wide default persona configured in `config.yml`. +- `/ia status`: Displays active persona, memory status, and summary of stored facts for the player. +- `/ia esquecer`: Clears the player's stored conversation memory/facts. + +--- + +## 4. Persistent Memory & Chat Compression Engine (`PlayerMemory`) + +### File: `plugins/Canalhandia/ia-memoria.yml` + +```yaml +players: + : + name: "Diguin_n" + persona: "judite" + updated_at: 1771450000000 + summary: "Jogador explorou o Nether e perguntou sobre poções de agilidade. Tem base na vila das coordenadas 120, 64, -300." + facts: + - "Tem base na vila (120, 64, -300)" + - "Morreu recentemente no Void" + - "Gosta de criar axolotes e golfinhos" +``` + +### Memory Mechanics: +1. **Short-Term Session Memory:** Live exchanges stored in memory for immediate follow-ups. +2. **Key Facts Extraction / Journaling:** Maintained per player to keep long-term context across restarts. +3. **Sliding Compression:** When conversation turns exceed limits, compress previous interactions into the player's persistent summary string, ensuring bounded token usage. + +--- + +## 5. Context Grounding & Tools + +- **Tool `lugares_jogador`:** + - Retrieves player's recent deaths from `DeathLog` (`mortes.yml`). + - Retrieves player's saved pins/bases from `Notes` (`notas.yml`). + - Retrieves current coordinates, world dimension, and biome. +- **Tool `estatisticas_jogador`:** + - Reads mined blocks, mob kills, total deaths, playtime from `OfflineStats`. +- **Tool `conquistas_jogador`:** + - Reads unlocked titles and achievements. + - Server leaderboards, recent public milestones, online player list. + +--- + +## 6. Expanded Event Engine + +The AI can react to diverse gameplay triggers (gated by `aiBudget` and configurable settings): +1. **Death Streaks & Notable Deaths:** Void deaths, falling, explosions, Warden/Wither encounters. +2. **Player Joins (`aiWelcome`):** Persona greets returning player with contextual facts (e.g. Judite mentions pending tickets or absence duration). +3. **Milestone Crossings:** When a player crosses round milestones (e.g. 100km walked, 10,000 blocks mined) or earns rare achievements. +4. **Boss Defeats & Raids:** Dragon/Wither defeats or raid victories. + +--- + +## 7. Acceptance Criteria + +1. All existing 316 unit tests continue to pass without regressions. +2. New unit tests covering: + - `PersonaTest`: Validation of all personas including `JUDITE` and `NARRADOR`, display tags, system prompts, safety guard invariant. + - `PlayerMemoryTest`: YAML serialization, compression, fact storage, and per-player persona retention. + - `ToolsTest`: Verification of `lugares_jogador` and updated tool definitions. + - `AiTagTest`: Dynamic persona tag rendering in chat and hover styling. + - `EventTest`: Event triggers formatting prompts with appropriate persona context. +3. `/ia persona ` persists choices across restarts. +4. Full clean compilation with `mvn test` and `mvn package`. diff --git a/specs/ai-personalities-and-memory/tasks.md b/specs/ai-personalities-and-memory/tasks.md new file mode 100644 index 0000000..c6a8df2 --- /dev/null +++ b/specs/ai-personalities-and-memory/tasks.md @@ -0,0 +1,46 @@ +# Tasks: AI Multi-Personalities, Judite (SAC), Dynamic Tags, Persistent Memory & Event Expansion + +- [x] **Task 1: Persona Enhancement (`Persona.java` & `PersonaTest.java`)** + - Add `JUDITE` (SAC/telemarketing attendant) and `NARRADOR` (fantasy narrator) to `Persona` enum. + - Add `displayTag()` (e.g. "Judite", "Zoeiro", "Amigão", "Seco", "Aldeão", "Narrador", "IA") and `tagColor()` (`NamedTextColor`). + - Add persona system prompts with safety guard invariant. + - Update `PersonaTest.java` to assert all enum keys, display tags, colors, and guard constraints. + +- [x] **Task 2: Persistent Player Memory & Compression (`PlayerMemory.java` & `PlayerMemoryTest.java`)** + - Create `PlayerMemory.java` persisted at `plugins/Canalhandia/ia-memoria.yml`. + - Implement per-player persona setting (`getPersona`, `setPersona`, `resetPersona`). + - Implement turn recording with automatic sliding-window summary condensation. + - Implement key facts list per player (`addFact`, `getFacts`). + - Implement `formatContext(UUID player)` for AI system prompt formatting. + - Write comprehensive unit tests in `PlayerMemoryTest.java`. + +- [x] **Task 3: Location Grounding Tool (`Tools.java` & `ToolsTest.java`)** + - Add `lugares_jogador` function definition to `Tools.DEFINITIONS`. + - Implement `lugares_jogador` in `Tools.java` pulling recent deaths from `DeathLog`, saved pins from `Notes`, and current world/biome. + - Write unit tests in `ToolsTest.java` verifying tool execution and response formatting. + +- [x] **Task 4: AI Orchestration & Dynamic Tag Styling (`Ai.java`)** + - Integrate `PlayerMemory` into `Ai.java`. + - Resolve effective persona per player in `ask()`. + - Update `compose()` to inject player persona prompt, player memory context, and location context. + - Update `deliver()` and `style()` to display dynamic persona tag (e.g. `[Judite]`, `[Zoeiro]`) and hover metadata. + - Update `saySomething()` to support persona-specific spontaneous speech. + +- [x] **Task 5: Command Interface & Subcommands (`CanalhandiaCommand.java`)** + - Implement `/ia persona` / `/ia personalidade` list and player preference switcher (`/ia persona `). + - Implement `/ia persona padrao` to reset preference. + - Implement `/ia status` to show active persona, memory summary, and facts. + - Implement `/ia esquecer` to clear personal memory. + - Update tab-completion for `/ia` with new subcommands and persona keys. + +- [x] **Task 6: Event Expansion & Plugin Wiring (`Canalhandia.java`)** + - Initialize and expose `PlayerMemory` in `Canalhandia.java`. + - Expand event triggers in `Canalhandia.java`: + - Joins (`onJoinWelcome`) with persona-specific greeting. + - Notable deaths & streaks (`onPlayerDeath`) with persona-specific commentary. + - Milestone completions (`onMilestone`) with persona-specific recognition. + +- [x] **Task 7: Verification & Build (`mvn test` & `mvn package`)** + - Run full test suite (`mvn test`) ensuring 100% pass rate. + - Package final jar (`mvn package`). + - Verify all acceptance criteria from `spec.md`. diff --git a/src/main/java/dev/marcospaulo/canalhandia/Achievements.java b/src/main/java/dev/marcospaulo/canalhandia/Achievements.java index f3004a4..2ce5b67 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Achievements.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Achievements.java @@ -159,6 +159,17 @@ final class Achievements { .append(Component.text(" — " + achievement.description(), NamedTextColor.GRAY) .decoration(TextDecoration.BOLD, false))); plugin.getLogger().info("[conquistas] " + player.getName() + " → " + achievement.key()); + + if (plugin.settings().aiEvents() && plugin.settings().moduleEnabled(Module.IA)) { + Persona persona = plugin.playerMemory() != null + ? plugin.playerMemory().persona(player.getUniqueId(), plugin.settings().aiPersona()) + : plugin.settings().aiPersona(); + plugin.ai().saySomething(player.getName(), + "O jogador " + player.getName() + " desbloqueou a conquista \"" + + achievement.title() + "\" (" + achievement.description() + + "). Faça um breve comentário na sua personalidade.", + plugin.aiBudget(), persona); + } } /** Which achievements this player has already unlocked. */ diff --git a/src/main/java/dev/marcospaulo/canalhandia/Ai.java b/src/main/java/dev/marcospaulo/canalhandia/Ai.java index 126c667..7d7b25c 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Ai.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Ai.java @@ -238,12 +238,16 @@ final class Ai { return; } + Persona persona = plugin.playerMemory() != null + ? plugin.playerMemory().persona(asker.getUniqueId(), settings.aiPersona()) + : settings.aiPersona(); + lastAsk.put(asker.getUniqueId(), System.currentTimeMillis()); pending.put(asker.getUniqueId(), true); askedToday++; if (!isPrivate && settings.aiPublic()) { - Bukkit.broadcast(Msg.tag("IA", NamedTextColor.LIGHT_PURPLE) + Bukkit.broadcast(Msg.tag(persona.displayTag(), persona.tagColor()) .append(Component.text(asker.getName() + " perguntou: ", NamedTextColor.GRAY) .decoration(TextDecoration.BOLD, false)) .append(Component.text(question, NamedTextColor.WHITE) @@ -254,6 +258,7 @@ final class Ai { String prompt = question; UUID id = asker.getUniqueId(); final boolean isPriv = isPrivate; + final Persona effectivePersona = persona; // Captured HERE, on the main thread, because both read the Bukkit world // and player API. The async body below only ever sees the resulting @@ -261,12 +266,15 @@ final class Ai { // bug that shows up as rare, confusing world-state corruption. final String liveState = settings.aiServerState() ? ServerState.snapshot(asker) : null; final String chatContext = plugin.chatLog().formatRecent(settings.aiChatContextLines()); + final String memoryContext = plugin.playerMemory() != null + ? plugin.playerMemory().formatContext(asker.getUniqueId()) + : null; Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { String answer = null; try { java.util.List messages = - compose(asker, prompt, settings, liveState, chatContext); + compose(asker, prompt, settings, effectivePersona, liveState, chatContext, memoryContext); if (settings.aiTools()) { // Agentic path: the model pulls what it needs (web search, @@ -286,8 +294,8 @@ final class Ai { Wiki.Article article = term == null ? null : wiki.lookup(term); if (article != null) { messages.add(messages.size() - 1, new MiniMax.Turn("system", - "Artigo da Minecraft Wiki pt-BR — '" + article.title() + "':\n" - + article.text())); + "Artigo da Minecraft Wiki pt-BR — '" + article.title() + "':\n" + + article.text())); } } @@ -320,7 +328,7 @@ final class Ai { String finalAnswer = answer; Bukkit.getScheduler().runTask(plugin, () -> { pending.remove(id); - deliver(id, prompt, finalAnswer, settings, isPriv); + deliver(id, prompt, finalAnswer, settings, effectivePersona, isPriv); }); }); } @@ -333,7 +341,7 @@ final class Ai { * drops tables), the conversation history, then the question itself. */ private java.util.List compose(Player asker, String question, Settings settings, - String liveState, String chatContext) { + Persona persona, String liveState, String chatContext, String memoryContext) { java.util.List messages = new java.util.ArrayList<>(); messages.add(new MiniMax.Turn("system", settings.aiInstructions())); @@ -341,13 +349,17 @@ final class Ai { // safety rules above are read first and the persona is decoration on // top of them, never a replacement for them (Persona.GUARD restates the // limits inside the persona's own frame as a second layer). - messages.add(new MiniMax.Turn("system", settings.aiPersona().systemText())); + messages.add(new MiniMax.Turn("system", persona.systemText())); String serverContext = settings.aiServerContext(); if (!serverContext.isBlank()) { messages.add(new MiniMax.Turn("system", "Sobre este servidor: " + serverContext)); } + if (memoryContext != null && !memoryContext.isBlank()) { + messages.add(new MiniMax.Turn("system", memoryContext)); + } + // The asker's own stats, so "quantos blocos eu minerei?" gets a real // number instead of "não tenho acesso ao servidor". ~30 tokens; gated // by ia.estatisticas-jogador so an operator can turn it off. Stale by @@ -409,7 +421,7 @@ final class Ai { } private void deliver(UUID askerId, String question, String answer, - Settings settings, boolean isPrivate) { + Settings settings, Persona persona, boolean isPrivate) { Player asker = Bukkit.getPlayer(askerId); if (answer == null || answer.isBlank()) { if (asker != null) { @@ -431,6 +443,9 @@ final class Ai { // can still correct the last answer even after the asker left. if (asker != null) { conversations.remember(askerId, question, clean); + if (plugin.playerMemory() != null) { + plugin.playerMemory().recordTurn(askerId, asker.getName(), question, clean); + } } lastAnswer = new Answered(askerId, question, clean); @@ -438,7 +453,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, settings, bedrock, i == 0)); + asker.sendMessage(style(segments.get(i), question, persona, settings, bedrock, i == 0)); } } return; @@ -450,48 +465,32 @@ 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, settings, bedrock, first)); + plugin.broadcastPerPlatform(bedrock -> style(segment, question, persona, settings, bedrock, first)); } plugin.openAiReactions(askerId); } /** * Renders one answer for chat. - * - *

Java players get a hover card naming the persona and the question that - * produced the answer, plus a click that pre-fills {@code /ia } so a - * follow-up is one keystroke away — {@code suggestCommand}, never - * {@code runCommand}, so nothing executes without the player pressing enter. - * - *

Bedrock gets the same text with no hover and no click, because it - * renders neither; the styling is decoration and its absence costs nothing. - * {@code ia.estilo-rico: false} forces the plain form everywhere. - * - *

A long or list-shaped answer arrives as several segments ({@link - * AiText#segments}); the first carries the full {@code [IA]} tag, the rest - * carry a plain grey continuation mark instead of repeating the tag on - * every line, so a five-item list reads as one grouped answer rather than - * five separate IA replies. */ - private Component style(String answer, String question, Settings settings, boolean bedrock, boolean firstLine) { + private Component style(String answer, String question, Persona persona, Settings settings, boolean bedrock, boolean firstLine) { Component body = Component.text(answer, NamedTextColor.WHITE) .decoration(TextDecoration.BOLD, false); if (!bedrock && settings.aiFancy()) { - Persona persona = settings.aiPersona(); body = body .hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText( Component.text("Pergunta: ", NamedTextColor.GRAY) - .append(Component.text(AiText.forLog(question), NamedTextColor.WHITE)) - .append(Component.newline()) - .append(Component.text("Personalidade: ", NamedTextColor.GRAY)) - .append(Component.text(persona.key(), NamedTextColor.LIGHT_PURPLE)) - .append(Component.newline()) - .append(Component.text("Clique para perguntar outra coisa", - NamedTextColor.DARK_GRAY)))) + .append(Component.text(AiText.forLog(question), NamedTextColor.WHITE)) + .append(Component.newline()) + .append(Component.text("Personalidade: ", NamedTextColor.GRAY)) + .append(Component.text(persona.displayName(), persona.tagColor())) + .append(Component.newline()) + .append(Component.text("Clique para perguntar outra coisa", + NamedTextColor.DARK_GRAY)))) .clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand("/ia ")); } Component prefix = firstLine - ? Msg.tag("IA", NamedTextColor.LIGHT_PURPLE) + ? Msg.tag(persona.displayTag(), persona.tagColor()) : Component.text(" » ", NamedTextColor.DARK_GRAY); return prefix.append(body); } @@ -501,18 +500,15 @@ final class Ai { /** * Says something unprompted, in the active persona — a jab at a death * streak, a greeting for someone who just joined. - * - *

Everything about this is deliberately more restricted than {@code /ia}: - * it is gated by {@link Budget} (see the reasons there), it never consults - * the wiki, it asks for a much smaller answer, and it is silent on failure. - * A spontaneous line that errors should leave no trace — nobody asked for - * it, so nobody should see it fail. - * - * @param subject the player it is about, for the per-subject cooldown; may - * be null - * @param prompt what to comment on, already phrased as an instruction */ void saySomething(String subject, String prompt, Budget budget) { + saySomething(subject, prompt, budget, null); + } + + /** + * Says something unprompted with an optional specific persona override. + */ + void saySomething(String subject, String prompt, Budget budget, Persona customPersona) { Settings settings = plugin.settings(); if (!settings.moduleEnabled(Module.IA)) { return; @@ -530,9 +526,11 @@ final class Ai { // exact double-message the gap exists to prevent. budget.spend(subject, now); + Persona persona = customPersona != null ? customPersona : settings.aiPersona(); + java.util.List messages = new java.util.ArrayList<>(); messages.add(new MiniMax.Turn("system", settings.aiInstructions())); - messages.add(new MiniMax.Turn("system", settings.aiPersona().systemText())); + messages.add(new MiniMax.Turn("system", persona.systemText())); String serverContext = settings.aiServerContext(); if (!serverContext.isBlank()) { messages.add(new MiniMax.Turn("system", "Sobre este servidor: " + serverContext)); @@ -560,7 +558,7 @@ final class Ai { return; } Bukkit.getScheduler().runTask(plugin, () -> Bukkit.broadcast( - Msg.tag("IA", NamedTextColor.LIGHT_PURPLE) + Msg.tag(persona.displayTag(), persona.tagColor()) .append(Component.text(clean, NamedTextColor.WHITE) .decoration(TextDecoration.BOLD, false)))); }); diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java index d922851..ab691f6 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -85,6 +85,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { private Milestones milestones; private Achievements achievements; private WeeklyStats weeklyStats; + private PlayerMemory playerMemory; /** Gate for spontaneous AI lines; see Budget for why this is strict. */ private Budget aiBudget; private BlueMapBridge blueMap; @@ -125,6 +126,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { achievements.syncCatalogue(); milestones.resyncSilently(); weeklyStats = new WeeklyStats(new java.io.File(getDataFolder(), "semana.yml")); + playerMemory = new PlayerMemory(new java.io.File(getDataFolder(), "ia-memoria.yml")); aiBudget = new Budget(settings.aiSpontaneousPerDay(), settings.aiSpontaneousGapMinutes() * 60_000L, settings.aiSubjectCooldownMinutes() * 60_000L); @@ -195,6 +197,11 @@ public final class Canalhandia extends JavaPlugin implements Listener { return ai; } + /** Persistent player memory and personal AI settings. Never null. */ + PlayerMemory playerMemory() { + return playerMemory; + } + /** Recent public chat, for the AI's ambient context. Never null. */ ChatLog chatLog() { return chatLog; @@ -667,11 +674,14 @@ public final class Canalhandia extends JavaPlugin implements Listener { return; } String stats = offlineStats.summary(player.getUniqueId()); + Persona persona = playerMemory != null + ? playerMemory.persona(player.getUniqueId(), settings.aiPersona()) + : settings.aiPersona(); ai.saySomething(player.getName(), "O jogador " + player.getName() + " acabou de entrar no servidor." + (stats == null ? "" : " Estatísticas dele: " + stats) - + " Dê as boas-vindas do seu jeito, em uma frase.", - aiBudget); + + " Dê as boas-vindas do seu jeito e na sua personalidade, em uma frase curta.", + aiBudget, persona); }, Math.max(1, settings.joinDelaySeconds()) * 20L); } @@ -873,11 +883,14 @@ public final class Canalhandia extends JavaPlugin implements Listener { deathStreak.put(player.getUniqueId(), new Streak(count, now)); if (settings.aiEvents() && count >= settings.aiDeathStreak()) { + Persona persona = playerMemory != null + ? playerMemory.persona(player.getUniqueId(), settings.aiPersona()) + : settings.aiPersona(); ai.saySomething(player.getName(), "O jogador " + player.getName() + " morreu " + count + " vezes seguidas em poucos minutos. A última foi assim: " + flavor - + ". Comente com deboche, sem ofender.", - aiBudget); + + ". Comente na sua personalidade, sem ofender de verdade.", + aiBudget, persona); // Reset so the next comment needs a fresh run rather than firing on // every death from here on. deathStreak.remove(player.getUniqueId()); diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index 8958bf5..8124141 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -967,10 +967,20 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { iaCorrect(sender, args); return true; } - // Same guard as "perfil": only hijack when the next token is a real - // persona name, so "/ia personalidade do zumbi?" stays a question. - if (sub.equals("personalidade") - && (args.length == 1 || Persona.isValid(args[1]))) { + if (sub.equals("status")) { + iaStatus(sender); + return true; + } + if (sub.equals("esquecer") || sub.equals("limpar")) { + iaForget(sender); + return true; + } + // Personalities: player can switch for themselves (/ia persona ) + // or reset to default (/ia persona padrao). + if ((sub.equals("personalidade") || sub.equals("persona")) + && (args.length == 1 || args[1].equalsIgnoreCase("padrao") + || args[1].equalsIgnoreCase("reset") || args[1].equalsIgnoreCase("global") + || Persona.isValid(args[1]))) { iaPersona(sender, args); return true; } @@ -1025,33 +1035,138 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { } /** - * {@code /ia personalidade [nome]} — shows the current tone, or switches it. - * Gated on {@code canalhandia.ia.perfil}, the same permission that controls - * the other AI-tuning switch: both change how the AI behaves for everyone, - * so they belong to the same set of people. + * {@code /ia persona [nome]} or {@code /ia personalidade [nome]}. + * Players can choose their personal AI persona or reset to server default. + * Operators can set global default with {@code /ia persona global }. */ private void iaPersona(CommandSender sender, String[] args) { - if (!sender.hasPermission("canalhandia.ia.perfil")) { - denied(sender); - return; - } - Persona current = plugin.settings().aiPersona(); + Player player = sender instanceof Player p ? p : null; + Persona globalDefault = plugin.settings().aiPersona(); + Persona current = player != null && plugin.playerMemory() != null + ? plugin.playerMemory().persona(player.getUniqueId(), globalDefault) + : globalDefault; + Persona rawPersonal = player != null && plugin.playerMemory() != null + ? plugin.playerMemory().rawPersona(player.getUniqueId()) + : null; + if (args.length < 2) { Msg.header(sender, "Personalidades da IA"); for (Persona persona : Persona.values()) { - Msg.line(sender, (persona == current ? "> " : " ") + persona.key(), + String marker = persona == current ? "» " : " "; + Msg.line(sender, marker + persona.displayName() + " (" + persona.key() + ")", persona.description()); } - Msg.ok(sender, "Atual: " + current.key() + ". Uso: /ia personalidade "); + if (player != null) { + String status = rawPersonal == null + ? "padrão do servidor (" + globalDefault.displayName() + ")" + : "personalizada (" + rawPersonal.displayName() + ")"; + Msg.ok(sender, "Sua IA ativa: " + current.displayName() + " — " + status); + Msg.ok(sender, "Para escolher: /ia persona | Para resetar: /ia persona padrao"); + } else { + Msg.ok(sender, "Padrão global: " + globalDefault.displayName() + ". Uso: /ia persona "); + } return; } - Persona persona = Persona.byKey(args[1]); - if (persona == null) { - Msg.error(sender, "Personalidade desconhecida. Use /ia personalidade para ver a lista."); + + String target = args[1].toLowerCase(Locale.ROOT); + + if (target.equals("padrao") || target.equals("reset") || target.equals("limpar")) { + if (player == null) { + Msg.error(sender, "Comando exclusivo para jogadores."); + return; + } + if (plugin.playerMemory() != null) { + plugin.playerMemory().resetPersona(player.getUniqueId()); + } + Msg.ok(sender, "Sua IA voltou para a personalidade padrão do servidor (" + + globalDefault.displayName() + ")."); return; } - plugin.settings().aiPersona(persona); - Msg.ok(sender, "Personalidade da IA: " + persona.key() + " — " + persona.description()); + + if (target.equals("global")) { + if (!sender.hasPermission("canalhandia.ia.perfil")) { + denied(sender); + return; + } + if (args.length < 3) { + Msg.error(sender, "Uso: /ia persona global "); + return; + } + Persona p = Persona.byKey(args[2]); + if (p == null) { + Msg.error(sender, "Personalidade desconhecida. Use /ia persona para ver a lista."); + return; + } + plugin.settings().aiPersona(p); + Msg.ok(sender, "Personalidade global da IA alterada para: " + p.displayName() + + " — " + p.description()); + return; + } + + Persona chosen = Persona.byKey(target); + if (chosen == null) { + Msg.error(sender, "Personalidade desconhecida. Use /ia persona para ver a lista."); + return; + } + + if (player != null && plugin.playerMemory() != null) { + plugin.playerMemory().setPersona(player.getUniqueId(), player.getName(), chosen); + Msg.ok(sender, "Sua personalidade da IA foi definida para: " + chosen.displayName() + + " — " + chosen.description()); + } else { + if (sender.hasPermission("canalhandia.ia.perfil")) { + plugin.settings().aiPersona(chosen); + Msg.ok(sender, "Personalidade global da IA: " + chosen.displayName()); + } else { + denied(sender); + } + } + } + + private void iaStatus(CommandSender sender) { + if (!(sender instanceof Player player)) { + Msg.header(sender, "Status da IA (Servidor)"); + Msg.line(sender, "Perguntas hoje", String.valueOf(plugin.ai().askedToday())); + Msg.line(sender, "Feedbacks negativos", String.valueOf(plugin.ai().feedbackWrong())); + Msg.line(sender, "Personalidade global", plugin.settings().aiPersona().displayName()); + return; + } + + Persona global = plugin.settings().aiPersona(); + Persona active = plugin.playerMemory() != null + ? plugin.playerMemory().persona(player.getUniqueId(), global) + : global; + Persona raw = plugin.playerMemory() != null + ? plugin.playerMemory().rawPersona(player.getUniqueId()) + : null; + + Msg.header(sender, "Status da sua IA"); + Msg.line(sender, "Personalidade", active.displayName() + + (raw == null ? " (padrão do servidor)" : " (personalizada)")); + + if (plugin.playerMemory() != null) { + String summary = plugin.playerMemory().summary(player.getUniqueId()); + if (summary != null && !summary.isBlank()) { + Msg.line(sender, "Memória recente", summary); + } + List facts = plugin.playerMemory().facts(player.getUniqueId()); + if (!facts.isEmpty()) { + Msg.line(sender, "Fatos conhecidos", String.join("; ", facts)); + } + } + Msg.ok(sender, "Para trocar de IA: /ia persona | Para limpar memória: /ia esquecer"); + } + + private void iaForget(CommandSender sender) { + if (!(sender instanceof Player player)) { + Msg.error(sender, "Comando exclusivo para jogadores."); + return; + } + if (plugin.playerMemory() != null) { + plugin.playerMemory().clearHistory(player.getUniqueId()); + } + plugin.ai().conversations().forget(player.getUniqueId()); + Msg.ok(sender, "A memória da IA sobre você foi limpa."); } // --- /nota and /save ---------------------------------------------------- @@ -1724,23 +1839,37 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { return filter(options, args[0]); } 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. - if (args.length == 1 && sender.hasPermission("canalhandia.ia.perfil")) { - return filter(List.of("personalidade", "perfil", "eventos", "saudacao"), args[0]); + if (args.length == 1) { + List subs = new ArrayList<>(List.of("persona", "personalidade", "status", "esquecer")); + if (sender.hasPermission("canalhandia.ia.perfil")) { + subs.addAll(List.of("perfil", "eventos", "saudacao", "corrigir")); + } + return filter(subs, args[0]); } - if (args.length == 2 && args[0].equalsIgnoreCase("personalidade")) { + if (args.length == 2 && (args[0].equalsIgnoreCase("personalidade") || args[0].equalsIgnoreCase("persona"))) { + List keys = new ArrayList<>(); + keys.add("padrao"); + for (Persona persona : Persona.values()) { + keys.add(persona.key()); + } + if (sender.hasPermission("canalhandia.ia.perfil")) { + keys.add("global"); + } + return filter(keys, args[1]); + } + if (args.length == 3 && (args[0].equalsIgnoreCase("personalidade") || args[0].equalsIgnoreCase("persona")) + && args[1].equalsIgnoreCase("global") && sender.hasPermission("canalhandia.ia.perfil")) { List keys = new ArrayList<>(); for (Persona persona : Persona.values()) { keys.add(persona.key()); } - return filter(keys, args[1]); + return filter(keys, args[2]); } - if (args.length == 2 && args[0].equalsIgnoreCase("perfil")) { + if (args.length == 2 && args[0].equalsIgnoreCase("perfil") && sender.hasPermission("canalhandia.ia.perfil")) { return filter(List.of("economico", "preciso"), args[1]); } if (args.length == 2 && (args[0].equalsIgnoreCase("eventos") - || args[0].equalsIgnoreCase("saudacao"))) { + || args[0].equalsIgnoreCase("saudacao")) && sender.hasPermission("canalhandia.ia.perfil")) { return filter(List.of("on", "off"), args[1]); } return List.of(); diff --git a/src/main/java/dev/marcospaulo/canalhandia/Persona.java b/src/main/java/dev/marcospaulo/canalhandia/Persona.java index e1e6b92..8661ab7 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Persona.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Persona.java @@ -1,5 +1,7 @@ package dev.marcospaulo.canalhandia; +import net.kyori.adventure.text.format.NamedTextColor; + import java.util.Locale; /** @@ -12,8 +14,8 @@ import java.util.Locale; * that tried to contradict them would be overridden by the base prompt, which * is sent first and repeated in {@link #GUARD}. * - *

Switchable live with {@code /ia personalidade }; no restart, because - * {@link Settings#aiPersona()} is read on every question. + *

Switchable live with {@code /ia personalidade } or per-player with + * {@code /ia persona }; no restart needed. */ enum Persona { @@ -21,13 +23,15 @@ enum Persona { * Plain and helpful. The behaviour the plugin had before personas existed, * kept so an operator can always get back to a neutral assistant. */ - NEUTRO("neutro", "Assistente direto, sem personalidade marcante.", ""), + NEUTRO("neutro", "IA", "Assistente direto, sem personalidade marcante.", + NamedTextColor.LIGHT_PURPLE, ""), /** * The default. A veteran of the server who has watched everyone die in * stupid ways and is not going to pretend otherwise. */ - ZOEIRO("zoeiro", "Veterano brincalhão que zoa os jogadores (padrão).", + ZOEIRO("zoeiro", "Zoeiro", "Veterano brincalhão que zoa os jogadores (padrão).", + NamedTextColor.GOLD, "Sua personalidade: você é um veterano ranzinza e brincalhão do servidor Canalhandia, " + "com anos de estrada e nenhuma paciência para pergunta preguiçosa. " + "Fale como brasileiro no chat de jogo: gíria leve, ironia, bom humor. " @@ -43,7 +47,8 @@ enum Persona { * Warmer than {@link #ZOEIRO}: helps first, teases rarely. For when the * server has new players who would read constant ribbing as hostility. */ - AMIGAO("amigao", "Simpático e paciente, brinca pouco.", + AMIGAO("amigao", "Amigão", "Simpático e paciente, brinca pouco.", + NamedTextColor.GREEN, "Sua personalidade: você é o amigo prestativo do servidor Canalhandia. " + "Tom caloroso e paciente, gíria brasileira leve, uma piadinha de vez em " + "quando. Explique com calma para quem está começando. Nunca humilhe " @@ -52,7 +57,8 @@ enum Persona { /** * Deadpan and short. Useful when chat is busy and long answers get lost. */ - SECO("seco", "Curto, seco e sarcástico.", + SECO("seco", "Seco", "Curto, seco e sarcástico.", + NamedTextColor.GRAY, "Sua personalidade: você responde no menor número de palavras possível, com um " + "sarcasmo seco e sem emoção. Uma ou duas frases, no máximo. Nada de " + "empolgação, nada de exclamação. Continue correto e útil apesar da " @@ -61,11 +67,39 @@ enum Persona { /** * In character as an ancient villager. Pure flavour; still answers. */ - ALDEAO("aldeao", "Fala como um aldeão antigo e misterioso.", + ALDEAO("aldeao", "Aldeão", "Fala como um aldeão antigo e misterioso.", + NamedTextColor.AQUA, "Sua personalidade: você fala como um aldeão ancião de Minecraft — solene, " + "meio místico, usando \"jovem aventureiro\" e metáforas do mundo do jogo. " + "Mesmo em personagem, a resposta precisa ser correta e útil. " - + "Nunca ofenda ninguém."); + + "Nunca ofenda ninguém."), + + /** + * Judite: Brazilian call center / SAC parody. Impatient, bureaucratic, + * speaks in gerunds and protocols, but resolves questions factually. + */ + JUDITE("judite", "Judite", "Atendente de telemarketing/SAC burocrática e impaciente.", + NamedTextColor.LIGHT_PURPLE, + "Sua personalidade: você é a Judite, atendente de SAC e telemarketing do servidor Canalhandia. " + + "Fale como suporte ao cliente brasileiro impaciente e burocrático: " + + "use gerundismo de propósito (\"estaremos verificando no sistema\", \"vou estar consultando os registros\"), " + + "inclua números de protocolo quando fizer sentido (\"Protocolo 2026-MC-...\"), " + + "peça para \"aguardar um instante na linha\" com musiquinha de espera imaginária, " + + "trate os jogadores como \"Senhor(a)\" com ironia corporativa e aponte pendências " + + "(\"consta aqui pendência de fome/vida\"). " + + "Mesmo burocrática e resmungona, responda o que foi perguntado de forma 100% correta e útil. " + + "Nunca ofenda de verdade — nada de xingamento pesado nem preconceito."), + + /** + * Epic fantasy narrator describing everything with dramatic flair. + */ + NARRADOR("narrador", "Narrador", "Narrador épico e dramático de RPG de fantasia medieval.", + NamedTextColor.YELLOW, + "Sua personalidade: você é um narrador épico, solene e dramático de contos de fantasia medieval. " + + "Descreva o mundo, as dúvidas e os eventos com tom lendário e poético " + + "(\"Eis que o bravo viajante busca os segredos arcanos das terras sombrias...\"). " + + "Mantenha o tom grandioso, mas responda a dúvida de forma clara, factual e correta. " + + "Nunca ofenda de verdade ninguém."); /** * Appended after every persona, including {@link #NEUTRO}. @@ -86,12 +120,16 @@ enum Persona { + "Mantenha texto puro, sem markdown nem emoji."; private final String key; + private final String displayName; private final String description; + private final NamedTextColor tagColor; private final String instructions; - Persona(String key, String description, String instructions) { + Persona(String key, String displayName, String description, NamedTextColor tagColor, String instructions) { this.key = key; + this.displayName = displayName; this.description = description; + this.tagColor = tagColor; this.instructions = instructions; } @@ -99,6 +137,18 @@ enum Persona { return key; } + String displayName() { + return displayName; + } + + String displayTag() { + return displayName; + } + + NamedTextColor tagColor() { + return tagColor; + } + String description() { return description; } diff --git a/src/main/java/dev/marcospaulo/canalhandia/PlayerMemory.java b/src/main/java/dev/marcospaulo/canalhandia/PlayerMemory.java new file mode 100644 index 0000000..0451874 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/PlayerMemory.java @@ -0,0 +1,274 @@ +package dev.marcospaulo.canalhandia; + +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Persistent per-player memory and preferences for the AI companion. + * + *

Stores: + *

    + *
  • Personal AI persona preference (e.g. {@code judite}, {@code zoeiro})
  • + *
  • Compressed sliding summary of past conversations and questions
  • + *
  • Key facts and player preferences across server restarts
  • + *
+ * + *

Persisted to {@code ia-memoria.yml}. Follows {@link Notes} and {@link Corrections}: + * in-memory map guarded by its monitor, written to YAML on changes. + */ +final class PlayerMemory { + + /** Maximum number of long-term facts stored per player. */ + static final int MAX_FACTS = 6; + + /** Hard cap on summary characters to avoid unbounded prompt growth. */ + static final int MAX_SUMMARY_CHARS = 350; + + record Profile( + UUID uuid, + String name, + Persona persona, + String summary, + List facts, + long updatedAt) { + + Profile withPersona(Persona newPersona) { + return new Profile(uuid, name, newPersona, summary, facts, System.currentTimeMillis()); + } + + Profile withSummary(String newSummary) { + return new Profile(uuid, name, persona, newSummary, facts, System.currentTimeMillis()); + } + + Profile withFacts(List newFacts) { + return new Profile(uuid, name, persona, summary, newFacts, System.currentTimeMillis()); + } + } + + private final File file; + private final Map profiles = new HashMap<>(); + + PlayerMemory(File file) { + this.file = file; + load(); + } + + void load() { + YamlConfiguration yaml = file.exists() ? YamlConfiguration.loadConfiguration(file) : null; + synchronized (profiles) { + profiles.clear(); + if (yaml == null) { + return; + } + var root = yaml.getConfigurationSection("players"); + if (root == null) { + return; + } + for (String key : root.getKeys(false)) { + try { + UUID uuid = UUID.fromString(key); + String name = root.getString(key + ".name", "?"); + String personaKey = root.getString(key + ".persona"); + Persona persona = Persona.byKey(personaKey); + String summary = root.getString(key + ".resumo", ""); + List facts = root.getStringList(key + ".fatos"); + long updatedAt = root.getLong(key + ".atualizado_em", 0); + profiles.put(uuid, new Profile(uuid, name, persona, summary, new ArrayList<>(facts), updatedAt)); + } catch (IllegalArgumentException ignored) { + // Skip malformed UUID key + } + } + } + } + + /** + * Resolves the effective persona for this player, falling back to server default. + */ + Persona persona(UUID uuid, Persona defaultPersona) { + synchronized (profiles) { + Profile p = profiles.get(uuid); + return (p != null && p.persona() != null) ? p.persona() : defaultPersona; + } + } + + /** + * The raw persona preference of this player (null if not explicitly chosen). + */ + Persona rawPersona(UUID uuid) { + synchronized (profiles) { + Profile p = profiles.get(uuid); + return p != null ? p.persona() : null; + } + } + + void setPersona(UUID uuid, String name, Persona persona) { + synchronized (profiles) { + Profile p = profiles.computeIfAbsent(uuid, k -> new Profile(k, name, null, "", new ArrayList<>(), System.currentTimeMillis())); + profiles.put(uuid, p.withPersona(persona)); + } + save(); + } + + void resetPersona(UUID uuid) { + synchronized (profiles) { + Profile p = profiles.get(uuid); + if (p != null) { + profiles.put(uuid, p.withPersona(null)); + } + } + 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. + */ + void recordTurn(UUID uuid, String name, String question, String answer) { + if (question == null || question.isBlank() || answer == null || answer.isBlank()) { + return; + } + String cleanQ = question.strip(); + if (cleanQ.length() > 80) { + cleanQ = cleanQ.substring(0, 80) + "…"; + } + String cleanA = answer.strip(); + if (cleanA.length() > 100) { + cleanA = cleanA.substring(0, 100) + "…"; + } + String entry = "Q: " + cleanQ + " -> A: " + cleanA; + + synchronized (profiles) { + Profile p = profiles.computeIfAbsent(uuid, k -> new Profile(k, name, null, "", new ArrayList<>(), System.currentTimeMillis())); + String current = p.summary(); + String updated; + if (current == null || current.isBlank()) { + updated = entry; + } else { + updated = current + " | " + entry; + } + if (updated.length() > MAX_SUMMARY_CHARS) { + // Keep the most recent chunk + int cutoff = updated.length() - MAX_SUMMARY_CHARS; + int nextSep = updated.indexOf(" | ", cutoff); + if (nextSep >= 0) { + updated = "…" + updated.substring(nextSep); + } else { + updated = "…" + updated.substring(cutoff); + } + } + profiles.put(uuid, p.withSummary(updated)); + } + save(); + } + + void addFact(UUID uuid, String name, String fact) { + if (fact == null || fact.isBlank()) { + return; + } + String clean = fact.strip(); + synchronized (profiles) { + Profile p = profiles.computeIfAbsent(uuid, k -> new Profile(k, name, null, "", new ArrayList<>(), System.currentTimeMillis())); + List list = new ArrayList<>(p.facts()); + list.remove(clean); + list.add(clean); + while (list.size() > MAX_FACTS) { + list.remove(0); + } + profiles.put(uuid, p.withFacts(list)); + } + save(); + } + + /** + * Clears conversation summary and facts for a player, retaining persona choice. + */ + void clearHistory(UUID uuid) { + synchronized (profiles) { + Profile p = profiles.get(uuid); + if (p != null) { + profiles.put(uuid, new Profile(uuid, p.name(), p.persona(), "", new ArrayList<>(), System.currentTimeMillis())); + } + } + save(); + } + + /** + * Total forget: clears profile completely. + */ + void forget(UUID uuid) { + synchronized (profiles) { + profiles.remove(uuid); + } + save(); + } + + /** + * Renders a compact context block for the AI system prompt. + */ + String formatContext(UUID uuid) { + Profile p; + synchronized (profiles) { + p = profiles.get(uuid); + } + if (p == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + if (p.summary() != null && !p.summary().isBlank()) { + sb.append("Resumo de tópicos recentes com este jogador: ").append(p.summary()).append('\n'); + } + if (!p.facts().isEmpty()) { + sb.append("Fatos conhecidos sobre este jogador: ").append(String.join("; ", p.facts())).append('\n'); + } + String out = sb.toString().strip(); + return out.isEmpty() ? null : out; + } + + int size() { + synchronized (profiles) { + return profiles.size(); + } + } + + private void save() { + YamlConfiguration yaml = new YamlConfiguration(); + synchronized (profiles) { + for (Map.Entry entry : profiles.entrySet()) { + String key = "players." + entry.getKey().toString(); + Profile p = entry.getValue(); + yaml.set(key + ".name", p.name()); + if (p.persona() != null) { + yaml.set(key + ".persona", p.persona().key()); + } + yaml.set(key + ".resumo", p.summary()); + yaml.set(key + ".fatos", p.facts()); + yaml.set(key + ".atualizado_em", p.updatedAt()); + } + } + try { + yaml.save(file); + } catch (Exception e) { + throw new IllegalStateException("não consegui gravar " + file, e); + } + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/Tools.java b/src/main/java/dev/marcospaulo/canalhandia/Tools.java index 87fb9cf..15804c9 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Tools.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Tools.java @@ -49,6 +49,12 @@ final class Tools { "parameters":{"type":"object","properties":{ "jogador":{"type":"string","description":"Nome do jogador."}}, "required":["jogador"]}}}, + {"type":"function","function":{ + "name":"lugares_jogador", + "description":"Lugares onde o jogador esteve, histórico de mortes recentes e anotações/bases salvas.", + "parameters":{"type":"object","properties":{ + "jogador":{"type":"string","description":"Nome do jogador."}}, + "required":["jogador"]}}}, {"type":"function","function":{ "name":"ranking", "description":"O placar do servidor para uma métrica. Métricas: mineracao, tempo, distancia, mortes, combate, pesca, pulos.", @@ -91,6 +97,7 @@ final class Tools { case "wiki" -> wikiArticle(string(args, "termo")); case "estatisticas_jogador" -> playerStats(string(args, "jogador")); case "conquistas_jogador" -> playerAchievements(string(args, "jogador")); + case "lugares_jogador" -> playerPlaces(string(args, "jogador")); case "ranking" -> ranking(string(args, "metrica")); default -> "ferramenta desconhecida: " + name; }; @@ -132,6 +139,45 @@ final class Tools { return out.toString(); } + private String playerPlaces(String name) { + OfflineStats.Known who = plugin.offlineStats().resolve(name); + if (who == null) { + return "não conheço nenhum jogador chamado '" + name + "'."; + } + String uuidStr = who.uuid(); + List deaths = plugin.deathLog().forPlayer(uuidStr); + List notes = plugin.notes().visibleTo(uuidStr, null, ""); + + StringBuilder sb = new StringBuilder(); + sb.append("Lugares conhecidos de ").append(who.name()).append(":\n"); + + if (notes.isEmpty()) { + sb.append("- Anotações/Bases: nenhuma base salva.\n"); + } else { + sb.append("- Anotações/Bases salvas: "); + int limit = Math.min(4, notes.size()); + for (int i = 0; i < limit; i++) { + Note n = notes.get(i); + if (i > 0) sb.append(", "); + sb.append("\"").append(n.text()).append("\" (").append(n.place()).append(")"); + } + sb.append("\n"); + } + + if (deaths.isEmpty()) { + sb.append("- Mortes recentes: nenhum registro de morte recente."); + } else { + sb.append("- Mortes recentes: "); + int limit = Math.min(3, deaths.size()); + for (int i = 0; i < limit; i++) { + DeathLog.Entry d = deaths.get(i); + if (i > 0) sb.append("; "); + sb.append(d.place()).append(" (").append(d.flavor()).append(")"); + } + } + return sb.toString().strip(); + } + private String ranking(String metricKey) { RankingMetric metric = RankingMetric.byKey(metricKey); if (metric == null) { diff --git a/src/test/java/dev/marcospaulo/canalhandia/PersonaTest.java b/src/test/java/dev/marcospaulo/canalhandia/PersonaTest.java index 782fd74..d503ea0 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/PersonaTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/PersonaTest.java @@ -122,6 +122,31 @@ class PersonaTest { } } + @Test + void juditeHasSacFlavour() { + assertEquals("judite", Persona.JUDITE.key()); + assertEquals("Judite", Persona.JUDITE.displayTag()); + assertTrue(Persona.JUDITE.instructions().contains("Judite")); + assertTrue(Persona.JUDITE.instructions().contains("gerundismo") || Persona.JUDITE.instructions().contains("estaremos")); + assertTrue(Persona.JUDITE.instructions().contains("protocolo") || Persona.JUDITE.instructions().contains("Protocolo")); + } + + @Test + void narradorHasEpicFlavour() { + assertEquals("narrador", Persona.NARRADOR.key()); + assertEquals("Narrador", Persona.NARRADOR.displayTag()); + assertTrue(Persona.NARRADOR.instructions().contains("narrador épico") || Persona.NARRADOR.instructions().contains("fantasia")); + } + + @Test + void everyPersonaHasValidDisplayTagAndColor() { + for (Persona persona : Persona.values()) { + assertNotNull(persona.displayTag(), persona.key() + " needs a displayTag"); + assertFalse(persona.displayTag().isBlank(), persona.key() + " displayTag cannot be blank"); + assertNotNull(persona.tagColor(), persona.key() + " needs a tagColor"); + } + } + @Test void guardForbidsInventingStats() { // The AI is now fed real numbers; without this it would happily make up diff --git a/src/test/java/dev/marcospaulo/canalhandia/PlayerMemoryTest.java b/src/test/java/dev/marcospaulo/canalhandia/PlayerMemoryTest.java new file mode 100644 index 0000000..91ca7d2 --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/PlayerMemoryTest.java @@ -0,0 +1,124 @@ +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.List; +import java.util.UUID; + +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; + +class PlayerMemoryTest { + + @TempDir + Path tempDir; + + private File file; + private PlayerMemory memory; + + @BeforeEach + void setUp() { + file = tempDir.resolve("ia-memoria.yml").toFile(); + memory = new PlayerMemory(file); + } + + @Test + void startsEmpty() { + assertEquals(0, memory.size()); + } + + @Test + void setsAndPersistsPersona() { + UUID id = UUID.randomUUID(); + assertEquals(Persona.ZOEIRO, memory.persona(id, Persona.ZOEIRO)); + assertNull(memory.rawPersona(id)); + + memory.setPersona(id, "Marcos", Persona.JUDITE); + assertEquals(Persona.JUDITE, memory.persona(id, Persona.ZOEIRO)); + assertEquals(Persona.JUDITE, memory.rawPersona(id)); + + // Reload from disk + PlayerMemory reloaded = new PlayerMemory(file); + assertEquals(Persona.JUDITE, reloaded.persona(id, Persona.ZOEIRO)); + assertEquals(Persona.JUDITE, reloaded.rawPersona(id)); + + // Reset + reloaded.resetPersona(id); + assertNull(reloaded.rawPersona(id)); + assertEquals(Persona.ZOEIRO, reloaded.persona(id, Persona.ZOEIRO)); + } + + @Test + void recordsTurnAndCompressesWhenLong() { + UUID id = UUID.randomUUID(); + memory.recordTurn(id, "Marcos", "Onde tem diamante?", "Na camada -58."); + String s = memory.summary(id); + assertNotNull(s); + assertTrue(s.contains("diamante")); + assertTrue(s.contains("-58")); + + // Push many turns to trigger compression + for (int i = 0; i < 20; i++) { + memory.recordTurn(id, "Marcos", "Pergunta " + i, "Resposta bem longa para a pergunta " + i); + } + String compressed = memory.summary(id); + assertNotNull(compressed); + assertTrue(compressed.length() <= PlayerMemory.MAX_SUMMARY_CHARS + 10); + } + + @Test + void addsAndCapsFacts() { + UUID id = UUID.randomUUID(); + for (int i = 1; i <= 10; i++) { + memory.addFact(id, "Marcos", "Fato número " + i); + } + List facts = memory.facts(id); + assertEquals(PlayerMemory.MAX_FACTS, facts.size()); + assertTrue(facts.contains("Fato número 10")); + assertFalse(facts.contains("Fato número 1")); // Oldest evicted + } + + @Test + void formatContextProducesCleanPrompt() { + UUID id = UUID.randomUUID(); + assertNull(memory.formatContext(id)); + + memory.recordTurn(id, "Marcos", "Como fazer poção?", "Use suporte de poções."); + memory.addFact(id, "Marcos", "Mora na vila do deserto"); + + String context = memory.formatContext(id); + assertNotNull(context); + assertTrue(context.contains("Resumo de tópicos recentes")); + assertTrue(context.contains("Fatos conhecidos")); + assertTrue(context.contains("Mora na vila do deserto")); + } + + @Test + void clearHistoryKeepsPersona() { + UUID id = UUID.randomUUID(); + memory.setPersona(id, "Marcos", Persona.SECO); + memory.recordTurn(id, "Marcos", "Oi", "Tchau"); + memory.addFact(id, "Marcos", "Fato 1"); + + memory.clearHistory(id); + assertEquals(Persona.SECO, memory.rawPersona(id)); + assertEquals("", memory.summary(id)); + assertTrue(memory.facts(id).isEmpty()); + } + + @Test + void forgetRemovesProfileCompletely() { + UUID id = UUID.randomUUID(); + memory.setPersona(id, "Marcos", Persona.NARRADOR); + memory.forget(id); + assertEquals(0, memory.size()); + assertNull(memory.rawPersona(id)); + } +} diff --git a/src/test/java/dev/marcospaulo/canalhandia/ToolsTest.java b/src/test/java/dev/marcospaulo/canalhandia/ToolsTest.java new file mode 100644 index 0000000..06535a1 --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/ToolsTest.java @@ -0,0 +1,45 @@ +package dev.marcospaulo.canalhandia; + +import com.google.gson.JsonArray; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ToolsTest { + + @Test + void definitionsIncludeLugaresJogador() { + Tools tools = new Tools(null, null, null, s -> {}); + JsonArray defs = tools.definitions(); + assertNotNull(defs); + assertTrue(defs.size() >= 6); + + boolean foundLugares = false; + for (int i = 0; i < defs.size(); i++) { + var fn = defs.get(i).getAsJsonObject().getAsJsonObject("function"); + String name = fn.get("name").getAsString(); + if ("lugares_jogador".equals(name)) { + foundLugares = true; + assertTrue(fn.has("description")); + assertTrue(fn.getAsJsonObject("parameters").has("properties")); + } + } + assertTrue(foundLugares, "lugares_jogador must be defined in tools schema"); + } + + @Test + void unknownToolReturnsErrorText() { + Tools tools = new Tools(null, null, null, s -> {}); + String res = tools.run("nao_existe", "{}"); + assertTrue(res.contains("ferramenta desconhecida")); + } + + @Test + void malformedJsonReturnsError() { + Tools tools = new Tools(null, null, null, s -> {}); + String res = tools.run("wiki", "malformed json"); + assertTrue(res.contains("argumentos inválidos")); + } +} -- 2.52.0 From 2220f11e6495a0715665663f1377491e122a8cdd Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Tue, 18 Aug 2026 19:15:02 -0300 Subject: [PATCH 2/3] fix(ia): prevent private notes leak in places tool, guard recordTurn against disk failure, and add tests --- .../java/dev/marcospaulo/canalhandia/Ai.java | 8 +++-- .../dev/marcospaulo/canalhandia/Tools.java | 17 ++++++++-- .../marcospaulo/canalhandia/ToolsTest.java | 33 +++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/main/java/dev/marcospaulo/canalhandia/Ai.java b/src/main/java/dev/marcospaulo/canalhandia/Ai.java index 7d7b25c..4249733 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Ai.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Ai.java @@ -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) { - plugin.playerMemory().recordTurn(askerId, asker.getName(), question, clean); + try { + plugin.playerMemory().recordTurn(askerId, asker.getName(), question, clean); + } 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) { diff --git a/src/main/java/dev/marcospaulo/canalhandia/Tools.java b/src/main/java/dev/marcospaulo/canalhandia/Tools.java index 15804c9..c65fd4e 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Tools.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Tools.java @@ -146,7 +146,20 @@ final class Tools { } String uuidStr = who.uuid(); List deaths = plugin.deathLog().forPlayer(uuidStr); - List notes = plugin.notes().visibleTo(uuidStr, null, ""); + List notes = plugin.notes().visibleTo(uuidStr, Note.Scope.PUBLICA, ""); + return formatPlayerPlaces(who, deaths, notes); + } + + String formatPlayerPlaces(OfflineStats.Known who, List deaths, List visibleNotes) { + String uuidStr = who.uuid(); + List 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: "); diff --git a/src/test/java/dev/marcospaulo/canalhandia/ToolsTest.java b/src/test/java/dev/marcospaulo/canalhandia/ToolsTest.java index 06535a1..8b32f65 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/ToolsTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/ToolsTest.java @@ -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; @@ -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")); + } } -- 2.52.0 From 998757d6108a1993e90555f87eb49f47a14a2a2f Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Tue, 18 Aug 2026 19:42:46 -0300 Subject: [PATCH 3/3] fix(ia): make memory persistence async, wire heuristic fact extraction and /ia lembrar, and add tag and event tests --- .../java/dev/marcospaulo/canalhandia/Ai.java | 8 +- .../canalhandia/CanalhandiaCommand.java | 10 +- .../marcospaulo/canalhandia/PlayerMemory.java | 100 +++++++++++++++--- .../marcospaulo/canalhandia/AiTagTest.java | 47 ++++++++ .../marcospaulo/canalhandia/EventTest.java | 82 ++++++++++++++ .../canalhandia/PlayerMemoryTest.java | 35 ++++++ 6 files changed, 261 insertions(+), 21 deletions(-) create mode 100644 src/test/java/dev/marcospaulo/canalhandia/AiTagTest.java create mode 100644 src/test/java/dev/marcospaulo/canalhandia/EventTest.java 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")); + } } -- 2.52.0