feat(ia): add Judite & Narrador personas, per-player AI selection, persistent memory, and event reactivity
This commit is contained in:
@@ -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.
|
||||||
@@ -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 <nome>`: 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:
|
||||||
|
<uuid>:
|
||||||
|
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 <nome>` persists choices across restarts.
|
||||||
|
4. Full clean compilation with `mvn test` and `mvn package`.
|
||||||
@@ -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 <nome>`).
|
||||||
|
- 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`.
|
||||||
@@ -159,6 +159,17 @@ final class Achievements {
|
|||||||
.append(Component.text(" — " + achievement.description(), NamedTextColor.GRAY)
|
.append(Component.text(" — " + achievement.description(), NamedTextColor.GRAY)
|
||||||
.decoration(TextDecoration.BOLD, false)));
|
.decoration(TextDecoration.BOLD, false)));
|
||||||
plugin.getLogger().info("[conquistas] " + player.getName() + " → " + achievement.key());
|
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. */
|
/** Which achievements this player has already unlocked. */
|
||||||
|
|||||||
@@ -238,12 +238,16 @@ final class Ai {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Persona persona = plugin.playerMemory() != null
|
||||||
|
? plugin.playerMemory().persona(asker.getUniqueId(), settings.aiPersona())
|
||||||
|
: settings.aiPersona();
|
||||||
|
|
||||||
lastAsk.put(asker.getUniqueId(), System.currentTimeMillis());
|
lastAsk.put(asker.getUniqueId(), System.currentTimeMillis());
|
||||||
pending.put(asker.getUniqueId(), true);
|
pending.put(asker.getUniqueId(), true);
|
||||||
askedToday++;
|
askedToday++;
|
||||||
|
|
||||||
if (!isPrivate && settings.aiPublic()) {
|
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)
|
.append(Component.text(asker.getName() + " perguntou: ", NamedTextColor.GRAY)
|
||||||
.decoration(TextDecoration.BOLD, false))
|
.decoration(TextDecoration.BOLD, false))
|
||||||
.append(Component.text(question, NamedTextColor.WHITE)
|
.append(Component.text(question, NamedTextColor.WHITE)
|
||||||
@@ -254,6 +258,7 @@ final class Ai {
|
|||||||
String prompt = question;
|
String prompt = question;
|
||||||
UUID id = asker.getUniqueId();
|
UUID id = asker.getUniqueId();
|
||||||
final boolean isPriv = isPrivate;
|
final boolean isPriv = isPrivate;
|
||||||
|
final Persona effectivePersona = persona;
|
||||||
|
|
||||||
// Captured HERE, on the main thread, because both read the Bukkit world
|
// Captured HERE, on the main thread, because both read the Bukkit world
|
||||||
// and player API. The async body below only ever sees the resulting
|
// 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.
|
// bug that shows up as rare, confusing world-state corruption.
|
||||||
final String liveState = settings.aiServerState() ? ServerState.snapshot(asker) : null;
|
final String liveState = settings.aiServerState() ? ServerState.snapshot(asker) : null;
|
||||||
final String chatContext = plugin.chatLog().formatRecent(settings.aiChatContextLines());
|
final String chatContext = plugin.chatLog().formatRecent(settings.aiChatContextLines());
|
||||||
|
final String memoryContext = plugin.playerMemory() != null
|
||||||
|
? plugin.playerMemory().formatContext(asker.getUniqueId())
|
||||||
|
: null;
|
||||||
|
|
||||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
|
||||||
String answer = null;
|
String answer = null;
|
||||||
try {
|
try {
|
||||||
java.util.List<MiniMax.Turn> messages =
|
java.util.List<MiniMax.Turn> messages =
|
||||||
compose(asker, prompt, settings, liveState, chatContext);
|
compose(asker, prompt, settings, effectivePersona, liveState, chatContext, memoryContext);
|
||||||
|
|
||||||
if (settings.aiTools()) {
|
if (settings.aiTools()) {
|
||||||
// Agentic path: the model pulls what it needs (web search,
|
// Agentic path: the model pulls what it needs (web search,
|
||||||
@@ -320,7 +328,7 @@ final class Ai {
|
|||||||
String finalAnswer = answer;
|
String finalAnswer = answer;
|
||||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||||
pending.remove(id);
|
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.
|
* drops tables), the conversation history, then the question itself.
|
||||||
*/
|
*/
|
||||||
private java.util.List<MiniMax.Turn> compose(Player asker, String question, Settings settings,
|
private java.util.List<MiniMax.Turn> compose(Player asker, String question, Settings settings,
|
||||||
String liveState, String chatContext) {
|
Persona persona, String liveState, String chatContext, String memoryContext) {
|
||||||
java.util.List<MiniMax.Turn> messages = new java.util.ArrayList<>();
|
java.util.List<MiniMax.Turn> messages = new java.util.ArrayList<>();
|
||||||
messages.add(new MiniMax.Turn("system", settings.aiInstructions()));
|
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
|
// safety rules above are read first and the persona is decoration on
|
||||||
// top of them, never a replacement for them (Persona.GUARD restates the
|
// top of them, never a replacement for them (Persona.GUARD restates the
|
||||||
// limits inside the persona's own frame as a second layer).
|
// 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();
|
String serverContext = settings.aiServerContext();
|
||||||
if (!serverContext.isBlank()) {
|
if (!serverContext.isBlank()) {
|
||||||
messages.add(new MiniMax.Turn("system", "Sobre este servidor: " + serverContext));
|
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
|
// 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
|
// 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
|
// 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,
|
private void deliver(UUID askerId, String question, String answer,
|
||||||
Settings settings, boolean isPrivate) {
|
Settings settings, Persona persona, boolean isPrivate) {
|
||||||
Player asker = Bukkit.getPlayer(askerId);
|
Player asker = Bukkit.getPlayer(askerId);
|
||||||
if (answer == null || answer.isBlank()) {
|
if (answer == null || answer.isBlank()) {
|
||||||
if (asker != null) {
|
if (asker != null) {
|
||||||
@@ -431,6 +443,9 @@ final class Ai {
|
|||||||
// can still correct the last answer even after the asker left.
|
// can still correct the last answer even after the asker left.
|
||||||
if (asker != null) {
|
if (asker != null) {
|
||||||
conversations.remember(askerId, question, clean);
|
conversations.remember(askerId, question, clean);
|
||||||
|
if (plugin.playerMemory() != null) {
|
||||||
|
plugin.playerMemory().recordTurn(askerId, asker.getName(), question, clean);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
lastAnswer = new Answered(askerId, question, clean);
|
lastAnswer = new Answered(askerId, question, clean);
|
||||||
|
|
||||||
@@ -438,7 +453,7 @@ final class Ai {
|
|||||||
if (asker != null) {
|
if (asker != null) {
|
||||||
boolean bedrock = Platform.isBedrock(asker);
|
boolean bedrock = Platform.isBedrock(asker);
|
||||||
for (int i = 0; i < segments.size(); i++) {
|
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;
|
return;
|
||||||
@@ -450,48 +465,32 @@ final class Ai {
|
|||||||
for (int i = 0; i < segments.size(); i++) {
|
for (int i = 0; i < segments.size(); i++) {
|
||||||
String segment = segments.get(i);
|
String segment = segments.get(i);
|
||||||
boolean first = i == 0;
|
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);
|
plugin.openAiReactions(askerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders one answer for chat.
|
* Renders one answer for chat.
|
||||||
*
|
|
||||||
* <p>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.
|
|
||||||
*
|
|
||||||
* <p>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.
|
|
||||||
*
|
|
||||||
* <p>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)
|
Component body = Component.text(answer, NamedTextColor.WHITE)
|
||||||
.decoration(TextDecoration.BOLD, false);
|
.decoration(TextDecoration.BOLD, false);
|
||||||
if (!bedrock && settings.aiFancy()) {
|
if (!bedrock && settings.aiFancy()) {
|
||||||
Persona persona = settings.aiPersona();
|
|
||||||
body = body
|
body = body
|
||||||
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
|
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
|
||||||
Component.text("Pergunta: ", NamedTextColor.GRAY)
|
Component.text("Pergunta: ", NamedTextColor.GRAY)
|
||||||
.append(Component.text(AiText.forLog(question), NamedTextColor.WHITE))
|
.append(Component.text(AiText.forLog(question), NamedTextColor.WHITE))
|
||||||
.append(Component.newline())
|
.append(Component.newline())
|
||||||
.append(Component.text("Personalidade: ", NamedTextColor.GRAY))
|
.append(Component.text("Personalidade: ", NamedTextColor.GRAY))
|
||||||
.append(Component.text(persona.key(), NamedTextColor.LIGHT_PURPLE))
|
.append(Component.text(persona.displayName(), persona.tagColor()))
|
||||||
.append(Component.newline())
|
.append(Component.newline())
|
||||||
.append(Component.text("Clique para perguntar outra coisa",
|
.append(Component.text("Clique para perguntar outra coisa",
|
||||||
NamedTextColor.DARK_GRAY))))
|
NamedTextColor.DARK_GRAY))))
|
||||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand("/ia "));
|
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand("/ia "));
|
||||||
}
|
}
|
||||||
Component prefix = firstLine
|
Component prefix = firstLine
|
||||||
? Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
|
? Msg.tag(persona.displayTag(), persona.tagColor())
|
||||||
: Component.text(" » ", NamedTextColor.DARK_GRAY);
|
: Component.text(" » ", NamedTextColor.DARK_GRAY);
|
||||||
return prefix.append(body);
|
return prefix.append(body);
|
||||||
}
|
}
|
||||||
@@ -501,18 +500,15 @@ final class Ai {
|
|||||||
/**
|
/**
|
||||||
* Says something unprompted, in the active persona — a jab at a death
|
* Says something unprompted, in the active persona — a jab at a death
|
||||||
* streak, a greeting for someone who just joined.
|
* streak, a greeting for someone who just joined.
|
||||||
*
|
|
||||||
* <p>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) {
|
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();
|
Settings settings = plugin.settings();
|
||||||
if (!settings.moduleEnabled(Module.IA)) {
|
if (!settings.moduleEnabled(Module.IA)) {
|
||||||
return;
|
return;
|
||||||
@@ -530,9 +526,11 @@ final class Ai {
|
|||||||
// exact double-message the gap exists to prevent.
|
// exact double-message the gap exists to prevent.
|
||||||
budget.spend(subject, now);
|
budget.spend(subject, now);
|
||||||
|
|
||||||
|
Persona persona = customPersona != null ? customPersona : settings.aiPersona();
|
||||||
|
|
||||||
java.util.List<MiniMax.Turn> messages = new java.util.ArrayList<>();
|
java.util.List<MiniMax.Turn> messages = new java.util.ArrayList<>();
|
||||||
messages.add(new MiniMax.Turn("system", settings.aiInstructions()));
|
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();
|
String serverContext = settings.aiServerContext();
|
||||||
if (!serverContext.isBlank()) {
|
if (!serverContext.isBlank()) {
|
||||||
messages.add(new MiniMax.Turn("system", "Sobre este servidor: " + serverContext));
|
messages.add(new MiniMax.Turn("system", "Sobre este servidor: " + serverContext));
|
||||||
@@ -560,7 +558,7 @@ final class Ai {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Bukkit.getScheduler().runTask(plugin, () -> Bukkit.broadcast(
|
Bukkit.getScheduler().runTask(plugin, () -> Bukkit.broadcast(
|
||||||
Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
|
Msg.tag(persona.displayTag(), persona.tagColor())
|
||||||
.append(Component.text(clean, NamedTextColor.WHITE)
|
.append(Component.text(clean, NamedTextColor.WHITE)
|
||||||
.decoration(TextDecoration.BOLD, false))));
|
.decoration(TextDecoration.BOLD, false))));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
private Milestones milestones;
|
private Milestones milestones;
|
||||||
private Achievements achievements;
|
private Achievements achievements;
|
||||||
private WeeklyStats weeklyStats;
|
private WeeklyStats weeklyStats;
|
||||||
|
private PlayerMemory playerMemory;
|
||||||
/** Gate for spontaneous AI lines; see Budget for why this is strict. */
|
/** Gate for spontaneous AI lines; see Budget for why this is strict. */
|
||||||
private Budget aiBudget;
|
private Budget aiBudget;
|
||||||
private BlueMapBridge blueMap;
|
private BlueMapBridge blueMap;
|
||||||
@@ -125,6 +126,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
achievements.syncCatalogue();
|
achievements.syncCatalogue();
|
||||||
milestones.resyncSilently();
|
milestones.resyncSilently();
|
||||||
weeklyStats = new WeeklyStats(new java.io.File(getDataFolder(), "semana.yml"));
|
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(),
|
aiBudget = new Budget(settings.aiSpontaneousPerDay(),
|
||||||
settings.aiSpontaneousGapMinutes() * 60_000L,
|
settings.aiSpontaneousGapMinutes() * 60_000L,
|
||||||
settings.aiSubjectCooldownMinutes() * 60_000L);
|
settings.aiSubjectCooldownMinutes() * 60_000L);
|
||||||
@@ -195,6 +197,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
return ai;
|
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. */
|
/** Recent public chat, for the AI's ambient context. Never null. */
|
||||||
ChatLog chatLog() {
|
ChatLog chatLog() {
|
||||||
return chatLog;
|
return chatLog;
|
||||||
@@ -667,11 +674,14 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String stats = offlineStats.summary(player.getUniqueId());
|
String stats = offlineStats.summary(player.getUniqueId());
|
||||||
|
Persona persona = playerMemory != null
|
||||||
|
? playerMemory.persona(player.getUniqueId(), settings.aiPersona())
|
||||||
|
: settings.aiPersona();
|
||||||
ai.saySomething(player.getName(),
|
ai.saySomething(player.getName(),
|
||||||
"O jogador " + player.getName() + " acabou de entrar no servidor."
|
"O jogador " + player.getName() + " acabou de entrar no servidor."
|
||||||
+ (stats == null ? "" : " Estatísticas dele: " + stats)
|
+ (stats == null ? "" : " Estatísticas dele: " + stats)
|
||||||
+ " Dê as boas-vindas do seu jeito, em uma frase.",
|
+ " Dê as boas-vindas do seu jeito e na sua personalidade, em uma frase curta.",
|
||||||
aiBudget);
|
aiBudget, persona);
|
||||||
}, Math.max(1, settings.joinDelaySeconds()) * 20L);
|
}, 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));
|
deathStreak.put(player.getUniqueId(), new Streak(count, now));
|
||||||
|
|
||||||
if (settings.aiEvents() && count >= settings.aiDeathStreak()) {
|
if (settings.aiEvents() && count >= settings.aiDeathStreak()) {
|
||||||
|
Persona persona = playerMemory != null
|
||||||
|
? playerMemory.persona(player.getUniqueId(), settings.aiPersona())
|
||||||
|
: settings.aiPersona();
|
||||||
ai.saySomething(player.getName(),
|
ai.saySomething(player.getName(),
|
||||||
"O jogador " + player.getName() + " morreu " + count
|
"O jogador " + player.getName() + " morreu " + count
|
||||||
+ " vezes seguidas em poucos minutos. A última foi assim: " + flavor
|
+ " vezes seguidas em poucos minutos. A última foi assim: " + flavor
|
||||||
+ ". Comente com deboche, sem ofender.",
|
+ ". Comente na sua personalidade, sem ofender de verdade.",
|
||||||
aiBudget);
|
aiBudget, persona);
|
||||||
// Reset so the next comment needs a fresh run rather than firing on
|
// Reset so the next comment needs a fresh run rather than firing on
|
||||||
// every death from here on.
|
// every death from here on.
|
||||||
deathStreak.remove(player.getUniqueId());
|
deathStreak.remove(player.getUniqueId());
|
||||||
|
|||||||
@@ -967,10 +967,20 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
iaCorrect(sender, args);
|
iaCorrect(sender, args);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Same guard as "perfil": only hijack when the next token is a real
|
if (sub.equals("status")) {
|
||||||
// persona name, so "/ia personalidade do zumbi?" stays a question.
|
iaStatus(sender);
|
||||||
if (sub.equals("personalidade")
|
return true;
|
||||||
&& (args.length == 1 || Persona.isValid(args[1]))) {
|
}
|
||||||
|
if (sub.equals("esquecer") || sub.equals("limpar")) {
|
||||||
|
iaForget(sender);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Personalities: player can switch for themselves (/ia persona <nome>)
|
||||||
|
// or reset to default (/ia persona padrao).
|
||||||
|
if ((sub.equals("personalidade") || sub.equals("persona"))
|
||||||
|
&& (args.length == 1 || args[1].equalsIgnoreCase("padrao")
|
||||||
|
|| args[1].equalsIgnoreCase("reset") || args[1].equalsIgnoreCase("global")
|
||||||
|
|| Persona.isValid(args[1]))) {
|
||||||
iaPersona(sender, args);
|
iaPersona(sender, args);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1025,33 +1035,138 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@code /ia personalidade [nome]} — shows the current tone, or switches it.
|
* {@code /ia persona [nome]} or {@code /ia personalidade [nome]}.
|
||||||
* Gated on {@code canalhandia.ia.perfil}, the same permission that controls
|
* Players can choose their personal AI persona or reset to server default.
|
||||||
* the other AI-tuning switch: both change how the AI behaves for everyone,
|
* Operators can set global default with {@code /ia persona global <nome>}.
|
||||||
* so they belong to the same set of people.
|
|
||||||
*/
|
*/
|
||||||
private void iaPersona(CommandSender sender, String[] args) {
|
private void iaPersona(CommandSender sender, String[] args) {
|
||||||
|
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()) {
|
||||||
|
String marker = persona == current ? "» " : " ";
|
||||||
|
Msg.line(sender, marker + persona.displayName() + " (" + persona.key() + ")",
|
||||||
|
persona.description());
|
||||||
|
}
|
||||||
|
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 <nome> | Para resetar: /ia persona padrao");
|
||||||
|
} else {
|
||||||
|
Msg.ok(sender, "Padrão global: " + globalDefault.displayName() + ". Uso: /ia persona <nome>");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.equals("global")) {
|
||||||
if (!sender.hasPermission("canalhandia.ia.perfil")) {
|
if (!sender.hasPermission("canalhandia.ia.perfil")) {
|
||||||
denied(sender);
|
denied(sender);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Persona current = plugin.settings().aiPersona();
|
if (args.length < 3) {
|
||||||
if (args.length < 2) {
|
Msg.error(sender, "Uso: /ia persona global <nome>");
|
||||||
Msg.header(sender, "Personalidades da IA");
|
|
||||||
for (Persona persona : Persona.values()) {
|
|
||||||
Msg.line(sender, (persona == current ? "> " : " ") + persona.key(),
|
|
||||||
persona.description());
|
|
||||||
}
|
|
||||||
Msg.ok(sender, "Atual: " + current.key() + ". Uso: /ia personalidade <nome>");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Persona persona = Persona.byKey(args[1]);
|
Persona p = Persona.byKey(args[2]);
|
||||||
if (persona == null) {
|
if (p == null) {
|
||||||
Msg.error(sender, "Personalidade desconhecida. Use /ia personalidade para ver a lista.");
|
Msg.error(sender, "Personalidade desconhecida. Use /ia persona para ver a lista.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
plugin.settings().aiPersona(persona);
|
plugin.settings().aiPersona(p);
|
||||||
Msg.ok(sender, "Personalidade da IA: " + persona.key() + " — " + persona.description());
|
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<String> 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 <nome> | 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 ----------------------------------------------------
|
// --- /nota and /save ----------------------------------------------------
|
||||||
@@ -1724,23 +1839,37 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
return filter(options, args[0]);
|
return filter(options, args[0]);
|
||||||
}
|
}
|
||||||
if (name.equals("ia") || name.equals("iap")) {
|
if (name.equals("ia") || name.equals("iap")) {
|
||||||
// Only the tuning subcommands are suggested — the rest of /ia is
|
if (args.length == 1) {
|
||||||
// free text, and completing a question would be noise.
|
List<String> subs = new ArrayList<>(List.of("persona", "personalidade", "status", "esquecer"));
|
||||||
if (args.length == 1 && sender.hasPermission("canalhandia.ia.perfil")) {
|
if (sender.hasPermission("canalhandia.ia.perfil")) {
|
||||||
return filter(List.of("personalidade", "perfil", "eventos", "saudacao"), args[0]);
|
subs.addAll(List.of("perfil", "eventos", "saudacao", "corrigir"));
|
||||||
}
|
}
|
||||||
if (args.length == 2 && args[0].equalsIgnoreCase("personalidade")) {
|
return filter(subs, args[0]);
|
||||||
|
}
|
||||||
|
if (args.length == 2 && (args[0].equalsIgnoreCase("personalidade") || args[0].equalsIgnoreCase("persona"))) {
|
||||||
|
List<String> 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<String> keys = new ArrayList<>();
|
List<String> keys = new ArrayList<>();
|
||||||
for (Persona persona : Persona.values()) {
|
for (Persona persona : Persona.values()) {
|
||||||
keys.add(persona.key());
|
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]);
|
return filter(List.of("economico", "preciso"), args[1]);
|
||||||
}
|
}
|
||||||
if (args.length == 2 && (args[0].equalsIgnoreCase("eventos")
|
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 filter(List.of("on", "off"), args[1]);
|
||||||
}
|
}
|
||||||
return List.of();
|
return List.of();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package dev.marcospaulo.canalhandia;
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
|
||||||
import java.util.Locale;
|
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
|
* that tried to contradict them would be overridden by the base prompt, which
|
||||||
* is sent first and repeated in {@link #GUARD}.
|
* is sent first and repeated in {@link #GUARD}.
|
||||||
*
|
*
|
||||||
* <p>Switchable live with {@code /ia personalidade <nome>}; no restart, because
|
* <p>Switchable live with {@code /ia personalidade <nome>} or per-player with
|
||||||
* {@link Settings#aiPersona()} is read on every question.
|
* {@code /ia persona <nome>}; no restart needed.
|
||||||
*/
|
*/
|
||||||
enum Persona {
|
enum Persona {
|
||||||
|
|
||||||
@@ -21,13 +23,15 @@ enum Persona {
|
|||||||
* Plain and helpful. The behaviour the plugin had before personas existed,
|
* Plain and helpful. The behaviour the plugin had before personas existed,
|
||||||
* kept so an operator can always get back to a neutral assistant.
|
* 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
|
* The default. A veteran of the server who has watched everyone die in
|
||||||
* stupid ways and is not going to pretend otherwise.
|
* 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, "
|
"Sua personalidade: você é um veterano ranzinza e brincalhão do servidor Canalhandia, "
|
||||||
+ "com anos de estrada e nenhuma paciência para pergunta preguiçosa. "
|
+ "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. "
|
+ "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
|
* Warmer than {@link #ZOEIRO}: helps first, teases rarely. For when the
|
||||||
* server has new players who would read constant ribbing as hostility.
|
* 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. "
|
"Sua personalidade: você é o amigo prestativo do servidor Canalhandia. "
|
||||||
+ "Tom caloroso e paciente, gíria brasileira leve, uma piadinha de vez em "
|
+ "Tom caloroso e paciente, gíria brasileira leve, uma piadinha de vez em "
|
||||||
+ "quando. Explique com calma para quem está começando. Nunca humilhe "
|
+ "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.
|
* 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 "
|
"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 "
|
+ "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 "
|
+ "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.
|
* 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, "
|
"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. "
|
+ "meio místico, usando \"jovem aventureiro\" e metáforas do mundo do jogo. "
|
||||||
+ "Mesmo em personagem, a resposta precisa ser correta e útil. "
|
+ "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}.
|
* Appended after every persona, including {@link #NEUTRO}.
|
||||||
@@ -86,12 +120,16 @@ enum Persona {
|
|||||||
+ "Mantenha texto puro, sem markdown nem emoji.";
|
+ "Mantenha texto puro, sem markdown nem emoji.";
|
||||||
|
|
||||||
private final String key;
|
private final String key;
|
||||||
|
private final String displayName;
|
||||||
private final String description;
|
private final String description;
|
||||||
|
private final NamedTextColor tagColor;
|
||||||
private final String instructions;
|
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.key = key;
|
||||||
|
this.displayName = displayName;
|
||||||
this.description = description;
|
this.description = description;
|
||||||
|
this.tagColor = tagColor;
|
||||||
this.instructions = instructions;
|
this.instructions = instructions;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +137,18 @@ enum Persona {
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String displayName() {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
String displayTag() {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
NamedTextColor tagColor() {
|
||||||
|
return tagColor;
|
||||||
|
}
|
||||||
|
|
||||||
String description() {
|
String description() {
|
||||||
return description;
|
return description;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Stores:
|
||||||
|
* <ul>
|
||||||
|
* <li>Personal AI persona preference (e.g. {@code judite}, {@code zoeiro})</li>
|
||||||
|
* <li>Compressed sliding summary of past conversations and questions</li>
|
||||||
|
* <li>Key facts and player preferences across server restarts</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>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<String> 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<String> newFacts) {
|
||||||
|
return new Profile(uuid, name, persona, summary, newFacts, System.currentTimeMillis());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final File file;
|
||||||
|
private final Map<UUID, Profile> 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<String> 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<String> facts(UUID uuid) {
|
||||||
|
synchronized (profiles) {
|
||||||
|
Profile p = profiles.get(uuid);
|
||||||
|
return p != null ? Collections.unmodifiableList(new ArrayList<>(p.facts())) : List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Condenses a conversation turn into the player's persistent summary.
|
||||||
|
*/
|
||||||
|
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<String> 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<UUID, Profile> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,12 @@ final class Tools {
|
|||||||
"parameters":{"type":"object","properties":{
|
"parameters":{"type":"object","properties":{
|
||||||
"jogador":{"type":"string","description":"Nome do jogador."}},
|
"jogador":{"type":"string","description":"Nome do jogador."}},
|
||||||
"required":["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":{
|
{"type":"function","function":{
|
||||||
"name":"ranking",
|
"name":"ranking",
|
||||||
"description":"O placar do servidor para uma métrica. Métricas: mineracao, tempo, distancia, mortes, combate, pesca, pulos.",
|
"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 "wiki" -> wikiArticle(string(args, "termo"));
|
||||||
case "estatisticas_jogador" -> playerStats(string(args, "jogador"));
|
case "estatisticas_jogador" -> playerStats(string(args, "jogador"));
|
||||||
case "conquistas_jogador" -> playerAchievements(string(args, "jogador"));
|
case "conquistas_jogador" -> playerAchievements(string(args, "jogador"));
|
||||||
|
case "lugares_jogador" -> playerPlaces(string(args, "jogador"));
|
||||||
case "ranking" -> ranking(string(args, "metrica"));
|
case "ranking" -> ranking(string(args, "metrica"));
|
||||||
default -> "ferramenta desconhecida: " + name;
|
default -> "ferramenta desconhecida: " + name;
|
||||||
};
|
};
|
||||||
@@ -132,6 +139,45 @@ final class Tools {
|
|||||||
return out.toString();
|
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<DeathLog.Entry> deaths = plugin.deathLog().forPlayer(uuidStr);
|
||||||
|
List<Note> 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) {
|
private String ranking(String metricKey) {
|
||||||
RankingMetric metric = RankingMetric.byKey(metricKey);
|
RankingMetric metric = RankingMetric.byKey(metricKey);
|
||||||
if (metric == null) {
|
if (metric == null) {
|
||||||
|
|||||||
@@ -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
|
@Test
|
||||||
void guardForbidsInventingStats() {
|
void guardForbidsInventingStats() {
|
||||||
// The AI is now fed real numbers; without this it would happily make up
|
// The AI is now fed real numbers; without this it would happily make up
|
||||||
|
|||||||
@@ -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<String> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user