Compare commits
35 Commits
dafd96a4b6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a21d3cfd5 | |||
| 2a63a04e4f | |||
| f35e4a5bff | |||
| 45f129df56 | |||
| aa56f385f8 | |||
| 60989cd72f | |||
| 6b2a73678f | |||
| 8ddc22409e | |||
| bc7a88b9e2 | |||
| 8a34565f3c | |||
| f24af7ab08 | |||
| b6e508c8c9 | |||
| cc5c983e41 | |||
| 07e5bb6018 | |||
| f2a19a729a | |||
| dfb84047ec | |||
| 8821e6d2c0 | |||
| 1ff830537e | |||
| da4a1777a7 | |||
| ce3b1ebbd9 | |||
| 5a6bb9a13d | |||
| 4b9507b36e | |||
| 4261e8b660 | |||
| 2c7f5f98ad | |||
| c3c4906e11 | |||
| d93711e5c1 | |||
| 24ab63b9be | |||
| e059ca6563 | |||
| e70df329b3 | |||
| e71babca1c | |||
| 01ada6d987 | |||
| 01e6c28fef | |||
| 998757d610 | |||
| 2220f11e64 | |||
| ea55813019 |
@@ -0,0 +1,53 @@
|
|||||||
|
## What
|
||||||
|
|
||||||
|
<!-- 1-2 sentences. What does this PR change? -->
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
<!-- Link the issue, design doc, or chat thread. Without a "why" the reviewer
|
||||||
|
has to guess whether the change is wanted at all. -->
|
||||||
|
|
||||||
|
Refs: #<!-- issue or N/A -->
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Commands the reviewer can run to reproduce / verify:
|
||||||
|
docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \
|
||||||
|
maven:3.9-eclipse-temurin-25 mvn -B test
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Manual test steps (if any): load a save, join a server, trigger a
|
||||||
|
death in the void, etc. State what you observed. -->
|
||||||
|
|
||||||
|
<!-- Server console output if relevant (translatable components render
|
||||||
|
in English there — that's normal). -->
|
||||||
|
|
||||||
|
## Risk
|
||||||
|
|
||||||
|
<!-- Which surface is touched? chat-only / item-loss / chunk-load / permissions.
|
||||||
|
Is a rollback plan needed? Does it need a config.yml default? -->
|
||||||
|
|
||||||
|
- [ ] chat message flow (immutable after send)
|
||||||
|
- [ ] pt-BR singular-form rule (`5.966 blocos de Pedra`, not `5.966 Pedras`)
|
||||||
|
- [ ] Bedrock equivalent (`comando:` typed fallback, `texto:` ASCII label)
|
||||||
|
- [ ] stats path (`<world>/players/stats`, not `<world>/stats`)
|
||||||
|
- [ ] reaction late-window (`reacao-validade-minutos`)
|
||||||
|
- [ ] Bukkit main thread (no async chunk loads in event handlers)
|
||||||
|
- [ ] new `comando:` added to `plugin.yml`
|
||||||
|
- [ ] new permission declared in `plugin.yml`
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] `mvn -B test` passes locally (JDK 25)
|
||||||
|
- [ ] new behaviour has a test (`src/test/java/...`)
|
||||||
|
- [ ] no hand edits to generated sources (none in this repo)
|
||||||
|
- [ ] PR title is conventional-commit style
|
||||||
|
- [ ] branch is up to date with `main`
|
||||||
|
|
||||||
|
## AI review
|
||||||
|
|
||||||
|
Label this PR **`AI-REVIEW`** to trigger `pragent`. Add **`AI-USAGE`** for a
|
||||||
|
token/cost report on the review. The bot reads `.pr-review.json` from `main`
|
||||||
|
+ a static `architecture.md` from Nexus raw-hosted — so house rules (chat
|
||||||
|
immutability, singular-form, Bedrock fallback, JDK 25) are baked in.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"focus": [
|
||||||
|
"correctness",
|
||||||
|
"security",
|
||||||
|
"performance"
|
||||||
|
],
|
||||||
|
"exclude_paths": [
|
||||||
|
"target/**",
|
||||||
|
"*.class"
|
||||||
|
],
|
||||||
|
"languages": [
|
||||||
|
"java"
|
||||||
|
],
|
||||||
|
"style": "balanced",
|
||||||
|
"require_tests": true,
|
||||||
|
"exclude_tests": false,
|
||||||
|
"max_findings": 15,
|
||||||
|
"severity_threshold": "low",
|
||||||
|
"patterns": {
|
||||||
|
"deny": [
|
||||||
|
"**/README.md",
|
||||||
|
"**/*.md"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"cost_target": "claude-sonnet-5",
|
||||||
|
"additional_context_urls": [
|
||||||
|
"http://nexus-service.nexus.svc.cluster.local:8081/repository/raw-hosted/canalhandia/architecture.md"
|
||||||
|
],
|
||||||
|
"instructions": "Minecraft plugin (Paper 26.2, pt-BR, JDK 25 build). Chat-only — never touch gameplay. Watch thread-safety on event handlers (PlayerDeathEvent, PlayerInteractEvent) — the Bukkit main thread is single-threaded but async chunks/events cross it. Avoid main-thread I/O; defer expensive scans (chunk loading, spiral search) to scheduled tasks or async paths. Flag mutable shared state across listener invocations. Hard constraints: chat messages are immutable after send (counts baked into buttons freeze at send time); names go out as translatable components so the singular-form rule applies (number never agrees with the noun); Geyser/Bedrock cannot click and cannot show emoji (every click has a typed fallback); vanilla statistics are the only data source (offline path is <world>/players/stats/<uuid>.json, NOT <world>/stats); reactions keep counting late (reacao-validade-minutos); Floodgate is optional runtime dep. Flag: real bugs, missing persistence of new settings, comando/permission not in plugin.yml, breaking Bedrock equivalent invariant, removing the frozen-at-send assumption, violating singular-form rule, missing Stats.resolve() on renames."
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Contributing — Canalhandia (Canalhandia Minecraft plugin)
|
||||||
|
|
||||||
|
Canalhandia is a Minecraft Paper plugin that adds PT-BR chat games, item
|
||||||
|
protection, and other in-game commands for the Canalhandia server. This repo
|
||||||
|
holds the plugin source. The companion server config lives at
|
||||||
|
`gitea_admin/canalhandia-config`.
|
||||||
|
|
||||||
|
## Before you open a PR
|
||||||
|
|
||||||
|
1. **Fill in the PR template** (`.gitea/pull_request_template.md`). The
|
||||||
|
*Risk* checklist is the part pragent scores you on — check the boxes that
|
||||||
|
apply, delete the ones that don't.
|
||||||
|
2. **Update the static context** if your change introduces new house rules.
|
||||||
|
See `## Adding house rules` below.
|
||||||
|
3. **Run the gates locally** (see *Test commands*). CI is a single
|
||||||
|
`mvn -B test` against JDK 25 — there's no other infra in this repo.
|
||||||
|
|
||||||
|
## Test commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the test suite (JDK 25 required). Same command CI uses:
|
||||||
|
mvn -B test
|
||||||
|
|
||||||
|
# Compile only (faster):
|
||||||
|
mvn -B -DskipTests package
|
||||||
|
|
||||||
|
# Integration: copy the resulting jar into a test Paper server's plugins/
|
||||||
|
# directory, start the server, exercise the affected commands. The chat
|
||||||
|
# and stats paths are NOT covered by unit tests — they need a live server.
|
||||||
|
```
|
||||||
|
|
||||||
|
### What counts as "tested"
|
||||||
|
|
||||||
|
For pragent (and reviewers) to consider a change tested, the PR must show:
|
||||||
|
|
||||||
|
- [ ] `mvn -B test` output from your machine pasted in the PR, **OR** a CI
|
||||||
|
run URL.
|
||||||
|
- [ ] A new behaviour has a JUnit test in `src/test/java/...` that exercises
|
||||||
|
the new code path. New `comando:` blocks need at least one test that
|
||||||
|
sends a fake event and asserts the message format.
|
||||||
|
- [ ] If the change touches the chat immutability rule, the singular-form
|
||||||
|
rule, or the Bedrock fallback, a manual test on a Paper server is
|
||||||
|
required **and** a one-line note pasted in the PR.
|
||||||
|
- [ ] If the change touches `<world>/players/stats`, you must show the
|
||||||
|
file path on a running server (`ls <world>/players/stats/*.json`)
|
||||||
|
and confirm the stats file is updated, not the wrong path.
|
||||||
|
|
||||||
|
### Out of scope for tests
|
||||||
|
|
||||||
|
- Performance / load tests (none in this repo).
|
||||||
|
- E2E against the live server (manual; verify in `canalhandia-config` repo).
|
||||||
|
- UI / browser tests (no web surface).
|
||||||
|
|
||||||
|
## Commit messages
|
||||||
|
|
||||||
|
Conventional commits. Examples:
|
||||||
|
|
||||||
|
- `feat: add per-player cobblestone counter command`
|
||||||
|
- `fix(void): keep inventory when dying in lava void`
|
||||||
|
- `docs: update README build instructions`
|
||||||
|
- `chore: bump pom parent to 1.4.2`
|
||||||
|
|
||||||
|
## Branch names
|
||||||
|
|
||||||
|
`<type>/<short-kebab-description>` matching commit type. Example:
|
||||||
|
`fix/void-keep-inventory`.
|
||||||
|
|
||||||
|
## PR review
|
||||||
|
|
||||||
|
- The bot (`pragent`) reviews every PR labelled `AI-REVIEW`. It reads
|
||||||
|
`.pr-review.json` from `main` + the static `architecture.md` from Nexus
|
||||||
|
raw-hosted. Adding a new house rule? Update both.
|
||||||
|
- Add the `AI-USAGE` label if you want the bot to also post a token /
|
||||||
|
cost-equivalent report on the review.
|
||||||
|
|
||||||
|
## Adding house rules
|
||||||
|
|
||||||
|
If your PR changes how translatable components behave, how chat edits work,
|
||||||
|
how stats are read, or how the Bedrock fallback is rendered, you must:
|
||||||
|
|
||||||
|
1. Update the relevant section in the static `architecture.md` (upload a new
|
||||||
|
copy to Nexus raw-hosted at `canalhandia/architecture.md`).
|
||||||
|
2. Bump `.pr-review.json:instructions` with a one-paragraph summary.
|
||||||
|
3. Add a finding-checklist item to `.gitea/pull_request_template.md` *Risk*
|
||||||
|
section so PR authors know to confirm the rule.
|
||||||
|
|
||||||
|
Bot does not detect house-rule drift automatically — these three updates
|
||||||
|
together are the maintainer contract.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
A single PR that bumps the plugin jar in `canalhandia-config` rolls back.
|
||||||
|
Plugins are jar-swapped, no migrations.
|
||||||
@@ -610,3 +610,5 @@ phrase it so the count never has to agree with a translated noun.
|
|||||||
Statistic constants get renamed between Minecraft releases, so resolve them via
|
Statistic constants get renamed between Minecraft releases, so resolve them via
|
||||||
`Stats.resolve("NEW_NAME", "OLD_NAME")` — a rename then degrades one curiosity
|
`Stats.resolve("NEW_NAME", "OLD_NAME")` — a rename then degrades one curiosity
|
||||||
instead of breaking the whole announcement.
|
instead of breaking the whole announcement.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Plan: Módulo Nativo de Chunk Loader
|
||||||
|
|
||||||
|
## Arquitetura
|
||||||
|
1. **`ChunkLoader.java` (Record puro)**
|
||||||
|
- `id`, `ownerUuid`, `ownerName`, `world`, `x`, `y`, `z`, `chunkX`, `chunkZ`, `createdAt`.
|
||||||
|
- Métodos utilitários: `chunkCoords()`, `blockCoords()`, etc.
|
||||||
|
|
||||||
|
2. **`ChunkLoaders.java` (Gerenciador e Persistência)**
|
||||||
|
- Gerencia lista em memória sincronizada `List<ChunkLoader>`.
|
||||||
|
- Persistência em `plugins/Canalhandia/chunks.yml` com salvamento assíncrono.
|
||||||
|
- Métodos: `add()`, `remove()`, `byChunk()`, `byOwner()`, `loadAll()`, `unloadAll()`, `playerLimit(Player)`.
|
||||||
|
- Executa `world.addPluginChunkTicket` e `world.removePluginChunkTicket`.
|
||||||
|
|
||||||
|
3. **`Module.java` & `Settings.java`**
|
||||||
|
- Adiciona `CHUNKLOADER("chunkloader", "Âncoras de carregamento contínuo de chunks")` no enum `Module`.
|
||||||
|
- Adiciona configurações em `Settings.java` (limite padrão, material do bloco, raio de partículas).
|
||||||
|
|
||||||
|
4. **`ChunkLoaderListener.java` (Eventos do Mundo)**
|
||||||
|
- `BlockPlaceEvent`: Detecta colocação da Âncora, verifica permissões/limites LuckPerms, registra loader e ticket.
|
||||||
|
- `BlockBreakEvent`: Protege quebra por não-donos, remove ticket e devolve o item customizado.
|
||||||
|
- `BlockExplodeEvent` / `EntityExplodeEvent`: Impede destruição por explosão.
|
||||||
|
- `BlockPistonExtendEvent` / `BlockPistonRetractEvent`: Impede movimentação por pistão.
|
||||||
|
|
||||||
|
5. **`CanalhandiaCommand.java` & `ChunkLoaderCommand.java`**
|
||||||
|
- Subcomandos de `/chunkloader` / `/ancora` e tab-completion completo.
|
||||||
|
|
||||||
|
6. **`BlueMapBridge.java`**
|
||||||
|
- Cria conjunto de marcadores para chunk loaders no mapa web.
|
||||||
|
|
||||||
|
7. **Testes Unitários (`ChunkLoaderTest.java`)**
|
||||||
|
- Validação de regras de permissão, cálculo de limite, serialização em YAML e exclusão de duplicatas na mesma chunk.
|
||||||
|
|
||||||
|
## Riscos & Mitigações
|
||||||
|
- **Risco:** Descarregamento incorreto no shutdown do servidor gerando tickets órfãos.
|
||||||
|
- **Mitigação:** `unloadAll()` limpo em `onDisable()`, e tickets do Paper (`PluginChunkTicket`) são re-validados no `onEnable()`.
|
||||||
|
- **Risco:** Jogador contornar limite colocando em mundos não permitidos ou múltiplas na mesma chunk.
|
||||||
|
- **Mitigação:** Validação estrita de unicidade de chunk (`byChunk(world, cx, cz) != null`) e verificação de limite antes de aceitar o evento.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Spec: Módulo Nativo de Chunk Loader (Canalhandia)
|
||||||
|
|
||||||
|
## 1. Visão Geral
|
||||||
|
Adiciona ao plugin `Canalhandia` um módulo nativo, performático e equilibrado de **Chunk Loading** para o Paper 1.21.x.
|
||||||
|
Permite que jogadores mantenham áreas/chunks específicas carregadas para farms, redstone e sistemas automatizados usando a API nativa de tickets do Paper (`addPluginChunkTicket`), com permissões granulares e limites por cargo configuráveis via **LuckPerms**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Requisitos e Mecânicas
|
||||||
|
|
||||||
|
### 2.1. Bloco e Item Customizado: "Âncora de Chunk" (Chunk Anchor)
|
||||||
|
- **Item Base:** `RESPAWN_ANCHOR` ou `LODESTONE` com nome formatado (`§b§lÂncora de Chunk`), lore explicativa e `PersistentDataContainer` identificando o item customizado.
|
||||||
|
- **Receita de Crafting:**
|
||||||
|
- Configurada em `config.yml` (ex: 4 Obsidianas Choronas, 4 Diamantes, 1 Estrela do Nether ou Olho do Fim).
|
||||||
|
- Desbloqueia automaticamente no livro de receitas ao obter os ingredientes.
|
||||||
|
|
||||||
|
### 2.2. Colocação e Restrições
|
||||||
|
- Ao colocar o bloco (`BlockPlaceEvent`):
|
||||||
|
- Verifica se o jogador possui permissão `canalhandia.chunkloader`.
|
||||||
|
- Verifica o limite de loaders do jogador no LuckPerms:
|
||||||
|
- Lê permissões numéricas: `canalhandia.chunkloader.limite.<N>` (ex: `limite.1`, `limite.2`, `limite.5`, `limite.10`).
|
||||||
|
- Pega o maior `<N>` encontrado nas permissões do jogador.
|
||||||
|
- Se não houver permissão numérica explícita, usa `chunkloader.limite-padrao` do `config.yml` (padrão: 1).
|
||||||
|
- Administradores com `canalhandia.admin` não têm limite.
|
||||||
|
- Verifica se já existe um loader ativo na mesma chunk (máximo 1 por chunk).
|
||||||
|
- Se aprovado:
|
||||||
|
- Ativa o ticket na chunk: `world.addPluginChunkTicket(chunkX, chunkZ, plugin)`.
|
||||||
|
- Salva em `chunks.yml`.
|
||||||
|
- Cria partícula/efeito sonoro de ativação.
|
||||||
|
- Registra marcador no BlueMap (opcional/configurável).
|
||||||
|
- Envia mensagem de confirmação informando quantas âncoras o jogador está usando (ex: `1/3 ativas`).
|
||||||
|
|
||||||
|
### 2.3. Remoção e Proteção
|
||||||
|
- **Proteção:** Apenas o dono da âncora ou administradores (`canalhandia.admin`) podem quebrar o bloco (`BlockBreakEvent`).
|
||||||
|
- **Explosões / Pistões:** Protegido contra destruição acidental por TNT/Creeper (`EntityExplodeEvent`, `BlockExplodeEvent`) e empurrão por pistão (`BlockPistonExtendEvent`).
|
||||||
|
- Ao quebrar:
|
||||||
|
- Remove o ticket de chunk do Paper: `world.removePluginChunkTicket(chunkX, chunkZ, plugin)`.
|
||||||
|
- Remove de `chunks.yml` e do BlueMap.
|
||||||
|
- Devolve o item "Âncora de Chunk" ao jogador.
|
||||||
|
|
||||||
|
### 2.4. Persistência e Ciclo de Vida
|
||||||
|
- Arquivo `plugins/Canalhandia/chunks.yml`:
|
||||||
|
- Armazena ID, UUID do dono, nome, mundo, coordenadas (x, y, z), chunk (cx, cz) e data de criação.
|
||||||
|
- **No `onEnable()` do plugin:** Carrega `chunks.yml` e registra `addPluginChunkTicket` em todas as chunks salvas.
|
||||||
|
- **No `onDisable()` do plugin:** Remove os tickets do plugin de forma limpa.
|
||||||
|
|
||||||
|
### 2.5. Comandos (`/chunkloader` ou `/ancora`)
|
||||||
|
- `/chunkloader` ou `/ancora`:
|
||||||
|
- `/chunkloader info` — Mostra o status da chunk atual (se está carregada por um loader e por quem) e seus limites de uso.
|
||||||
|
- `/chunkloader listar` — Lista todas as âncoras ativas do jogador com coordenadas e link para deletar/desativar.
|
||||||
|
- `/chunkloader remover <id>` — Desativa remotamente uma âncora do próprio jogador.
|
||||||
|
- `/chunkloader receita` — Mostra a receita de crafting.
|
||||||
|
- `/chunkloader admin listar [jogador]` — (Admin) Lista todos os chunk loaders do servidor.
|
||||||
|
- `/chunkloader admin remover <id>` — (Admin) Força a remoção de qualquer loader.
|
||||||
|
- `/chunkloader reload` — (Admin) Recarrega configurações e sincroniza tickets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Integração com LuckPerms
|
||||||
|
Nós de permissão:
|
||||||
|
- `canalhandia.chunkloader` — Habilita o jogador a craftar, colocar e gerenciar âncoras.
|
||||||
|
- `canalhandia.chunkloader.limite.<N>` — Define o limite máximo de âncoras ativas para o cargo (ex: `canalhandia.chunkloader.limite.3` para VIP).
|
||||||
|
- `canalhandia.chunkloader.admin` — Acesso total aos comandos administrativos de chunk loading.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Critérios de Aceite
|
||||||
|
1. Módulo pode ser ativado/desativado via `config.yml` e `/canalhandia modulo chunkloader`.
|
||||||
|
2. Bloco colocado registra ticket via `world.addPluginChunkTicket` que sobrevive a reboots via `chunks.yml`.
|
||||||
|
3. Limites de permissão do LuckPerms são respeitados estritamente.
|
||||||
|
4. Blocos não podem ser roubados ou quebrados por terceiros.
|
||||||
|
5. Suíte de testes unitários (`ChunkLoaderTest.java`) com 100% de aprovação.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Tasks: Módulo Nativo de Chunk Loader
|
||||||
|
|
||||||
|
- [x] **Task 1: Modelo de Dados e Gerenciador (`ChunkLoader.java`, `ChunkLoaders.java` e `ChunkLoaderTest.java`)**
|
||||||
|
- Implementar record `ChunkLoader` puro.
|
||||||
|
- Implementar `ChunkLoaders` com persistência em `chunks.yml`, `playerLimit(Player)` (LuckPerms `canalhandia.chunkloader.limite.<N>`), registro de tickets no Paper e busca por chunk/dono.
|
||||||
|
- Criar suíte de testes unitários `ChunkLoaderTest.java` validando regras de limites, unicidade e serialização.
|
||||||
|
|
||||||
|
- [x] **Task 2: Configuração e Item Customizado (`Module.java`, `Settings.java` e `ChunkAnchorItem.java`)**
|
||||||
|
- Adicionar `CHUNKLOADER` ao enum `Module`.
|
||||||
|
- Adicionar chaves de configuração em `config.yml` e `Settings.java`.
|
||||||
|
- Criar utilitário `ChunkAnchorItem` para gerar o ItemStack com nome, lore e `PersistentDataContainer`, e registrar receita de crafting.
|
||||||
|
|
||||||
|
- [x] **Task 3: Listeners de Proteção e Colocação (`ChunkLoaderListener.java`)**
|
||||||
|
- Tratar `BlockPlaceEvent` (validação de permissão, cálculo de limite LuckPerms, ativação de ticket).
|
||||||
|
- Tratar `BlockBreakEvent` (proteção de dono/admin, remoção de ticket, drop do item).
|
||||||
|
- Tratar explosões e pistões.
|
||||||
|
|
||||||
|
- [x] **Task 4: Comandos e Tab-Completion (`CanalhandiaCommand.java`)**
|
||||||
|
- Adicionar `/chunkloader` e alias `/ancora` (`listar`, `info`, `remover`, `receita`, `admin`).
|
||||||
|
- Implementar tab-completion completo com permissões.
|
||||||
|
|
||||||
|
- [x] **Task 5: Integração no Ciclo de Vida e BlueMap (`Canalhandia.java` & `BlueMapBridge.java`)**
|
||||||
|
- Inicializar `ChunkLoaders` no `onEnable` e descarregar tickets no `onDisable`.
|
||||||
|
- Adicionar marcadores no BlueMap.
|
||||||
|
|
||||||
|
- [x] **Task 6: Testes, PR e Deploy (`mvn test`, `mvn package`, PR no Gitea)**
|
||||||
|
- Executar suíte completa de testes.
|
||||||
|
- Criar branch `feat/chunk-loader-module`, abrir PR com labels `AI-REVIEW` e `AI-USAGE`.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Spec: Proteção de Itens no Vácuo (Void Protection)
|
||||||
|
|
||||||
|
## 1. Problema e Motivação
|
||||||
|
Quando um jogador morre no vácuo (caindo no The End, Nether ou Overworld profundo), todos os itens e armaduras caem abaixo de Y = -64 e são excluídos pelo motor do Minecraft, impossibilitando qualquer recuperação legítima e gerando frustração.
|
||||||
|
|
||||||
|
## 2. Solução
|
||||||
|
Implementar o módulo `salvavoid` no plugin Canalhandia:
|
||||||
|
1. **Detecção:** Identifica mortes por vácuo (`DamageCause.VOID` ou coordenada Y abaixo da altura mínima do mundo).
|
||||||
|
2. **Busca de Terreno Seguro:** Procura o bloco sólido mais próximo da posição horizontal onde o jogador caiu (raio configurável, padrão 32 blocos).
|
||||||
|
3. **Opção A (Baú de Resgate):** Se encontrar terreno seguro:
|
||||||
|
- Cria um baú (ou baú duplo se necessário) sobre o bloco seguro.
|
||||||
|
- Guarda todos os itens e armaduras do jogador dentro do baú.
|
||||||
|
- Limpa os drops do evento de morte (para não cair no vácuo).
|
||||||
|
- Informa ao jogador a localização exata (coordenadas X, Y, Z) do baú no chat.
|
||||||
|
4. **Opção B (Preservação Direta no Inventário):** Se NÃO houver nenhum bloco seguro por perto (ex: caiu no meio do vácuo infinito do End):
|
||||||
|
- Preserva o inventário e nível de XP do jogador (`keepInventory = true`, `keepLevel = true`).
|
||||||
|
- Limpa os drops do evento de morte.
|
||||||
|
- Envia mensagem confortando o jogador e avisando que os itens foram mantidos no inventário.
|
||||||
|
|
||||||
|
## 3. Critérios de Aceitação
|
||||||
|
- Módulo integrado ao `/canalhandia modulo salvavoid <on|off>`.
|
||||||
|
- Configurações em `Settings.java`: `voidProtectionEnabled`, `voidProtectionRadius`, `voidProtectionKeepXp`.
|
||||||
|
- Testes unitários cobrindo detecção de morte no vácuo, cálculo de busca e empacotamento de inventário.
|
||||||
@@ -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) {
|
||||||
@@ -425,20 +437,27 @@ final class Ai {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String clean = String.join(" ", segments);
|
String clean = String.join(" ", segments);
|
||||||
|
lastAnswer = new Answered(askerId, question, clean);
|
||||||
// Only remember if the asker is still online: a PlayerQuitEvent forgets
|
// Only remember if the asker is still online: a PlayerQuitEvent forgets
|
||||||
// the player's history (carry-forward #6), and re-adding here after the
|
// the player's history (carry-forward #6), and re-adding here after the
|
||||||
// quit would resurrect it. lastAnswer stays regardless, so /ia corrigir
|
// quit would resurrect it. lastAnswer stays regardless, so /ia corrigir
|
||||||
// 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) {
|
||||||
|
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 (isPrivate || !settings.aiPublic()) {
|
||||||
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.aiFancy(), bedrock, i == 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -450,48 +469,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.aiFancy(), 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) {
|
static Component style(String answer, String question, Persona persona, boolean fancy, 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 && fancy) {
|
||||||
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 +504,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 +530,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 +562,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))));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,18 +27,26 @@ final class BlueMapBridge {
|
|||||||
/** Id and label of the marker set this plugin owns on the map. */
|
/** Id and label of the marker set this plugin owns on the map. */
|
||||||
private static final String SET_ID = "canalhandia-notas";
|
private static final String SET_ID = "canalhandia-notas";
|
||||||
private static final String SET_LABEL = "Anotações";
|
private static final String SET_LABEL = "Anotações";
|
||||||
|
private static final String SET_LOADERS_ID = "canalhandia-chunkloaders";
|
||||||
|
private static final String SET_LOADERS_LABEL = "Âncoras de Chunk";
|
||||||
|
|
||||||
private final Notes notes;
|
private final Notes notes;
|
||||||
|
private final java.util.function.Supplier<ChunkLoaders> chunkLoaders;
|
||||||
private final Logger logger;
|
private final Logger logger;
|
||||||
private final java.util.function.BooleanSupplier enabled;
|
private final java.util.function.BooleanSupplier enabled;
|
||||||
|
private final java.util.function.BooleanSupplier chunkLoadersEnabled;
|
||||||
|
|
||||||
/** False once we know BlueMap is not usable, so we stop retrying. */
|
/** False once we know BlueMap is not usable, so we stop retrying. */
|
||||||
private boolean available = true;
|
private boolean available = true;
|
||||||
|
|
||||||
BlueMapBridge(Notes notes, Logger logger, java.util.function.BooleanSupplier enabled) {
|
BlueMapBridge(Notes notes, java.util.function.Supplier<ChunkLoaders> chunkLoaders,
|
||||||
|
Logger logger, java.util.function.BooleanSupplier enabled,
|
||||||
|
java.util.function.BooleanSupplier chunkLoadersEnabled) {
|
||||||
this.notes = notes;
|
this.notes = notes;
|
||||||
|
this.chunkLoaders = chunkLoaders;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.enabled = enabled;
|
this.enabled = enabled;
|
||||||
|
this.chunkLoadersEnabled = chunkLoadersEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,20 +55,19 @@ final class BlueMapBridge {
|
|||||||
*/
|
*/
|
||||||
void hook() {
|
void hook() {
|
||||||
try {
|
try {
|
||||||
de.bluecolored.bluemap.api.BlueMapAPI.onEnable(api -> sync());
|
de.bluecolored.bluemap.api.BlueMapAPI.onEnable(api -> {
|
||||||
logger.info("BlueMap encontrado — anotações públicas vão para o mapa.");
|
sync();
|
||||||
|
syncChunkLoaders();
|
||||||
|
});
|
||||||
|
logger.info("BlueMap encontrado — marcadores vão para o mapa.");
|
||||||
} catch (NoClassDefFoundError | Exception e) {
|
} catch (NoClassDefFoundError | Exception e) {
|
||||||
available = false;
|
available = false;
|
||||||
logger.fine("BlueMap não está instalado; anotações ficam só no chat.");
|
logger.fine("BlueMap não está instalado; marcadores desativados.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rebuilds the marker set from the current public notes.
|
* Rebuilds the marker set from the current public notes.
|
||||||
*
|
|
||||||
* <p>Rebuild rather than incremental add/remove: the note list is tiny, and
|
|
||||||
* a full rebuild cannot drift out of sync with the notes file the way a
|
|
||||||
* missed delete would.
|
|
||||||
*/
|
*/
|
||||||
void sync() {
|
void sync() {
|
||||||
if (!available || !enabled.getAsBoolean()) {
|
if (!available || !enabled.getAsBoolean()) {
|
||||||
@@ -79,9 +86,6 @@ final class BlueMapBridge {
|
|||||||
.label(SET_LABEL)
|
.label(SET_LABEL)
|
||||||
.build();
|
.build();
|
||||||
for (Note note : publicNotes) {
|
for (Note note : publicNotes) {
|
||||||
// Only notes from the world this map renders. Without the
|
|
||||||
// check, a Nether note would be drawn at the same numeric
|
|
||||||
// coordinates in the overworld map, pointing at nothing.
|
|
||||||
if (!sameWorld(map, note.world())) {
|
if (!sameWorld(map, note.world())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -96,13 +100,66 @@ final class BlueMapBridge {
|
|||||||
map.getMarkerSets().put(SET_ID, set);
|
map.getMarkerSets().put(SET_ID, set);
|
||||||
}
|
}
|
||||||
} catch (NoClassDefFoundError | Exception e) {
|
} catch (NoClassDefFoundError | Exception e) {
|
||||||
// One line, then stop trying: a broken bridge must never turn into
|
|
||||||
// a log flood on every note edit.
|
|
||||||
available = false;
|
available = false;
|
||||||
logger.warning("Não consegui atualizar os marcadores do BlueMap: " + e);
|
logger.warning("Não consegui atualizar os marcadores do BlueMap: " + e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void syncChunkLoaders() {
|
||||||
|
if (!available || !chunkLoadersEnabled.getAsBoolean() || chunkLoaders == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ChunkLoaders loaders = chunkLoaders.get();
|
||||||
|
if (loaders == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var maybeApi = de.bluecolored.bluemap.api.BlueMapAPI.getInstance();
|
||||||
|
if (maybeApi.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var api = maybeApi.get();
|
||||||
|
for (var map : api.getMaps()) {
|
||||||
|
var set = de.bluecolored.bluemap.api.markers.MarkerSet.builder()
|
||||||
|
.label(SET_LOADERS_LABEL)
|
||||||
|
.build();
|
||||||
|
for (ChunkLoader loader : loaders.all()) {
|
||||||
|
if (!mapMatchesWorld(map, loader.world())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String statusHtml = loader.enabled()
|
||||||
|
? "<span style='color:#55ff55'>Ativo</span>"
|
||||||
|
: "<span style='color:#ff5555'>Pausado</span>";
|
||||||
|
var marker = de.bluecolored.bluemap.api.markers.POIMarker.builder()
|
||||||
|
.label(loader.simpleName() + " (" + loader.ownerName() + ")" + (loader.enabled() ? "" : " [Pausado]"))
|
||||||
|
.detail("<b>" + escape(loader.displayName()) + "</b><br>Status: "
|
||||||
|
+ statusHtml + "<br>Dono: "
|
||||||
|
+ escape(loader.ownerName()) + "<br>Chunk: " + loader.chunkCoords())
|
||||||
|
.position(loader.x(), loader.y(), loader.z())
|
||||||
|
.build();
|
||||||
|
set.getMarkers().put("loader-" + loader.id(), marker);
|
||||||
|
}
|
||||||
|
map.getMarkerSets().put(SET_LOADERS_ID, set);
|
||||||
|
}
|
||||||
|
} catch (NoClassDefFoundError | Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean mapMatchesWorld(de.bluecolored.bluemap.api.BlueMapMap map, String world) {
|
||||||
|
if (world == null || world.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String mapId = map.getId().toLowerCase(java.util.Locale.ROOT);
|
||||||
|
String w = world.toLowerCase(java.util.Locale.ROOT);
|
||||||
|
if (w.endsWith("_nether") || w.equals("nether") || w.equals("world_nether")) {
|
||||||
|
return mapId.contains("nether");
|
||||||
|
}
|
||||||
|
if (w.endsWith("_the_end") || w.equals("the_end") || w.equals("world_the_end") || w.equals("end")) {
|
||||||
|
return mapId.contains("end");
|
||||||
|
}
|
||||||
|
return !mapId.contains("nether") && !mapId.contains("end");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a map renders the world a note was written in.
|
* Whether a map renders the world a note was written in.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ 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;
|
||||||
|
private ChunkLoaders chunkLoaders;
|
||||||
/** 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,13 +127,21 @@ 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"));
|
||||||
|
chunkLoaders = new ChunkLoaders(this, new java.io.File(getDataFolder(), "chunks.yml"));
|
||||||
|
if (settings.moduleEnabled(Module.CHUNKLOADER)) {
|
||||||
|
chunkLoaders.loadAllTickets();
|
||||||
|
chunkLoaders.startSimulation();
|
||||||
|
ChunkAnchorItem.registerRecipe(this);
|
||||||
|
}
|
||||||
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);
|
||||||
ai = new Ai(this);
|
ai = new Ai(this);
|
||||||
// Optional: does nothing (and logs nothing loud) without BlueMap.
|
// Optional: does nothing (and logs nothing loud) without BlueMap.
|
||||||
blueMap = new BlueMapBridge(notes, getLogger(),
|
blueMap = new BlueMapBridge(notes, () -> chunkLoaders, getLogger(),
|
||||||
() -> settings.moduleEnabled(Module.NOTAS) && settings.notesOnMap());
|
() -> settings.moduleEnabled(Module.NOTAS) && settings.notesOnMap(),
|
||||||
|
() -> settings.moduleEnabled(Module.CHUNKLOADER) && settings.chunkLoaderBlueMap());
|
||||||
blueMap.hook();
|
blueMap.hook();
|
||||||
// Snapshot the server's recipes on the main thread; RecipeBook.describe
|
// Snapshot the server's recipes on the main thread; RecipeBook.describe
|
||||||
// reads from the async answer path and Bukkit.recipeIterator() is not
|
// reads from the async answer path and Bukkit.recipeIterator() is not
|
||||||
@@ -144,12 +154,14 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking",
|
for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking",
|
||||||
"reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia", "iap",
|
"reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia", "iap",
|
||||||
"errado", "nota", "save", "recado", "recados", "mortes", "conquistas",
|
"errado", "nota", "save", "recado", "recados", "mortes", "conquistas",
|
||||||
"perfil", "titulo")) {
|
"perfil", "titulo", "chunkloader")) {
|
||||||
register(name, root);
|
register(name, root);
|
||||||
}
|
}
|
||||||
|
|
||||||
getServer().getPluginManager().registerEvents(this, this);
|
getServer().getPluginManager().registerEvents(this, this);
|
||||||
getServer().getPluginManager().registerEvents(new TitleChatListener(this), this);
|
getServer().getPluginManager().registerEvents(new TitleChatListener(this), this);
|
||||||
|
getServer().getPluginManager().registerEvents(new ChunkLoaderListener(this), this);
|
||||||
|
getServer().getPluginManager().registerEvents(new XpAndItemMergeListener(this), this);
|
||||||
rescheduleTimer();
|
rescheduleTimer();
|
||||||
rescheduleMilestones();
|
rescheduleMilestones();
|
||||||
|
|
||||||
@@ -181,6 +193,12 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
if (poll != null) {
|
if (poll != null) {
|
||||||
poll.hide();
|
poll.hide();
|
||||||
}
|
}
|
||||||
|
if (chunkLoaders != null) {
|
||||||
|
chunkLoaders.stopSimulation();
|
||||||
|
chunkLoaders.unloadAllTickets();
|
||||||
|
chunkLoaders.close();
|
||||||
|
ChunkAnchorItem.unregisterRecipe(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Settings settings() {
|
Settings settings() {
|
||||||
@@ -195,6 +213,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;
|
||||||
@@ -220,6 +243,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
return achievements;
|
return achievements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Active chunk loaders. Never null. */
|
||||||
|
ChunkLoaders chunkLoaders() {
|
||||||
|
return chunkLoaders;
|
||||||
|
}
|
||||||
|
|
||||||
/** The title each player has chosen to wear in chat. Never null. */
|
/** The title each player has chosen to wear in chat. Never null. */
|
||||||
Titles titles() {
|
Titles titles() {
|
||||||
return titles;
|
return titles;
|
||||||
@@ -667,11 +695,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,6 +840,60 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
}, settings.reactionWindowSeconds() * 20L);
|
}, settings.reactionWindowSeconds() * 20L);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rescues player items when falling into the void. Places a chest on the nearest
|
||||||
|
* safe ground block, or preserves items directly in the inventory if no solid ground is nearby.
|
||||||
|
*/
|
||||||
|
@EventHandler(priority = EventPriority.HIGH)
|
||||||
|
public void onVoidDeath(PlayerDeathEvent event) {
|
||||||
|
if (!settings.moduleEnabled(Module.SALVAVOID)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Player player = event.getEntity();
|
||||||
|
EntityDamageEvent damage = player.getLastDamageCause();
|
||||||
|
EntityDamageEvent.DamageCause cause = damage == null ? null : damage.getCause();
|
||||||
|
|
||||||
|
if (!VoidProtection.isVoidDeath(player.getLocation().getY(), player.getWorld().getMinHeight(), cause)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ItemStack> drops = new ArrayList<>(event.getDrops());
|
||||||
|
Location safeLoc = VoidProtection.findSafeChestLocation(player.getWorld(), player.getLocation(), settings.voidProtectionRadius());
|
||||||
|
|
||||||
|
if (safeLoc != null && !drops.isEmpty() && VoidProtection.rescueToChest(drops, safeLoc)) {
|
||||||
|
event.getDrops().clear();
|
||||||
|
if (settings.voidProtectionKeepXp()) {
|
||||||
|
event.setKeepLevel(true);
|
||||||
|
event.setDroppedExp(0);
|
||||||
|
}
|
||||||
|
int x = safeLoc.getBlockX();
|
||||||
|
int y = safeLoc.getBlockY();
|
||||||
|
int z = safeLoc.getBlockZ();
|
||||||
|
getServer().getScheduler().runTaskLater(this, () -> {
|
||||||
|
if (player.isOnline()) {
|
||||||
|
player.sendMessage(Component.text("[Canalhandia] ", NamedTextColor.GOLD)
|
||||||
|
.append(Component.text("Você caiu no vácuo! Seus itens foram guardados em segurança num baú em ", NamedTextColor.YELLOW))
|
||||||
|
.append(Component.text(x + ", " + y + ", " + z, NamedTextColor.AQUA, TextDecoration.BOLD))
|
||||||
|
.append(Component.text(".", NamedTextColor.YELLOW)));
|
||||||
|
}
|
||||||
|
}, 20L);
|
||||||
|
} else {
|
||||||
|
// No safe ground found within radius: keep inventory directly
|
||||||
|
event.setKeepInventory(true);
|
||||||
|
event.getDrops().clear();
|
||||||
|
if (settings.voidProtectionKeepXp()) {
|
||||||
|
event.setKeepLevel(true);
|
||||||
|
event.setDroppedExp(0);
|
||||||
|
}
|
||||||
|
getServer().getScheduler().runTaskLater(this, () -> {
|
||||||
|
if (player.isOnline()) {
|
||||||
|
player.sendMessage(Component.text("[Canalhandia] ", NamedTextColor.GOLD)
|
||||||
|
.append(Component.text("Você caiu no vácuo sem terra firme por perto! Seus itens foram mantidos no seu inventário.", NamedTextColor.GREEN)));
|
||||||
|
}
|
||||||
|
}, 20L);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comic death broadcast + private coordinates, gated by the {@code mortes}
|
* Comic death broadcast + private coordinates, gated by the {@code mortes}
|
||||||
* module. Replaces the vanilla translatable death message with a pt-BR
|
* module. Replaces the vanilla translatable death message with a pt-BR
|
||||||
@@ -852,7 +937,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
Location loc = player.getLocation();
|
Location loc = player.getLocation();
|
||||||
String coords = loc.getBlockX() + " " + loc.getBlockY() + " " + loc.getBlockZ()
|
String coords = loc.getBlockX() + " " + loc.getBlockY() + " " + loc.getBlockZ()
|
||||||
+ " (" + loc.getWorld().getName() + ")";
|
+ " (" + loc.getWorld().getName() + ")";
|
||||||
boolean keepInventory = Boolean.TRUE.equals(
|
boolean keepInventory = event.getKeepInventory() || Boolean.TRUE.equals(
|
||||||
loc.getWorld().getGameRuleValue(GameRule.KEEP_INVENTORY));
|
loc.getWorld().getGameRuleValue(GameRule.KEEP_INVENTORY));
|
||||||
pendingDeathCoords.put(player.getUniqueId(), new DeathCoords(coords, keepInventory));
|
pendingDeathCoords.put(player.getUniqueId(), new DeathCoords(coords, keepInventory));
|
||||||
|
|
||||||
@@ -873,11 +958,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());
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import net.kyori.adventure.text.event.ClickEvent;
|
|||||||
import net.kyori.adventure.text.format.NamedTextColor;
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
import org.bukkit.command.Command;
|
import org.bukkit.command.Command;
|
||||||
import org.bukkit.command.CommandExecutor;
|
import org.bukkit.command.CommandExecutor;
|
||||||
import org.bukkit.command.CommandSender;
|
import org.bukkit.command.CommandSender;
|
||||||
import org.bukkit.command.TabCompleter;
|
import org.bukkit.command.TabCompleter;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
@@ -62,6 +65,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
case "conquistas" -> conquistas(sender, args);
|
case "conquistas" -> conquistas(sender, args);
|
||||||
case "perfil" -> perfil(sender, args);
|
case "perfil" -> perfil(sender, args);
|
||||||
case "titulo" -> titulo(sender, args);
|
case "titulo" -> titulo(sender, args);
|
||||||
|
case "chunkloader", "ancora", "ancoras", "chunkloaders" -> chunkloader(sender, args);
|
||||||
// Typed twin of the [F] mourning button — "f" is not a configured
|
// Typed twin of the [F] mourning button — "f" is not a configured
|
||||||
// reaction (the mourning set is hardcoded), so it can't be found via
|
// reaction (the mourning set is hardcoded), so it can't be found via
|
||||||
// reactionForCommand; route it directly. Acts on the latest message,
|
// reactionForCommand; route it directly. Acts on the latest message,
|
||||||
@@ -563,6 +567,18 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
}
|
}
|
||||||
boolean on = args[1].equalsIgnoreCase("on");
|
boolean on = args[1].equalsIgnoreCase("on");
|
||||||
plugin.settings().moduleEnabled(module, on);
|
plugin.settings().moduleEnabled(module, on);
|
||||||
|
if (module == Module.CHUNKLOADER) {
|
||||||
|
if (on) {
|
||||||
|
plugin.chunkLoaders().loadAllTickets();
|
||||||
|
plugin.chunkLoaders().startSimulation();
|
||||||
|
ChunkAnchorItem.registerRecipe(plugin);
|
||||||
|
} else {
|
||||||
|
plugin.chunkLoaders().stopSimulation();
|
||||||
|
plugin.chunkLoaders().unloadAllTickets();
|
||||||
|
ChunkAnchorItem.unregisterRecipe(plugin);
|
||||||
|
}
|
||||||
|
plugin.blueMap().syncChunkLoaders();
|
||||||
|
}
|
||||||
plugin.rescheduleTimer();
|
plugin.rescheduleTimer();
|
||||||
plugin.rescheduleMilestones();
|
plugin.rescheduleMilestones();
|
||||||
Msg.ok(sender, "Módulo " + module.label() + " " + (on ? "ativado" : "desativado") + ".");
|
Msg.ok(sender, "Módulo " + module.label() + " " + (on ? "ativado" : "desativado") + ".");
|
||||||
@@ -967,10 +983,28 @@ 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;
|
||||||
|
}
|
||||||
|
if (sub.equals("lembrar") && args.length >= 2) {
|
||||||
|
String fact = String.join(" ", Arrays.copyOfRange(args, 1, args.length)).trim();
|
||||||
|
if (!fact.isBlank() && plugin.playerMemory() != null) {
|
||||||
|
plugin.playerMemory().addFact(player.getUniqueId(), player.getName(), fact);
|
||||||
|
Msg.ok(sender, "Fato gravado na memória da sua IA: \"" + fact + "\"");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Personalities: player can switch for themselves (/ia persona <nome>)
|
||||||
|
// or reset to default (/ia persona padrao).
|
||||||
|
if ((sub.equals("personalidade") || sub.equals("persona"))
|
||||||
|
&& (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 +1059,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 ----------------------------------------------------
|
||||||
@@ -1391,8 +1530,10 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
String arg = String.join(" ", args);
|
String arg = String.join(" ", args).trim();
|
||||||
if (arg.equalsIgnoreCase("limpar") || arg.equalsIgnoreCase("nenhum")) {
|
if (arg.equalsIgnoreCase("limpar") || arg.equalsIgnoreCase("nenhum")
|
||||||
|
|| arg.equalsIgnoreCase("remover") || arg.equalsIgnoreCase("remove")
|
||||||
|
|| arg.equalsIgnoreCase("clear")) {
|
||||||
plugin.titles().clear(player.getUniqueId());
|
plugin.titles().clear(player.getUniqueId());
|
||||||
Msg.ok(player, Lang.tr("canalhandia.cmd.titulo.removido"));
|
Msg.ok(player, Lang.tr("canalhandia.cmd.titulo.removido"));
|
||||||
return true;
|
return true;
|
||||||
@@ -1407,20 +1548,37 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Matches typed text to an earned achievement by key or (case-insensitive) title. */
|
/** Matches typed text to an earned achievement by key, title, or accent-normalized text. */
|
||||||
static Achievement matchEarned(String text, List<Achievement> earned) {
|
static Achievement matchEarned(String text, List<Achievement> earned) {
|
||||||
Achievement byKey = Achievement.byKey(text);
|
if (text == null || text.isBlank() || earned == null || earned.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String clean = text.trim();
|
||||||
|
Achievement byKey = Achievement.byKey(clean);
|
||||||
if (byKey != null && earned.contains(byKey)) {
|
if (byKey != null && earned.contains(byKey)) {
|
||||||
return byKey;
|
return byKey;
|
||||||
}
|
}
|
||||||
for (Achievement achievement : earned) {
|
for (Achievement achievement : earned) {
|
||||||
if (achievement.title().equalsIgnoreCase(text.trim())) {
|
if (achievement.title().equalsIgnoreCase(clean)) {
|
||||||
|
return achievement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String normalizedClean = normalizeText(clean);
|
||||||
|
for (Achievement achievement : earned) {
|
||||||
|
if (normalizeText(achievement.title()).equalsIgnoreCase(normalizedClean)
|
||||||
|
|| normalizeText(achievement.key()).equalsIgnoreCase(normalizedClean)) {
|
||||||
return achievement;
|
return achievement;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String normalizeText(String input) {
|
||||||
|
if (input == null) return "";
|
||||||
|
return java.text.Normalizer.normalize(input, java.text.Normalizer.Form.NFD)
|
||||||
|
.replaceAll("\\p{M}", "");
|
||||||
|
}
|
||||||
|
|
||||||
private static String titlesList(List<Achievement> earned) {
|
private static String titlesList(List<Achievement> earned) {
|
||||||
List<String> names = new ArrayList<>();
|
List<String> names = new ArrayList<>();
|
||||||
for (Achievement achievement : earned) {
|
for (Achievement achievement : earned) {
|
||||||
@@ -1660,6 +1818,451 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- /chunkloader -------------------------------------------------------
|
||||||
|
|
||||||
|
private boolean chunkloader(CommandSender sender, String[] args) {
|
||||||
|
if (!plugin.settings().moduleEnabled(Module.CHUNKLOADER)) {
|
||||||
|
Msg.error(sender, "O módulo de Âncoras de Chunk está desativado.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!sender.hasPermission("canalhandia.chunkloader")) {
|
||||||
|
return denied(sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.length == 0) {
|
||||||
|
return chunkloaderHelp(sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
String sub = args[0].toLowerCase(Locale.ROOT);
|
||||||
|
String[] rest = Arrays.copyOfRange(args, 1, args.length);
|
||||||
|
|
||||||
|
switch (sub) {
|
||||||
|
case "listar", "list" -> chunkloaderList(sender, rest);
|
||||||
|
case "info", "chunk" -> chunkloaderInfo(sender);
|
||||||
|
case "remover", "remove", "del" -> chunkloaderRemove(sender, rest);
|
||||||
|
case "receita", "craft" -> chunkloaderRecipe(sender);
|
||||||
|
case "limite", "limit" -> chunkloaderLimit(sender, rest);
|
||||||
|
case "nome", "renomear", "rename" -> chunkloaderRename(sender, rest);
|
||||||
|
case "ativar", "enable" -> chunkloaderSetEnabled(sender, rest, true);
|
||||||
|
case "desativar", "pausar", "disable", "pause" -> chunkloaderSetEnabled(sender, rest, false);
|
||||||
|
case "tempo", "abastecer", "fuel" -> chunkloaderTime(sender, rest);
|
||||||
|
case "admin" -> chunkloaderAdmin(sender, rest);
|
||||||
|
case "dar", "give" -> {
|
||||||
|
if (admin(sender)) {
|
||||||
|
chunkloaderGive(sender, rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> chunkloaderHelp(sender);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderList(CommandSender sender, String[] args) {
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
chunkloaderAdminList(sender, args.length > 0 ? args[0] : null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ChunkLoader> mine = plugin.chunkLoaders().byOwner(player.getUniqueId().toString());
|
||||||
|
int limit = ChunkLoaders.playerLimit(player, plugin.settings().chunkLoaderDefaultLimit());
|
||||||
|
String limitStr = limit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(limit);
|
||||||
|
|
||||||
|
Msg.header(sender, "Suas Âncoras de Chunk (" + mine.size() + "/" + limitStr + ")");
|
||||||
|
if (mine.isEmpty()) {
|
||||||
|
sender.sendMessage(Component.text(" Você não possui nenhuma Âncora de Chunk ativa.", NamedTextColor.GRAY));
|
||||||
|
sender.sendMessage(Component.text(" Use ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text("/chunkloader receita", NamedTextColor.AQUA))
|
||||||
|
.append(Component.text(" para ver como criar uma.", NamedTextColor.GRAY)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean bedrock = isBedrock(sender);
|
||||||
|
for (ChunkLoader loader : mine) {
|
||||||
|
String statusText = loader.enabled() ? "[Ativa]" : "[Pausada]";
|
||||||
|
NamedTextColor statusColor = loader.enabled() ? NamedTextColor.GREEN : NamedTextColor.RED;
|
||||||
|
|
||||||
|
Component line = Component.text(" " + loader.displayName() + " ", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(loader.place() + " " + loader.chunkCoords() + " ", NamedTextColor.WHITE))
|
||||||
|
.append(Component.text(statusText + " ", statusColor))
|
||||||
|
.append(Component.text("[" + loader.timeLeft() + "]", NamedTextColor.GOLD));
|
||||||
|
|
||||||
|
if (!bedrock) {
|
||||||
|
if (loader.enabled()) {
|
||||||
|
line = line.append(Component.text(" [Pausar]", NamedTextColor.YELLOW)
|
||||||
|
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
|
||||||
|
Component.text("Clique para pausar " + loader.displayName(), NamedTextColor.GRAY)))
|
||||||
|
.clickEvent(net.kyori.adventure.text.event.ClickEvent.runCommand(
|
||||||
|
"/chunkloader desativar " + loader.id())));
|
||||||
|
} else {
|
||||||
|
line = line.append(Component.text(" [Ativar]", NamedTextColor.GREEN)
|
||||||
|
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
|
||||||
|
Component.text("Clique para reativar " + loader.displayName(), NamedTextColor.GRAY)))
|
||||||
|
.clickEvent(net.kyori.adventure.text.event.ClickEvent.runCommand(
|
||||||
|
"/chunkloader ativar " + loader.id())));
|
||||||
|
}
|
||||||
|
line = line.append(Component.text(" [Remover]", NamedTextColor.RED)
|
||||||
|
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
|
||||||
|
Component.text("Clique para recolher " + loader.displayName(), NamedTextColor.GRAY)))
|
||||||
|
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand(
|
||||||
|
"/chunkloader remover " + loader.id())));
|
||||||
|
}
|
||||||
|
sender.sendMessage(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderInfo(CommandSender sender) {
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
Msg.error(sender, "Comando exclusivo para jogadores.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Location loc = player.getLocation();
|
||||||
|
String world = loc.getWorld().getName();
|
||||||
|
int cx = loc.getBlockX() >> 4;
|
||||||
|
int cz = loc.getBlockZ() >> 4;
|
||||||
|
|
||||||
|
ChunkLoader here = plugin.chunkLoaders().byChunk(world, cx, cz);
|
||||||
|
int limit = ChunkLoaders.playerLimit(player, plugin.settings().chunkLoaderDefaultLimit());
|
||||||
|
int used = plugin.chunkLoaders().byOwner(player.getUniqueId().toString()).size();
|
||||||
|
String limitStr = limit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(limit);
|
||||||
|
|
||||||
|
Msg.header(sender, "Informações da Chunk");
|
||||||
|
sender.sendMessage(Component.text(" Chunk atual: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text("[" + cx + ", " + cz + "]", NamedTextColor.AQUA))
|
||||||
|
.append(Component.text(" em " + world, NamedTextColor.WHITE)));
|
||||||
|
|
||||||
|
if (here != null) {
|
||||||
|
String status = here.enabled() ? "Ativa" : "Pausada";
|
||||||
|
NamedTextColor statusCol = here.enabled() ? NamedTextColor.GREEN : NamedTextColor.RED;
|
||||||
|
sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(here.displayName() + " (" + status + ")", statusCol))
|
||||||
|
.append(Component.text(" de " + here.ownerName(), NamedTextColor.WHITE)));
|
||||||
|
sender.sendMessage(Component.text(" Tempo restante: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(here.timeLeft(), NamedTextColor.GOLD)));
|
||||||
|
} else {
|
||||||
|
sender.sendMessage(Component.text(" Status: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text("Carregamento normal (sem âncora)", NamedTextColor.DARK_GRAY)));
|
||||||
|
}
|
||||||
|
|
||||||
|
sender.sendMessage(Component.text(" Suas âncoras ativas: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(used + "/" + limitStr, NamedTextColor.YELLOW)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderRename(CommandSender sender, String[] args) {
|
||||||
|
if (args.length < 2) {
|
||||||
|
Msg.error(sender, "Uso: /chunkloader nome <id|nome_antigo> <novo_nome>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ChunkLoader loader = plugin.chunkLoaders().find(args[0]);
|
||||||
|
if (loader == null) {
|
||||||
|
Msg.error(sender, "Nenhuma âncora encontrada para '" + args[0] + "'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
|
||||||
|
boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin");
|
||||||
|
if (!isOwner && !isAdmin) {
|
||||||
|
Msg.error(sender, "Esta âncora pertence a " + loader.ownerName() + ".");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String newName = String.join(" ", Arrays.copyOfRange(args, 1, args.length)).trim();
|
||||||
|
if (newName.length() > 32) {
|
||||||
|
Msg.error(sender, "Nome muito longo (máximo 32 caracteres).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
plugin.chunkLoaders().rename(loader.id(), newName);
|
||||||
|
plugin.blueMap().syncChunkLoaders();
|
||||||
|
Msg.ok(sender, "Âncora #" + loader.id() + " renomeada para '" + newName + "'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderSetEnabled(CommandSender sender, String[] args, boolean enabled) {
|
||||||
|
if (args.length < 1) {
|
||||||
|
Msg.error(sender, "Uso: /chunkloader " + (enabled ? "ativar" : "desativar") + " <id|nome>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ChunkLoader loader = plugin.chunkLoaders().find(args[0]);
|
||||||
|
if (loader == null) {
|
||||||
|
Msg.error(sender, "Nenhuma âncora encontrada para '" + args[0] + "'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
|
||||||
|
boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin");
|
||||||
|
if (!isOwner && !isAdmin) {
|
||||||
|
Msg.error(sender, "Esta âncora pertence a " + loader.ownerName() + ".");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loader.enabled() == enabled) {
|
||||||
|
Msg.error(sender, "A âncora " + loader.displayName() + " já está " + (enabled ? "ativa" : "pausada") + ".");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (enabled && loader.isExpired()) {
|
||||||
|
Msg.error(sender, "A âncora " + loader.displayName() + " está expirada! Adicione tempo (/chunkloader tempo " + loader.id() + " <horas>) ou abasteça com combustível antes de reativar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
plugin.chunkLoaders().setEnabled(loader.id(), enabled);
|
||||||
|
plugin.blueMap().syncChunkLoaders();
|
||||||
|
Msg.ok(sender, "Âncora " + loader.displayName() + " " + (enabled ? "ATIVADA." : "PAUSADA/DESATIVADA."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderTime(CommandSender sender, String[] args) {
|
||||||
|
if (args.length < 2) {
|
||||||
|
Msg.error(sender, "Uso: /chunkloader tempo <id|nome> <horas>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ChunkLoader loader = plugin.chunkLoaders().find(args[0]);
|
||||||
|
if (loader == null) {
|
||||||
|
Msg.error(sender, "Nenhuma âncora encontrada para '" + args[0] + "'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
|
||||||
|
boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin");
|
||||||
|
if (!isOwner && !isAdmin) {
|
||||||
|
Msg.error(sender, "Esta âncora pertence a " + loader.ownerName() + ".");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
double hours = Double.parseDouble(args[1]);
|
||||||
|
if (hours <= 0) {
|
||||||
|
if (isAdmin) {
|
||||||
|
plugin.chunkLoaders().setExpiresAt(loader.id(), 0L); // permanent
|
||||||
|
Msg.ok(sender, "Âncora " + loader.displayName() + " definida como PERMANENTE.");
|
||||||
|
} else {
|
||||||
|
Msg.error(sender, "A quantidade de horas deve ser maior que zero.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long millis = (long) (hours * 3600_000L);
|
||||||
|
plugin.chunkLoaders().addTime(loader.id(), millis);
|
||||||
|
ChunkLoader updated = plugin.chunkLoaders().byId(loader.id());
|
||||||
|
String time = (updated != null) ? updated.timeLeft() : "Permanente";
|
||||||
|
Msg.ok(sender, "Adicionado " + hours + "h à âncora " + loader.displayName() + ". Tempo restante: " + time);
|
||||||
|
plugin.blueMap().syncChunkLoaders();
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
Msg.error(sender, "Horas inválidas: " + args[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderRemove(CommandSender sender, String[] args) {
|
||||||
|
if (args.length < 1) {
|
||||||
|
Msg.error(sender, "Uso: /chunkloader remover <id|nome>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChunkLoader loader = plugin.chunkLoaders().find(args[0]);
|
||||||
|
if (loader == null) {
|
||||||
|
Msg.error(sender, "Nenhuma Âncora de Chunk encontrada para '" + args[0] + "'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long id = loader.id();
|
||||||
|
boolean isOwner = sender instanceof Player player && player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
|
||||||
|
boolean isAdmin = sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin");
|
||||||
|
|
||||||
|
if (!isOwner && !isAdmin) {
|
||||||
|
Msg.error(sender, "Esta âncora pertence a " + loader.ownerName() + ".");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.chunkLoaders().remove(id);
|
||||||
|
plugin.blueMap().syncChunkLoaders();
|
||||||
|
|
||||||
|
try {
|
||||||
|
org.bukkit.World w = Bukkit.getWorld(loader.world());
|
||||||
|
if (w != null && w.isChunkLoaded(loader.chunkX(), loader.chunkZ())) {
|
||||||
|
Block b = w.getBlockAt(loader.x(), loader.y(), loader.z());
|
||||||
|
if (b.getType() == Material.RESPAWN_ANCHOR) {
|
||||||
|
b.setType(Material.AIR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sender instanceof Player player) {
|
||||||
|
Map<Integer, ItemStack> overflow = player.getInventory().addItem(ChunkAnchorItem.create(plugin, 1));
|
||||||
|
for (ItemStack drop : overflow.values()) {
|
||||||
|
player.getWorld().dropItemNaturally(player.getLocation(), drop);
|
||||||
|
}
|
||||||
|
Msg.ok(sender, "Âncora de Chunk " + loader.displayName() + " desativada e devolvida ao inventário.");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
org.bukkit.World w = Bukkit.getWorld(loader.world());
|
||||||
|
if (w != null) {
|
||||||
|
w.dropItemNaturally(new Location(w, loader.x() + 0.5, loader.y() + 0.5, loader.z() + 0.5),
|
||||||
|
ChunkAnchorItem.create(plugin, 1));
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
Msg.ok(sender, "Âncora de Chunk " + loader.displayName() + " desativada e dropada em " + loader.place() + ".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderRecipe(CommandSender sender) {
|
||||||
|
Msg.header(sender, "Receita da Âncora de Chunk");
|
||||||
|
sender.sendMessage(Component.text(" Na Bancada de Trabalho:", NamedTextColor.GRAY));
|
||||||
|
sender.sendMessage(Component.text(" [ D ] [ O ] [ D ]", NamedTextColor.AQUA));
|
||||||
|
sender.sendMessage(Component.text(" [ O ] [ E ] [ O ]", NamedTextColor.LIGHT_PURPLE));
|
||||||
|
sender.sendMessage(Component.text(" [ D ] [ O ] [ D ]", NamedTextColor.AQUA));
|
||||||
|
sender.sendMessage(Component.text(" D = Diamante | O = Obsidiana Chorona | E = Olho do Fim", NamedTextColor.YELLOW));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderAdmin(CommandSender sender, String[] args) {
|
||||||
|
if (!admin(sender)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (args.length == 0) {
|
||||||
|
Msg.header(sender, "Admin Chunk Loaders");
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader admin listar [jogador]", NamedTextColor.AQUA));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader admin remover <id>", NamedTextColor.AQUA));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader dar <jogador> [quantidade]", NamedTextColor.AQUA));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (args[0].toLowerCase(Locale.ROOT)) {
|
||||||
|
case "listar", "list" -> chunkloaderAdminList(sender, args.length > 1 ? args[1] : null);
|
||||||
|
case "remover", "del" -> {
|
||||||
|
if (args.length < 2) {
|
||||||
|
Msg.error(sender, "Uso: /chunkloader admin remover <id>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunkloaderRemove(sender, new String[]{args[1]});
|
||||||
|
}
|
||||||
|
case "dar", "give" -> chunkloaderGive(sender, Arrays.copyOfRange(args, 1, args.length));
|
||||||
|
default -> Msg.error(sender, "Subcomando de admin desconhecido.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderAdminList(CommandSender sender, String targetName) {
|
||||||
|
List<ChunkLoader> all = plugin.chunkLoaders().all();
|
||||||
|
if (targetName != null && !targetName.isBlank()) {
|
||||||
|
all = all.stream().filter(l -> l.ownerName().equalsIgnoreCase(targetName)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Msg.header(sender, "Todas as Âncoras do Servidor (" + all.size() + ")");
|
||||||
|
if (all.isEmpty()) {
|
||||||
|
sender.sendMessage(Component.text(" Nenhuma âncora ativa encontrada.", NamedTextColor.GRAY));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ChunkLoader loader : all) {
|
||||||
|
sender.sendMessage(Component.text(" #" + loader.id() + " ", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(loader.ownerName(), NamedTextColor.YELLOW))
|
||||||
|
.append(Component.text(" — " + loader.place() + " " + loader.chunkCoords(), NamedTextColor.WHITE)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderGive(CommandSender sender, String[] args) {
|
||||||
|
if (args.length < 1) {
|
||||||
|
Msg.error(sender, "Uso: /chunkloader dar <jogador> [quantidade]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Player target = Bukkit.getPlayer(args[0]);
|
||||||
|
if (target == null) {
|
||||||
|
Msg.error(sender, "Jogador não encontrado: " + args[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int amount = 1;
|
||||||
|
if (args.length >= 2) {
|
||||||
|
try {
|
||||||
|
amount = Math.max(1, Integer.parseInt(args[1]));
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target.getInventory().addItem(ChunkAnchorItem.create(plugin, amount));
|
||||||
|
Msg.ok(sender, "Entregue " + amount + "x Âncora de Chunk para " + target.getName() + ".");
|
||||||
|
Msg.ok(target, "Você recebeu " + amount + "x Âncora de Chunk!");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void chunkloaderLimit(CommandSender sender, String[] args) {
|
||||||
|
if (args.length == 0) {
|
||||||
|
int defaultLimit = plugin.settings().chunkLoaderDefaultLimit();
|
||||||
|
Msg.header(sender, "Limite de Âncoras de Chunk");
|
||||||
|
sender.sendMessage(Component.text(" Limite padrão global: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(defaultLimit, NamedTextColor.YELLOW)));
|
||||||
|
if (sender instanceof Player player) {
|
||||||
|
int myLimit = ChunkLoaders.playerLimit(player, defaultLimit);
|
||||||
|
String myLimitStr = myLimit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(myLimit);
|
||||||
|
int current = plugin.chunkLoaders().byOwner(player.getUniqueId().toString()).size();
|
||||||
|
sender.sendMessage(Component.text(" Seu limite atual: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(current + "/" + myLimitStr, NamedTextColor.AQUA)));
|
||||||
|
}
|
||||||
|
if (admin(sender)) {
|
||||||
|
sender.sendMessage(Component.text(" Alterar padrão global: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text("/chunkloader limite padrao <quantidade>", NamedTextColor.YELLOW)));
|
||||||
|
sender.sendMessage(Component.text(" Definir por jogador (LuckPerms): ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text("/chunkloader limite <jogador> <quantidade>", NamedTextColor.YELLOW)));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!admin(sender)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args[0].equalsIgnoreCase("padrao") || args[0].equalsIgnoreCase("default")) {
|
||||||
|
if (args.length < 2) {
|
||||||
|
Msg.error(sender, "Uso: /chunkloader limite padrao <quantidade>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int newLimit = Math.max(0, Integer.parseInt(args[1]));
|
||||||
|
plugin.settings().chunkLoaderDefaultLimit(newLimit);
|
||||||
|
Msg.ok(sender, "Limite padrão global de Âncoras alterado para " + newLimit + ".");
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
Msg.error(sender, "Quantidade inválida: " + args[1]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Player target = Bukkit.getPlayerExact(args[0]);
|
||||||
|
if (target == null) {
|
||||||
|
Msg.error(sender, "Jogador '" + args[0] + "' não encontrado online.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (args.length < 2) {
|
||||||
|
int targetLimit = ChunkLoaders.playerLimit(target, plugin.settings().chunkLoaderDefaultLimit());
|
||||||
|
String str = targetLimit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(targetLimit);
|
||||||
|
int current = plugin.chunkLoaders().byOwner(target.getUniqueId().toString()).size();
|
||||||
|
Msg.ok(sender, "Limite de " + target.getName() + ": " + current + "/" + str + " âncoras ativas.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int newLimit = Math.max(0, Integer.parseInt(args[1]));
|
||||||
|
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "lp user " + target.getName() + " permission set canalhandia.chunkloader.limite." + newLimit);
|
||||||
|
Msg.ok(sender, "Limite de " + target.getName() + " definido para " + newLimit + " âncoras.");
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
Msg.error(sender, "Quantidade inválida: " + args[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean chunkloaderHelp(CommandSender sender) {
|
||||||
|
Msg.header(sender, "Âncoras de Chunk");
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader info", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(" — Informações sobre a chunk onde você está", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader listar", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(" — Lista suas âncoras, nomes, status e coordenadas", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader nome <id|nome> <novo_nome>", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(" — Define um nome/rótulo para a âncora", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader ativar <id|nome>", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(" — Reativa o carregamento contínuo da âncora", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader desativar <id|nome>", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(" — Pausa o carregamento sem quebrar o bloco", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader remover <id|nome>", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(" — Desativa e recolhe uma de suas âncoras", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader receita", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(" — Mostra a receita de criação do bloco", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader tempo <id|nome> <horas>", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(" — Adiciona tempo ou abastece a âncora", NamedTextColor.GRAY)));
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader limite", NamedTextColor.AQUA)
|
||||||
|
.append(Component.text(" — Mostra ou configura os limites de âncoras", NamedTextColor.GRAY)));
|
||||||
|
if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) {
|
||||||
|
sender.sendMessage(Component.text(" /chunkloader admin", NamedTextColor.YELLOW)
|
||||||
|
.append(Component.text(" — Comandos de administração", NamedTextColor.GRAY)));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// --- tab completion -----------------------------------------------------
|
// --- tab completion -----------------------------------------------------
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -1724,23 +2327,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", "lembrar", "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();
|
||||||
@@ -1806,6 +2423,45 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
return filter(List.of("on", "off"), args[2]);
|
return filter(List.of("on", "off"), args[2]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (name.equals("chunkloader") || name.equals("ancora") || name.equals("ancoras")) {
|
||||||
|
if (args.length == 1) {
|
||||||
|
List<String> options = new ArrayList<>(List.of("info", "listar", "nome", "ativar", "desativar", "tempo", "remover", "receita", "limite"));
|
||||||
|
if (sender.hasPermission(ADMIN) || sender.hasPermission("canalhandia.chunkloader.admin")) {
|
||||||
|
options.addAll(List.of("admin", "dar"));
|
||||||
|
}
|
||||||
|
return filter(options, args[0]);
|
||||||
|
}
|
||||||
|
if (args.length == 2 && (args[0].equalsIgnoreCase("remover") || args[0].equalsIgnoreCase("del")
|
||||||
|
|| args[0].equalsIgnoreCase("nome") || args[0].equalsIgnoreCase("renomear")
|
||||||
|
|| args[0].equalsIgnoreCase("ativar") || args[0].equalsIgnoreCase("desativar")
|
||||||
|
|| args[0].equalsIgnoreCase("pausar") || args[0].equalsIgnoreCase("tempo")
|
||||||
|
|| args[0].equalsIgnoreCase("abastecer"))) {
|
||||||
|
if (sender instanceof Player player) {
|
||||||
|
List<String> identifiers = new ArrayList<>();
|
||||||
|
for (ChunkLoader loader : plugin.chunkLoaders().byOwner(player.getUniqueId().toString())) {
|
||||||
|
identifiers.add(String.valueOf(loader.id()));
|
||||||
|
if (loader.name() != null && !loader.name().isBlank()) {
|
||||||
|
identifiers.add(loader.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filter(identifiers, args[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (args.length == 2 && args[0].equalsIgnoreCase("limite")) {
|
||||||
|
List<String> opts = new ArrayList<>(List.of("padrao"));
|
||||||
|
opts.addAll(onlineNames());
|
||||||
|
return filter(opts, args[1]);
|
||||||
|
}
|
||||||
|
if (args.length == 2 && args[0].equalsIgnoreCase("admin")) {
|
||||||
|
return filter(List.of("listar", "remover", "dar"), args[1]);
|
||||||
|
}
|
||||||
|
if (args.length == 2 && args[0].equalsIgnoreCase("dar")) {
|
||||||
|
return filter(onlineNames(), args[1]);
|
||||||
|
}
|
||||||
|
if (args.length == 3 && args[0].equalsIgnoreCase("admin") && args[1].equalsIgnoreCase("dar")) {
|
||||||
|
return filter(onlineNames(), args[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import net.kyori.adventure.text.format.TextDecoration;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.NamespacedKey;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.ShapedRecipe;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
import org.bukkit.persistence.PersistentDataType;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and validates the "Âncora de Chunk" custom block item and its crafting recipe.
|
||||||
|
*/
|
||||||
|
final class ChunkAnchorItem {
|
||||||
|
|
||||||
|
private static final String KEY_NAME = "chunk_anchor";
|
||||||
|
private static final String RECIPE_NAME = "ancora_de_chunk";
|
||||||
|
|
||||||
|
static NamespacedKey key(Plugin plugin) {
|
||||||
|
return new NamespacedKey(plugin, KEY_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
static NamespacedKey recipeKey(Plugin plugin) {
|
||||||
|
return new NamespacedKey(plugin, RECIPE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new Chunk Anchor item stack.
|
||||||
|
*/
|
||||||
|
static ItemStack create(Plugin plugin, int amount) {
|
||||||
|
ItemStack item = new ItemStack(Material.RESPAWN_ANCHOR, Math.max(1, amount));
|
||||||
|
ItemMeta meta = item.getItemMeta();
|
||||||
|
if (meta != null) {
|
||||||
|
meta.displayName(Component.text("Âncora de Chunk", NamedTextColor.AQUA)
|
||||||
|
.decoration(TextDecoration.ITALIC, false)
|
||||||
|
.decoration(TextDecoration.BOLD, true));
|
||||||
|
meta.lore(List.of(
|
||||||
|
Component.text("Coloque no chão para manter esta chunk", NamedTextColor.GRAY)
|
||||||
|
.decoration(TextDecoration.ITALIC, false),
|
||||||
|
Component.text("carregada continuamente no servidor.", NamedTextColor.GRAY)
|
||||||
|
.decoration(TextDecoration.ITALIC, false),
|
||||||
|
Component.text("Canalhandia Chunk Loader", NamedTextColor.DARK_GRAY)
|
||||||
|
.decoration(TextDecoration.ITALIC, false)
|
||||||
|
));
|
||||||
|
meta.getPersistentDataContainer().set(key(plugin), PersistentDataType.BYTE, (byte) 1);
|
||||||
|
item.setItemMeta(meta);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests if an item is a valid Chunk Anchor.
|
||||||
|
*/
|
||||||
|
static boolean isAnchor(Plugin plugin, ItemStack item) {
|
||||||
|
if (item == null || item.getType() != Material.RESPAWN_ANCHOR) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ItemMeta meta = item.getItemMeta();
|
||||||
|
if (meta == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return meta.getPersistentDataContainer().has(key(plugin), PersistentDataType.BYTE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the shaped crafting recipe for the Chunk Anchor.
|
||||||
|
*/
|
||||||
|
static void registerRecipe(Plugin plugin) {
|
||||||
|
try {
|
||||||
|
NamespacedKey rKey = recipeKey(plugin);
|
||||||
|
if (Bukkit.getRecipe(rKey) != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ShapedRecipe recipe = new ShapedRecipe(rKey, create(plugin, 1));
|
||||||
|
recipe.shape("DOD", "OEO", "DOD");
|
||||||
|
recipe.setIngredient('D', Material.DIAMOND);
|
||||||
|
recipe.setIngredient('O', Material.CRYING_OBSIDIAN);
|
||||||
|
recipe.setIngredient('E', Material.ENDER_EYE);
|
||||||
|
|
||||||
|
Bukkit.addRecipe(recipe);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void unregisterRecipe(Plugin plugin) {
|
||||||
|
try {
|
||||||
|
Bukkit.removeRecipe(recipeKey(plugin));
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A persistent chunk loader anchor placed in the world.
|
||||||
|
*
|
||||||
|
* <p>A plain immutable record with no Bukkit types, so the model, coordinate
|
||||||
|
* math, and persistence rules are testable without a running server.
|
||||||
|
*/
|
||||||
|
record ChunkLoader(
|
||||||
|
long id,
|
||||||
|
String name,
|
||||||
|
boolean enabled,
|
||||||
|
long expiresAt,
|
||||||
|
String ownerUuid,
|
||||||
|
String ownerName,
|
||||||
|
String world,
|
||||||
|
int x,
|
||||||
|
int y,
|
||||||
|
int z,
|
||||||
|
int chunkX,
|
||||||
|
int chunkZ,
|
||||||
|
long createdAt) {
|
||||||
|
|
||||||
|
ChunkLoader(long id, String ownerUuid, String ownerName, String world, int x, int y, int z, int chunkX, int chunkZ, long createdAt) {
|
||||||
|
this(id, "", true, 0L, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
ChunkLoader(long id, String name, boolean enabled, String ownerUuid, String ownerName, String world, int x, int y, int z, int chunkX, int chunkZ, long createdAt) {
|
||||||
|
this(id, name, enabled, 0L, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
String blockCoords() {
|
||||||
|
return x + ", " + y + ", " + z;
|
||||||
|
}
|
||||||
|
|
||||||
|
String chunkCoords() {
|
||||||
|
return "[" + chunkX + ", " + chunkZ + "]";
|
||||||
|
}
|
||||||
|
|
||||||
|
String place() {
|
||||||
|
return blockCoords() + (world == null || world.isBlank() ? "" : " (" + world + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
String displayName() {
|
||||||
|
return (name != null && !name.isBlank()) ? name + " (#" + id + ")" : "#" + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
String simpleName() {
|
||||||
|
return (name != null && !name.isBlank()) ? name : "#" + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isExpired() {
|
||||||
|
return expiresAt > 0 && System.currentTimeMillis() >= expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
String timeLeft() {
|
||||||
|
if (expiresAt <= 0) {
|
||||||
|
return "Permanente";
|
||||||
|
}
|
||||||
|
long diff = expiresAt - System.currentTimeMillis();
|
||||||
|
if (diff <= 0) {
|
||||||
|
return "Expirado";
|
||||||
|
}
|
||||||
|
long hours = diff / (3600_000L);
|
||||||
|
long minutes = (diff % (3600_000L)) / 60_000L;
|
||||||
|
if (hours >= 24) {
|
||||||
|
long days = hours / 24;
|
||||||
|
hours = hours % 24;
|
||||||
|
return days + "d " + hours + "h";
|
||||||
|
}
|
||||||
|
return hours + "h " + minutes + "m";
|
||||||
|
}
|
||||||
|
|
||||||
|
ChunkLoader withName(String newName) {
|
||||||
|
return new ChunkLoader(id, newName == null ? "" : newName.trim(), enabled, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
ChunkLoader withEnabled(boolean newEnabled) {
|
||||||
|
return new ChunkLoader(id, name, newEnabled, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
ChunkLoader withExpiresAt(long newExpiresAt) {
|
||||||
|
return new ChunkLoader(id, name, enabled, Math.max(0L, newExpiresAt), ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.bukkit.GameMode;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.Particle;
|
||||||
|
import org.bukkit.Sound;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.entity.Mob;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.block.Action;
|
||||||
|
import org.bukkit.event.block.BlockBreakEvent;
|
||||||
|
import org.bukkit.event.block.BlockExplodeEvent;
|
||||||
|
import org.bukkit.event.block.BlockPistonExtendEvent;
|
||||||
|
import org.bukkit.event.block.BlockPistonRetractEvent;
|
||||||
|
import org.bukkit.event.block.BlockPlaceEvent;
|
||||||
|
import org.bukkit.event.entity.CreatureSpawnEvent;
|
||||||
|
import org.bukkit.event.entity.EntityExplodeEvent;
|
||||||
|
import org.bukkit.event.player.PlayerInteractEvent;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protects and handles block placement, removal, and explosions for Chunk Anchors.
|
||||||
|
*/
|
||||||
|
final class ChunkLoaderListener implements Listener {
|
||||||
|
|
||||||
|
private final Canalhandia plugin;
|
||||||
|
|
||||||
|
ChunkLoaderListener(Canalhandia plugin) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
|
public void onBlockPlace(BlockPlaceEvent event) {
|
||||||
|
if (!ChunkAnchorItem.isAnchor(plugin, event.getItemInHand())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
if (!plugin.settings().moduleEnabled(Module.CHUNKLOADER)) {
|
||||||
|
Msg.error(player, "O módulo de Âncoras de Chunk está desativado.");
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!player.hasPermission("canalhandia.chunkloader")) {
|
||||||
|
Msg.error(player, "Você não tem permissão para usar Âncoras de Chunk.");
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int limit = ChunkLoaders.playerLimit(player, plugin.settings().chunkLoaderDefaultLimit());
|
||||||
|
int current = plugin.chunkLoaders().byOwner(player.getUniqueId().toString()).size();
|
||||||
|
|
||||||
|
if (current >= limit) {
|
||||||
|
Msg.error(player, "Você atingiu seu limite de Âncoras de Chunk ativas (" + current + "/" + limit + ").");
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Location loc = event.getBlockPlaced().getLocation();
|
||||||
|
String world = loc.getWorld().getName();
|
||||||
|
int chunkX = loc.getBlockX() >> 4;
|
||||||
|
int chunkZ = loc.getBlockZ() >> 4;
|
||||||
|
|
||||||
|
ChunkLoader existing = plugin.chunkLoaders().byChunk(world, chunkX, chunkZ);
|
||||||
|
if (existing != null) {
|
||||||
|
Msg.error(player, "Esta chunk já possui uma Âncora de Chunk ativa em " + existing.blockCoords()
|
||||||
|
+ " (" + existing.ownerName() + ").");
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String customName = "";
|
||||||
|
var meta = event.getItemInHand().getItemMeta();
|
||||||
|
if (meta != null && meta.hasDisplayName()) {
|
||||||
|
try {
|
||||||
|
String raw = net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
|
||||||
|
.serialize(meta.displayName()).trim();
|
||||||
|
if (!raw.equalsIgnoreCase("Âncora de Chunk") && !raw.isBlank()) {
|
||||||
|
customName = raw;
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ChunkLoader loader = plugin.chunkLoaders().add(
|
||||||
|
player.getUniqueId().toString(),
|
||||||
|
player.getName(),
|
||||||
|
world,
|
||||||
|
loc.getBlockX(),
|
||||||
|
loc.getBlockY(),
|
||||||
|
loc.getBlockZ(),
|
||||||
|
customName
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
loc.getWorld().spawnParticle(Particle.PORTAL, loc.clone().add(0.5, 1.2, 0.5), 35, 0.3, 0.3, 0.3, 0.05);
|
||||||
|
loc.getWorld().playSound(loc, Sound.BLOCK_RESPAWN_ANCHOR_SET_SPAWN, 1.0f, 1.2f);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.blueMap().syncChunkLoaders();
|
||||||
|
|
||||||
|
String limitStr = limit == Integer.MAX_VALUE ? "ilimitado" : String.valueOf(limit);
|
||||||
|
Msg.ok(player, "Âncora de Chunk " + loader.displayName() + " ativada! Esta chunk [" + chunkX + ", " + chunkZ + "] ficará carregada continuamente ("
|
||||||
|
+ (current + 1) + "/" + limitStr + ").");
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||||
|
public void onBlockBreak(BlockBreakEvent event) {
|
||||||
|
Block block = event.getBlock();
|
||||||
|
ChunkLoader loader = plugin.chunkLoaders().byLocation(
|
||||||
|
block.getWorld().getName(),
|
||||||
|
block.getX(),
|
||||||
|
block.getY(),
|
||||||
|
block.getZ()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loader == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
boolean isOwner = player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
|
||||||
|
boolean isAdmin = player.hasPermission("canalhandia.admin") || player.hasPermission("canalhandia.chunkloader.admin");
|
||||||
|
|
||||||
|
if (!isOwner && !isAdmin) {
|
||||||
|
Msg.error(player, "Esta Âncora de Chunk pertence a " + loader.ownerName() + " e só pode ser quebrada por ele.");
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.chunkLoaders().remove(loader.id());
|
||||||
|
event.setDropItems(false);
|
||||||
|
event.setExpToDrop(0);
|
||||||
|
|
||||||
|
if (player.getGameMode() == GameMode.CREATIVE) {
|
||||||
|
Msg.ok(player, "Âncora de Chunk " + loader.displayName() + " desativada (Modo Criativo).");
|
||||||
|
} else {
|
||||||
|
Map<Integer, ItemStack> overflow = player.getInventory().addItem(ChunkAnchorItem.create(plugin, 1));
|
||||||
|
for (ItemStack drop : overflow.values()) {
|
||||||
|
player.getWorld().dropItemNaturally(player.getLocation(), drop);
|
||||||
|
}
|
||||||
|
Msg.ok(player, "Âncora de Chunk " + loader.displayName() + " desativada e recolhida para o seu inventário.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var loc = block.getLocation().add(0.5, 0.5, 0.5);
|
||||||
|
loc.getWorld().playSound(loc, Sound.BLOCK_RESPAWN_ANCHOR_DEPLETE, 1.0f, 0.8f);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.blueMap().syncChunkLoaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
|
public void onPlayerInteract(PlayerInteractEvent event) {
|
||||||
|
if (!plugin.settings().moduleEnabled(Module.CHUNKLOADER)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.getHand() != org.bukkit.inventory.EquipmentSlot.HAND) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.getAction() != Action.RIGHT_CLICK_BLOCK) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Block block = event.getClickedBlock();
|
||||||
|
if (block == null || block.getType() != Material.RESPAWN_ANCHOR) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ChunkLoader loader = plugin.chunkLoaders().byLocation(
|
||||||
|
block.getWorld().getName(),
|
||||||
|
block.getX(),
|
||||||
|
block.getY(),
|
||||||
|
block.getZ()
|
||||||
|
);
|
||||||
|
if (loader != null) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
boolean isOwner = player.getUniqueId().toString().equalsIgnoreCase(loader.ownerUuid());
|
||||||
|
boolean isAdmin = player.hasPermission("canalhandia.admin") || player.hasPermission("canalhandia.chunkloader.admin");
|
||||||
|
|
||||||
|
ItemStack inHand = player.getInventory().getItemInMainHand();
|
||||||
|
long fuelMillis = fuelDuration(inHand.getType());
|
||||||
|
if (fuelMillis > 0 && (isOwner || isAdmin)) {
|
||||||
|
if (player.getGameMode() != GameMode.CREATIVE) {
|
||||||
|
inHand.subtract(1);
|
||||||
|
}
|
||||||
|
plugin.chunkLoaders().addTime(loader.id(), fuelMillis);
|
||||||
|
ChunkLoader updated = plugin.chunkLoaders().byId(loader.id());
|
||||||
|
Location l = block.getLocation().add(0.5, 0.5, 0.5);
|
||||||
|
try {
|
||||||
|
l.getWorld().playSound(l, Sound.BLOCK_RESPAWN_ANCHOR_CHARGE, 1.0f, 1.2f);
|
||||||
|
l.getWorld().spawnParticle(Particle.FIREWORK, l, 15, 0.2, 0.2, 0.2, 0.05);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
plugin.blueMap().syncChunkLoaders();
|
||||||
|
String time = (updated != null) ? updated.timeLeft() : "Permanente";
|
||||||
|
Msg.ok(player, "Âncora " + loader.displayName() + " abastecida! Tempo restante: " + time);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.isSneaking() && (isOwner || isAdmin)) {
|
||||||
|
if (!loader.enabled() && loader.isExpired()) {
|
||||||
|
Msg.error(player, "A âncora " + loader.displayName() + " está expirada! Clique com combustível (Pérola do End, Blaze, Diamante, etc.) ou adicione tempo antes de reativar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean newState = !loader.enabled();
|
||||||
|
plugin.chunkLoaders().setEnabled(loader.id(), newState);
|
||||||
|
Location l = block.getLocation().add(0.5, 0.5, 0.5);
|
||||||
|
if (newState) {
|
||||||
|
try {
|
||||||
|
l.getWorld().playSound(l, Sound.BLOCK_BEACON_ACTIVATE, 1.0f, 1.2f);
|
||||||
|
l.getWorld().spawnParticle(Particle.PORTAL, l, 25, 0.2, 0.2, 0.2, 0.05);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
Msg.ok(player, "Âncora " + loader.displayName() + " ATIVADA.");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
l.getWorld().playSound(l, Sound.BLOCK_BEACON_DEACTIVATE, 1.0f, 0.8f);
|
||||||
|
l.getWorld().spawnParticle(Particle.SMOKE, l, 20, 0.2, 0.2, 0.2, 0.02);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
Msg.ok(player, "Âncora " + loader.displayName() + " PAUSADA/DESATIVADA.");
|
||||||
|
}
|
||||||
|
plugin.blueMap().syncChunkLoaders();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String stateStr = loader.enabled() ? "ATIVA" : "PAUSADA";
|
||||||
|
Msg.ok(player, "Âncora " + loader.displayName() + " (" + loader.ownerName() + ") está " + stateStr
|
||||||
|
+ " [Tempo: " + loader.timeLeft() + "]. (Shift + Clique Direito para pausar/ativar | Clique com combustível para abastecer)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long fuelDuration(Material type) {
|
||||||
|
if (type == Material.REDSTONE || type == Material.GLOWSTONE_DUST) {
|
||||||
|
return 30 * 60_000L; // 30 min
|
||||||
|
}
|
||||||
|
if (type == Material.GLOWSTONE || type == Material.AMETHYST_SHARD) {
|
||||||
|
return 2 * 3600_000L; // 2h
|
||||||
|
}
|
||||||
|
if (type == Material.ENDER_PEARL || type == Material.BLAZE_POWDER || type == Material.BLAZE_ROD) {
|
||||||
|
return 4 * 3600_000L; // 4h
|
||||||
|
}
|
||||||
|
if (type == Material.ENDER_EYE || type == Material.DIAMOND || type == Material.EMERALD) {
|
||||||
|
return 12 * 3600_000L; // 12h
|
||||||
|
}
|
||||||
|
if (type == Material.NETHER_STAR || type == Material.NETHERITE_INGOT || type == Material.END_CRYSTAL) {
|
||||||
|
return 7 * 24 * 3600_000L; // 7 dias
|
||||||
|
}
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
|
public void onBlockExplode(BlockExplodeEvent event) {
|
||||||
|
protectExplosions(event.blockList().iterator());
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
|
public void onEntityExplode(EntityExplodeEvent event) {
|
||||||
|
protectExplosions(event.blockList().iterator());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void protectExplosions(Iterator<Block> it) {
|
||||||
|
while (it.hasNext()) {
|
||||||
|
Block b = it.next();
|
||||||
|
if (plugin.chunkLoaders().byLocation(b.getWorld().getName(), b.getX(), b.getY(), b.getZ()) != null) {
|
||||||
|
it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
|
public void onPistonExtend(BlockPistonExtendEvent event) {
|
||||||
|
for (Block b : event.getBlocks()) {
|
||||||
|
if (plugin.chunkLoaders().byLocation(b.getWorld().getName(), b.getX(), b.getY(), b.getZ()) != null) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
|
public void onPistonRetract(BlockPistonRetractEvent event) {
|
||||||
|
for (Block b : event.getBlocks()) {
|
||||||
|
if (plugin.chunkLoaders().byLocation(b.getWorld().getName(), b.getX(), b.getY(), b.getZ()) != null) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
|
public void onCreatureSpawn(CreatureSpawnEvent event) {
|
||||||
|
if (!plugin.settings().moduleEnabled(Module.CHUNKLOADER)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Location loc = event.getLocation();
|
||||||
|
World w = loc.getWorld();
|
||||||
|
if (w == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ChunkLoader loader = plugin.chunkLoaders().byChunk(w.getName(), loc.getBlockX() >> 4, loc.getBlockZ() >> 4);
|
||||||
|
if (loader != null && event.getEntity() instanceof Mob mob) {
|
||||||
|
mob.setRemoveWhenFarAway(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Chunk;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.block.BlockState;
|
||||||
|
import org.bukkit.block.CreatureSpawner;
|
||||||
|
import org.bukkit.configuration.ConfigurationSection;
|
||||||
|
import org.bukkit.configuration.file.YamlConfiguration;
|
||||||
|
import org.bukkit.entity.Entity;
|
||||||
|
import org.bukkit.entity.EntityType;
|
||||||
|
import org.bukkit.entity.Mob;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.entity.CreatureSpawnEvent;
|
||||||
|
import org.bukkit.permissions.PermissionAttachmentInfo;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
import org.bukkit.scheduler.BukkitTask;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages active chunk loader anchors, ticket registration in Paper, and persistence in chunks.yml.
|
||||||
|
*/
|
||||||
|
final class ChunkLoaders {
|
||||||
|
|
||||||
|
private static final String PERM_LIMIT_PREFIX = "canalhandia.chunkloader.limite.";
|
||||||
|
|
||||||
|
private final Canalhandia plugin;
|
||||||
|
private final File file;
|
||||||
|
private final List<ChunkLoader> loaders = new ArrayList<>();
|
||||||
|
private final AtomicLong nextId = new AtomicLong(1);
|
||||||
|
private BukkitTask simulationTask;
|
||||||
|
|
||||||
|
private final ExecutorService io = Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "canalhandia-chunkloaders-io");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
ChunkLoaders(Canalhandia plugin, File file) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.file = file;
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
void load() {
|
||||||
|
YamlConfiguration yaml = file.exists() ? YamlConfiguration.loadConfiguration(file) : null;
|
||||||
|
synchronized (loaders) {
|
||||||
|
loaders.clear();
|
||||||
|
if (yaml == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ConfigurationSection root = yaml.getConfigurationSection("loaders");
|
||||||
|
if (root == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long maxId = 0;
|
||||||
|
for (String key : root.getKeys(false)) {
|
||||||
|
try {
|
||||||
|
long id = Long.parseLong(key);
|
||||||
|
String name = root.getString(key + ".name", "");
|
||||||
|
boolean enabled = root.getBoolean(key + ".enabled", true);
|
||||||
|
long expiresAt = root.getLong(key + ".expires_at", 0L);
|
||||||
|
String ownerUuid = root.getString(key + ".owner_uuid", "");
|
||||||
|
String ownerName = root.getString(key + ".owner_name", "?");
|
||||||
|
String world = root.getString(key + ".world", "world");
|
||||||
|
int x = root.getInt(key + ".x");
|
||||||
|
int y = root.getInt(key + ".y");
|
||||||
|
int z = root.getInt(key + ".z");
|
||||||
|
int chunkX = root.getInt(key + ".chunk_x", x >> 4);
|
||||||
|
int chunkZ = root.getInt(key + ".chunk_z", z >> 4);
|
||||||
|
long createdAt = root.getLong(key + ".created_at", System.currentTimeMillis());
|
||||||
|
|
||||||
|
loaders.add(new ChunkLoader(id, name, enabled, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt));
|
||||||
|
if (id > maxId) {
|
||||||
|
maxId = id;
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextId.set(maxId + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a new chunk loader and activates the ticket in the world.
|
||||||
|
*/
|
||||||
|
ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z) {
|
||||||
|
return add(ownerUuid, ownerName, world, x, y, z, "", defaultInitialExpiration());
|
||||||
|
}
|
||||||
|
|
||||||
|
ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z, String name) {
|
||||||
|
return add(ownerUuid, ownerName, world, x, y, z, name, defaultInitialExpiration());
|
||||||
|
}
|
||||||
|
|
||||||
|
private long defaultInitialExpiration() {
|
||||||
|
if (plugin != null && plugin.settings() != null && plugin.settings().chunkLoaderRequiresTime()) {
|
||||||
|
double hours = plugin.settings().chunkLoaderInitialHours();
|
||||||
|
if (hours > 0) {
|
||||||
|
return System.currentTimeMillis() + (long) (hours * 3600_000L);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z, String name, long expiresAt) {
|
||||||
|
int chunkX = x >> 4;
|
||||||
|
int chunkZ = z >> 4;
|
||||||
|
long id = nextId.getAndIncrement();
|
||||||
|
ChunkLoader loader = new ChunkLoader(id, name == null ? "" : name.trim(), true, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, System.currentTimeMillis());
|
||||||
|
|
||||||
|
synchronized (loaders) {
|
||||||
|
loaders.add(loader);
|
||||||
|
}
|
||||||
|
addTicket(loader);
|
||||||
|
save();
|
||||||
|
return loader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds duration (in milliseconds) to a chunk loader's expiration timer.
|
||||||
|
*/
|
||||||
|
boolean addTime(long id, long millisToAdd) {
|
||||||
|
ChunkLoader updated = null;
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (int i = 0; i < loaders.size(); i++) {
|
||||||
|
ChunkLoader curr = loaders.get(i);
|
||||||
|
if (curr.id() == id) {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long base = (curr.expiresAt() > now) ? curr.expiresAt() : now;
|
||||||
|
long newExpires = base + millisToAdd;
|
||||||
|
updated = curr.withExpiresAt(newExpires).withEnabled(true);
|
||||||
|
loaders.set(i, updated);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated != null) {
|
||||||
|
addTicket(updated);
|
||||||
|
save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a specific expiration timestamp (or 0 for permanent) for a chunk loader.
|
||||||
|
*/
|
||||||
|
boolean setExpiresAt(long id, long expiresAt) {
|
||||||
|
ChunkLoader updated = null;
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (int i = 0; i < loaders.size(); i++) {
|
||||||
|
ChunkLoader curr = loaders.get(i);
|
||||||
|
if (curr.id() == id) {
|
||||||
|
updated = curr.withExpiresAt(expiresAt).withEnabled(true);
|
||||||
|
loaders.set(i, updated);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated != null) {
|
||||||
|
addTicket(updated);
|
||||||
|
save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renames an existing chunk loader.
|
||||||
|
*/
|
||||||
|
boolean rename(long id, String newName) {
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (int i = 0; i < loaders.size(); i++) {
|
||||||
|
ChunkLoader curr = loaders.get(i);
|
||||||
|
if (curr.id() == id) {
|
||||||
|
loaders.set(i, curr.withName(newName));
|
||||||
|
save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables or disables (pauses) a chunk loader without destroying the block.
|
||||||
|
*/
|
||||||
|
boolean setEnabled(long id, boolean enabled) {
|
||||||
|
ChunkLoader oldLoader = null;
|
||||||
|
ChunkLoader updated = null;
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (int i = 0; i < loaders.size(); i++) {
|
||||||
|
ChunkLoader curr = loaders.get(i);
|
||||||
|
if (curr.id() == id) {
|
||||||
|
oldLoader = curr;
|
||||||
|
updated = curr.withEnabled(enabled);
|
||||||
|
loaders.set(i, updated);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldLoader != null && updated != null) {
|
||||||
|
if (enabled) {
|
||||||
|
addTicket(updated);
|
||||||
|
} else {
|
||||||
|
removeTicket(oldLoader);
|
||||||
|
}
|
||||||
|
save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a chunk loader by its numeric ID (with '#' prefix or plain ID) or custom name.
|
||||||
|
* Custom names take precedence over raw numeric IDs to avoid shadowing named loaders.
|
||||||
|
*/
|
||||||
|
ChunkLoader find(String query) {
|
||||||
|
if (query == null || query.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = query.trim();
|
||||||
|
if (trimmed.startsWith("#")) {
|
||||||
|
try {
|
||||||
|
long id = Long.parseLong(trimmed.substring(1));
|
||||||
|
ChunkLoader loader = byId(id);
|
||||||
|
if (loader != null) {
|
||||||
|
return loader;
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (ChunkLoader loader : loaders) {
|
||||||
|
if (loader.name() != null && loader.name().equalsIgnoreCase(trimmed)) {
|
||||||
|
return loader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
long id = Long.parseLong(trimmed);
|
||||||
|
ChunkLoader loader = byId(id);
|
||||||
|
if (loader != null) {
|
||||||
|
return loader;
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a chunk loader by its unique ID and releases the chunk ticket.
|
||||||
|
*/
|
||||||
|
boolean remove(long id) {
|
||||||
|
ChunkLoader removed = null;
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (int i = 0; i < loaders.size(); i++) {
|
||||||
|
if (loaders.get(i).id() == id) {
|
||||||
|
removed = loaders.remove(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (removed != null) {
|
||||||
|
removeTicket(removed);
|
||||||
|
save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChunkLoader byId(long id) {
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (ChunkLoader loader : loaders) {
|
||||||
|
if (loader.id() == id) {
|
||||||
|
return loader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ChunkLoader byChunk(String world, int chunkX, int chunkZ) {
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (ChunkLoader loader : loaders) {
|
||||||
|
if (loader.world().equalsIgnoreCase(world) && loader.chunkX() == chunkX && loader.chunkZ() == chunkZ) {
|
||||||
|
return loader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ChunkLoader byLocation(String world, int x, int y, int z) {
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (ChunkLoader loader : loaders) {
|
||||||
|
if (loader.world().equalsIgnoreCase(world) && loader.x() == x && loader.y() == y && loader.z() == z) {
|
||||||
|
return loader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ChunkLoader> byOwner(String ownerUuid) {
|
||||||
|
List<ChunkLoader> out = new ArrayList<>();
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (ChunkLoader loader : loaders) {
|
||||||
|
if (loader.ownerUuid().equalsIgnoreCase(ownerUuid)) {
|
||||||
|
out.add(loader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Collections.unmodifiableList(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ChunkLoader> all() {
|
||||||
|
synchronized (loaders) {
|
||||||
|
return Collections.unmodifiableList(new ArrayList<>(loaders));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int size() {
|
||||||
|
synchronized (loaders) {
|
||||||
|
return loaders.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the maximum chunk loaders allowed for a player based on LuckPerms permissions.
|
||||||
|
*/
|
||||||
|
static int playerLimit(Player player, int defaultLimit) {
|
||||||
|
if (player == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (player.hasPermission("canalhandia.admin") || player.hasPermission("canalhandia.chunkloader.admin")) {
|
||||||
|
return Integer.MAX_VALUE;
|
||||||
|
}
|
||||||
|
if (!player.hasPermission("canalhandia.chunkloader")) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int max = -1;
|
||||||
|
for (PermissionAttachmentInfo info : player.getEffectivePermissions()) {
|
||||||
|
String perm = info.getPermission().toLowerCase(Locale.ROOT);
|
||||||
|
if (info.getValue() && perm.startsWith(PERM_LIMIT_PREFIX)) {
|
||||||
|
String valStr = perm.substring(PERM_LIMIT_PREFIX.length());
|
||||||
|
try {
|
||||||
|
int val = Integer.parseInt(valStr);
|
||||||
|
if (val > max) {
|
||||||
|
max = val;
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max >= 0 ? max : defaultLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadAllTickets() {
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (ChunkLoader loader : loaders) {
|
||||||
|
if (loader.enabled()) {
|
||||||
|
addTicket(loader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void unloadAllTickets() {
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (ChunkLoader loader : loaders) {
|
||||||
|
removeTicket(loader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void startSimulation() {
|
||||||
|
if (plugin == null || simulationTask != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
simulationTask = Bukkit.getScheduler().runTaskTimer(plugin, this::tickMobSimulation, 20L, 20L);
|
||||||
|
}
|
||||||
|
|
||||||
|
void stopSimulation() {
|
||||||
|
if (simulationTask != null) {
|
||||||
|
simulationTask.cancel();
|
||||||
|
simulationTask = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void tickMobSimulation() {
|
||||||
|
if (plugin == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<ChunkLoader> currentLoaders;
|
||||||
|
synchronized (loaders) {
|
||||||
|
currentLoaders = new ArrayList<>(loaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ChunkLoader loader : currentLoaders) {
|
||||||
|
if (loader.isExpired() && loader.enabled()) {
|
||||||
|
setEnabled(loader.id(), false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!loader.enabled()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
World w = Bukkit.getWorld(loader.world());
|
||||||
|
if (w == null || !w.isChunkLoaded(loader.chunkX(), loader.chunkZ())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Chunk chunk = w.getChunkAt(loader.chunkX(), loader.chunkZ());
|
||||||
|
|
||||||
|
boolean spawnersActive = plugin.settings() == null || plugin.settings().chunkLoaderSpawnersActive();
|
||||||
|
boolean monsterSpawning = plugin.settings() == null || plugin.settings().chunkLoaderMonsterSpawning();
|
||||||
|
int monsterCap = plugin.settings() != null ? plugin.settings().chunkLoaderMonsterCap() : 20;
|
||||||
|
|
||||||
|
// 1. Keep mob spawners (dungeon / blaze / skeleton cages) active if enabled
|
||||||
|
if (spawnersActive) {
|
||||||
|
for (BlockState state : chunk.getTileEntities()) {
|
||||||
|
if (state instanceof CreatureSpawner spawner) {
|
||||||
|
if (spawner.getRequiredPlayerRange() < 1024) {
|
||||||
|
spawner.setRequiredPlayerRange(2048);
|
||||||
|
spawner.update(true, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Simulate natural mob spawning (dark platforms / slime / nether farms) if enabled
|
||||||
|
if (monsterSpawning) {
|
||||||
|
long mobCount = 0;
|
||||||
|
for (Entity entity : chunk.getEntities()) {
|
||||||
|
if (entity instanceof Mob) {
|
||||||
|
mobCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mobCount < monsterCap) {
|
||||||
|
simulateNaturalSpawning(w, loader, chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void simulateNaturalSpawning(World w, ChunkLoader loader, Chunk chunk) {
|
||||||
|
int baseX = loader.chunkX() << 4;
|
||||||
|
int baseZ = loader.chunkZ() << 4;
|
||||||
|
ThreadLocalRandom rnd = ThreadLocalRandom.current();
|
||||||
|
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
int rx = baseX + rnd.nextInt(16);
|
||||||
|
int rz = baseZ + rnd.nextInt(16);
|
||||||
|
int topY = Math.max(w.getMinHeight() + 2, Math.min(w.getMaxHeight() - 2, loader.y() + rnd.nextInt(-24, 25)));
|
||||||
|
|
||||||
|
Block ground = w.getBlockAt(rx, topY - 1, rz);
|
||||||
|
Block space = w.getBlockAt(rx, topY, rz);
|
||||||
|
Block spaceAbove = w.getBlockAt(rx, topY + 1, rz);
|
||||||
|
|
||||||
|
if (!ground.getType().isSolid() || ground.isLiquid() || ground.getType() == Material.LAVA || ground.getType() == Material.WATER) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!space.getType().isAir()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isAirAbove = spaceAbove.getType().isAir();
|
||||||
|
boolean isTrapdoorAbove = isTrapdoor(spaceAbove.getType());
|
||||||
|
if (!isAirAbove && !isTrapdoorAbove) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int light = space.getLightLevel();
|
||||||
|
EntityType toSpawn;
|
||||||
|
if (isTrapdoorAbove) {
|
||||||
|
if (light > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
toSpawn = EntityType.CREEPER;
|
||||||
|
} else {
|
||||||
|
toSpawn = pickEntityType(w, space, light);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toSpawn == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Entity spawned = w.spawnEntity(space.getLocation().add(0.5, 0, 0.5), toSpawn, CreatureSpawnEvent.SpawnReason.NATURAL);
|
||||||
|
if (spawned instanceof Mob mob) {
|
||||||
|
mob.setRemoveWhenFarAway(false);
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isTrapdoor(Material mat) {
|
||||||
|
if (mat == null) return false;
|
||||||
|
return mat.name().endsWith("_TRAPDOOR");
|
||||||
|
}
|
||||||
|
|
||||||
|
static EntityType pickEntityType(World w, Block space, int light) {
|
||||||
|
boolean isSlime = false;
|
||||||
|
try {
|
||||||
|
isSlime = space.getChunk().isSlimeChunk();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
return pickEntityType(w.getEnvironment(), isSlime, space.getY(), light, ThreadLocalRandom.current().nextInt(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
static EntityType pickEntityType(World.Environment env, boolean isSlimeChunk, int y, int light, int roll) {
|
||||||
|
if (env == World.Environment.NETHER) {
|
||||||
|
if (light > 11) return null;
|
||||||
|
int r = Math.floorMod(roll, 100);
|
||||||
|
if (r < 45) return EntityType.ZOMBIFIED_PIGLIN;
|
||||||
|
if (r < 65) return EntityType.WITHER_SKELETON;
|
||||||
|
if (r < 85) return EntityType.BLAZE;
|
||||||
|
return EntityType.MAGMA_CUBE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env == World.Environment.THE_END) {
|
||||||
|
return EntityType.ENDERMAN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSlimeChunk && y < 40 && light <= 7 && roll % 3 == 0) {
|
||||||
|
return EntityType.SLIME;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (light > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int r = Math.floorMod(roll, 100);
|
||||||
|
if (r < 35) return EntityType.ZOMBIE;
|
||||||
|
if (r < 65) return EntityType.SKELETON;
|
||||||
|
if (r < 85) return EntityType.CREEPER;
|
||||||
|
if (r < 95) return EntityType.SPIDER;
|
||||||
|
return EntityType.WITCH;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addTicket(ChunkLoader loader) {
|
||||||
|
if (plugin == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
World w = Bukkit.getWorld(loader.world());
|
||||||
|
if (w != null) {
|
||||||
|
w.setChunkForceLoaded(loader.chunkX(), loader.chunkZ(), true);
|
||||||
|
w.addPluginChunkTicket(loader.chunkX(), loader.chunkZ(), plugin);
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeTicket(ChunkLoader loader) {
|
||||||
|
if (plugin == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
World w = Bukkit.getWorld(loader.world());
|
||||||
|
if (w != null) {
|
||||||
|
w.removePluginChunkTicket(loader.chunkX(), loader.chunkZ(), plugin);
|
||||||
|
w.setChunkForceLoaded(loader.chunkX(), loader.chunkZ(), false);
|
||||||
|
if (w.isChunkLoaded(loader.chunkX(), loader.chunkZ())) {
|
||||||
|
Chunk chunk = w.getChunkAt(loader.chunkX(), loader.chunkZ());
|
||||||
|
for (BlockState state : chunk.getTileEntities()) {
|
||||||
|
if (state instanceof CreatureSpawner spawner && spawner.getRequiredPlayerRange() > 16) {
|
||||||
|
spawner.setRequiredPlayerRange(16);
|
||||||
|
spawner.update(true, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private YamlConfiguration buildYaml() {
|
||||||
|
YamlConfiguration yaml = new YamlConfiguration();
|
||||||
|
synchronized (loaders) {
|
||||||
|
for (ChunkLoader l : loaders) {
|
||||||
|
String key = "loaders." + l.id();
|
||||||
|
yaml.set(key + ".name", l.name());
|
||||||
|
yaml.set(key + ".enabled", l.enabled());
|
||||||
|
yaml.set(key + ".expires_at", l.expiresAt());
|
||||||
|
yaml.set(key + ".owner_uuid", l.ownerUuid());
|
||||||
|
yaml.set(key + ".owner_name", l.ownerName());
|
||||||
|
yaml.set(key + ".world", l.world());
|
||||||
|
yaml.set(key + ".x", l.x());
|
||||||
|
yaml.set(key + ".y", l.y());
|
||||||
|
yaml.set(key + ".z", l.z());
|
||||||
|
yaml.set(key + ".chunk_x", l.chunkX());
|
||||||
|
yaml.set(key + ".chunk_z", l.chunkZ());
|
||||||
|
yaml.set(key + ".created_at", l.createdAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return yaml;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void save() {
|
||||||
|
YamlConfiguration yaml = buildYaml();
|
||||||
|
io.execute(() -> {
|
||||||
|
try {
|
||||||
|
yaml.save(file);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void flush() {
|
||||||
|
try {
|
||||||
|
io.submit(() -> {}).get(2, TimeUnit.SECONDS);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() {
|
||||||
|
flush();
|
||||||
|
io.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@ enum Module {
|
|||||||
NOTAS("notas", "Anotações públicas e privadas no chat"),
|
NOTAS("notas", "Anotações públicas e privadas no chat"),
|
||||||
RECADOS("recados", "Recados entregues quando o jogador entra"),
|
RECADOS("recados", "Recados entregues quando o jogador entra"),
|
||||||
CONQUISTAS("conquistas", "Conquistas com nome, além dos marcos numéricos"),
|
CONQUISTAS("conquistas", "Conquistas com nome, além dos marcos numéricos"),
|
||||||
IA("ia", "Perguntas para a IA");
|
IA("ia", "Perguntas para a IA"),
|
||||||
|
CHUNKLOADER("chunkloader", "Âncoras de carregamento de chunks"),
|
||||||
|
SALVAVOID("salvavoid", "Proteção de itens no vácuo");
|
||||||
|
|
||||||
private final String key;
|
private final String key;
|
||||||
private final String label;
|
private final String label;
|
||||||
|
|||||||
@@ -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,343 @@
|
|||||||
|
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.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.
|
||||||
|
*
|
||||||
|
* <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<>();
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String summary(UUID uuid) {
|
||||||
|
synchronized (profiles) {
|
||||||
|
Profile p = profiles.get(uuid);
|
||||||
|
return p != null ? p.summary() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> facts(UUID uuid) {
|
||||||
|
synchronized (profiles) {
|
||||||
|
Profile p = profiles.get(uuid);
|
||||||
|
return p != null ? Collections.unmodifiableList(p.facts()) : List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setPersona(UUID uuid, String name, Persona persona) {
|
||||||
|
if (persona == null) {
|
||||||
|
resetPersona(uuid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
synchronized (profiles) {
|
||||||
|
Profile p = profiles.computeIfAbsent(uuid, k -> new Profile(k, name, null, "", new ArrayList<>(), System.currentTimeMillis()));
|
||||||
|
profiles.put(uuid, p.withPersona(persona));
|
||||||
|
}
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetPersona(UUID uuid) {
|
||||||
|
synchronized (profiles) {
|
||||||
|
Profile p = profiles.get(uuid);
|
||||||
|
if (p != null) {
|
||||||
|
profiles.put(uuid, p.withPersona(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 clean = text.trim();
|
||||||
|
String lower = clean.toLowerCase(Locale.ROOT);
|
||||||
|
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 = clean.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) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flushes any pending background writes to disk (useful for shutdown or tests). */
|
||||||
|
void flush() {
|
||||||
|
try {
|
||||||
|
io.submit(() -> {}).get(2, TimeUnit.SECONDS);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private YamlConfiguration buildYaml() {
|
||||||
|
YamlConfiguration yaml = new YamlConfiguration();
|
||||||
|
synchronized (profiles) {
|
||||||
|
for (Map.Entry<UUID, Profile> entry : profiles.entrySet()) {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
throw new IllegalStateException("não consegui gravar " + file, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -579,6 +579,90 @@ final class Settings {
|
|||||||
set("categorias." + category.key(), enabled);
|
set("categorias." + category.key(), enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- chunkloader --------------------------------------------------------
|
||||||
|
|
||||||
|
int chunkLoaderDefaultLimit() {
|
||||||
|
return Math.max(0, plugin.getConfig().getInt("chunkloader.limite-padrao", 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
void chunkLoaderDefaultLimit(int limit) {
|
||||||
|
set("chunkloader.limite-padrao", Math.max(0, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean chunkLoaderBlueMap() {
|
||||||
|
return plugin.getConfig().getBoolean("chunkloader.bluemap", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void chunkLoaderBlueMap(boolean enabled) {
|
||||||
|
set("chunkloader.bluemap", enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean chunkLoaderSpawnersActive() {
|
||||||
|
return plugin.getConfig().getBoolean("chunkloader.simulacao.gaiolas-ativas", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void chunkLoaderSpawnersActive(boolean active) {
|
||||||
|
set("chunkloader.simulacao.gaiolas-ativas", active);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean chunkLoaderMonsterSpawning() {
|
||||||
|
return plugin.getConfig().getBoolean("chunkloader.simulacao.spawn-monstros", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void chunkLoaderMonsterSpawning(boolean spawning) {
|
||||||
|
set("chunkloader.simulacao.spawn-monstros", spawning);
|
||||||
|
}
|
||||||
|
|
||||||
|
int chunkLoaderMonsterCap() {
|
||||||
|
return Math.max(1, plugin.getConfig().getInt("chunkloader.simulacao.limite-monstros-chunk", 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
void chunkLoaderMonsterCap(int cap) {
|
||||||
|
set("chunkloader.simulacao.limite-monstros-chunk", Math.max(1, cap));
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean chunkLoaderPassiveEntities() {
|
||||||
|
return plugin.getConfig().getBoolean("chunkloader.simulacao.tick-entidades-passivas", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void chunkLoaderPassiveEntities(boolean active) {
|
||||||
|
set("chunkloader.simulacao.tick-entidades-passivas", active);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean chunkLoaderRequiresTime() {
|
||||||
|
return plugin.getConfig().getBoolean("chunkloader.combustivel.requer-tempo", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void chunkLoaderRequiresTime(boolean required) {
|
||||||
|
set("chunkloader.combustivel.requer-tempo", required);
|
||||||
|
}
|
||||||
|
|
||||||
|
double chunkLoaderInitialHours() {
|
||||||
|
return Math.max(0.0, plugin.getConfig().getDouble("chunkloader.combustivel.tempo-inicial-horas", 24.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void chunkLoaderInitialHours(double hours) {
|
||||||
|
set("chunkloader.combustivel.tempo-inicial-horas", Math.max(0.0, hours));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- salvavoid ----------------------------------------------------------
|
||||||
|
|
||||||
|
int voidProtectionRadius() {
|
||||||
|
return Math.max(1, plugin.getConfig().getInt("salvavoid.raio-busca", 32));
|
||||||
|
}
|
||||||
|
|
||||||
|
void voidProtectionRadius(int radius) {
|
||||||
|
set("salvavoid.raio-busca", Math.max(1, radius));
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean voidProtectionKeepXp() {
|
||||||
|
return plugin.getConfig().getBoolean("salvavoid.preservar-xp", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void voidProtectionKeepXp(boolean keep) {
|
||||||
|
set("salvavoid.preservar-xp", keep);
|
||||||
|
}
|
||||||
|
|
||||||
// --- plumbing -----------------------------------------------------------
|
// --- plumbing -----------------------------------------------------------
|
||||||
|
|
||||||
private void set(String path, Object value) {
|
private void set(String path, Object value) {
|
||||||
|
|||||||
@@ -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,58 @@ 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, Note.Scope.PUBLICA, "");
|
||||||
|
return formatPlayerPlaces(who, deaths, notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatPlayerPlaces(OfflineStats.Known who, List<DeathLog.Entry> deaths, List<Note> visibleNotes) {
|
||||||
|
String uuidStr = who.uuid();
|
||||||
|
List<Note> notes = new java.util.ArrayList<>();
|
||||||
|
if (visibleNotes != null) {
|
||||||
|
for (Note n : visibleNotes) {
|
||||||
|
if (n.scope() == Note.Scope.PUBLICA && n.authorId() != null && n.authorId().equals(uuidStr)) {
|
||||||
|
notes.add(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("Lugares conhecidos de ").append(who.name()).append(":\n");
|
||||||
|
|
||||||
|
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 == null || 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) {
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.bukkit.HeightMap;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.block.BlockState;
|
||||||
|
import org.bukkit.block.Chest;
|
||||||
|
import org.bukkit.event.entity.EntityDamageEvent;
|
||||||
|
import org.bukkit.inventory.Inventory;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rescues player items on void death, either into a safe nearby chest
|
||||||
|
* on solid ground or directly retained in the player's inventory.
|
||||||
|
*/
|
||||||
|
public final class VoidProtection {
|
||||||
|
|
||||||
|
private static final int MAX_CHECKED_COLUMNS = 120;
|
||||||
|
|
||||||
|
private VoidProtection() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether a death was caused by falling into the void.
|
||||||
|
*/
|
||||||
|
public static boolean isVoidDeath(double y, double minHeight, EntityDamageEvent.DamageCause cause) {
|
||||||
|
if (cause == EntityDamageEvent.DamageCause.VOID) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return y < minHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the nearest safe solid block with air space above it within the given horizontal radius.
|
||||||
|
* Only checks already-loaded chunks and caps checked columns to prevent server hitches.
|
||||||
|
*/
|
||||||
|
public static Location findSafeChestLocation(World world, Location deathLoc, int radius) {
|
||||||
|
if (world == null || deathLoc == null || radius <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int centerX = deathLoc.getBlockX();
|
||||||
|
int centerZ = deathLoc.getBlockZ();
|
||||||
|
int minHeight = world.getMinHeight();
|
||||||
|
int checked = 0;
|
||||||
|
|
||||||
|
for (int r = 0; r <= radius; r += (r > 8 ? 2 : 1)) {
|
||||||
|
Location ringBest = null;
|
||||||
|
double ringBestDistSq = Double.MAX_VALUE;
|
||||||
|
|
||||||
|
for (int dx = -r; dx <= r; dx += (r > 8 ? 2 : 1)) {
|
||||||
|
for (int dz = -r; dz <= r; dz += (r > 8 ? 2 : 1)) {
|
||||||
|
if (r > 0 && Math.abs(dx) != r && Math.abs(dz) != r) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (++checked > MAX_CHECKED_COLUMNS) {
|
||||||
|
return ringBest;
|
||||||
|
}
|
||||||
|
|
||||||
|
int x = centerX + dx;
|
||||||
|
int z = centerZ + dz;
|
||||||
|
|
||||||
|
// Do not load or generate new chunks synchronously on death
|
||||||
|
if (!world.isChunkLoaded(x >> 4, z >> 4)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int topY;
|
||||||
|
try {
|
||||||
|
topY = world.getHighestBlockYAt(x, z, HeightMap.MOTION_BLOCKING_NO_LEAVES);
|
||||||
|
} catch (Exception e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int maxHeight = world.getMaxHeight();
|
||||||
|
if (topY <= minHeight || topY >= maxHeight - 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Block ground;
|
||||||
|
Block space;
|
||||||
|
try {
|
||||||
|
ground = world.getBlockAt(x, topY, z);
|
||||||
|
space = world.getBlockAt(x, topY + 1, z);
|
||||||
|
} catch (Exception e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSafeGround(ground) && isReplaceable(space)) {
|
||||||
|
double distSq = (dx * dx) + (dz * dz);
|
||||||
|
if (distSq < ringBestDistSq) {
|
||||||
|
ringBestDistSq = distSq;
|
||||||
|
ringBest = space.getLocation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ringBest != null) {
|
||||||
|
return ringBest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isSafeGround(Block block) {
|
||||||
|
return block != null && isSafeGroundMaterial(block.getType());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isSafeGroundMaterial(Material mat) {
|
||||||
|
if (mat == null || mat == Material.AIR || mat == Material.CAVE_AIR || mat == Material.VOID_AIR) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (mat == Material.LAVA || mat == Material.WATER || mat == Material.FIRE || mat == Material.SOUL_FIRE
|
||||||
|
|| mat == Material.CACTUS || mat == Material.MAGMA_BLOCK || mat == Material.SWEET_BERRY_BUSH
|
||||||
|
|| mat == Material.WITHER_ROSE || mat == Material.POWDER_SNOW) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return mat.isSolid();
|
||||||
|
} catch (Exception | LinkageError e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isReplaceable(Block block) {
|
||||||
|
return block != null && isReplaceableMaterial(block.getType());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isReplaceableMaterial(Material mat) {
|
||||||
|
if (mat == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return mat == Material.AIR || mat == Material.CAVE_AIR || mat == Material.VOID_AIR
|
||||||
|
|| mat == Material.SHORT_GRASS || mat == Material.TALL_GRASS
|
||||||
|
|| mat == Material.SNOW || mat == Material.FERN || mat == Material.LARGE_FERN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the estimated number of inventory slots needed to store the given items.
|
||||||
|
*/
|
||||||
|
public static int calculateRequiredSlots(List<ItemStack> items) {
|
||||||
|
if (items == null || items.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int slots = 0;
|
||||||
|
for (ItemStack item : items) {
|
||||||
|
if (item != null && item.getType() != Material.AIR && item.getAmount() > 0) {
|
||||||
|
int maxStack = Math.max(1, item.getMaxStackSize());
|
||||||
|
slots += (int) Math.ceil((double) item.getAmount() / maxStack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean canFitInSingleChest(List<ItemStack> items) {
|
||||||
|
return calculateRequiredSlots(items) <= 27;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean canFitInDoubleChest(List<ItemStack> items) {
|
||||||
|
return calculateRequiredSlots(items) <= 54;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores items into a chest (and an adjacent chest if needed) at the target location.
|
||||||
|
* All items must be stored without overflow; on any failure, blocks are rolled back
|
||||||
|
* and false is returned so caller can safely fall back to keepInventory.
|
||||||
|
*/
|
||||||
|
public static boolean rescueToChest(List<ItemStack> items, Location chestLoc) {
|
||||||
|
if (items == null || items.isEmpty() || chestLoc == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Block chestBlock = chestLoc.getBlock();
|
||||||
|
Material orig1 = chestBlock.getType();
|
||||||
|
Block adjacent = null;
|
||||||
|
Material orig2 = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
chestBlock.setType(Material.CHEST, false);
|
||||||
|
BlockState state = chestBlock.getState();
|
||||||
|
if (!(state instanceof Chest chest)) {
|
||||||
|
chestBlock.setType(orig1, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Inventory inv = chest.getInventory();
|
||||||
|
List<ItemStack> remaining = new ArrayList<>();
|
||||||
|
|
||||||
|
for (ItemStack item : items) {
|
||||||
|
if (item != null && !item.getType().isAir()) {
|
||||||
|
Map<Integer, ItemStack> leftover = inv.addItem(item.clone());
|
||||||
|
remaining.addAll(leftover.values());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!remaining.isEmpty()) {
|
||||||
|
adjacent = findAdjacentSpace(chestLoc);
|
||||||
|
if (adjacent == null) {
|
||||||
|
// Cannot fit all items and no space for second chest -> rollback
|
||||||
|
inv.clear();
|
||||||
|
chestBlock.setType(orig1, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
orig2 = adjacent.getType();
|
||||||
|
adjacent.setType(Material.CHEST, false);
|
||||||
|
BlockState adjState = adjacent.getState();
|
||||||
|
if (!(adjState instanceof Chest adjChest)) {
|
||||||
|
inv.clear();
|
||||||
|
chestBlock.setType(orig1, false);
|
||||||
|
adjacent.setType(orig2, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Inventory adjInv = adjChest.getInventory();
|
||||||
|
List<ItemStack> secondLeftover = new ArrayList<>();
|
||||||
|
for (ItemStack rem : remaining) {
|
||||||
|
Map<Integer, ItemStack> leftover = adjInv.addItem(rem);
|
||||||
|
secondLeftover.addAll(leftover.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!secondLeftover.isEmpty()) {
|
||||||
|
// Still overflowed double chest -> rollback everything
|
||||||
|
inv.clear();
|
||||||
|
adjInv.clear();
|
||||||
|
chestBlock.setType(orig1, false);
|
||||||
|
adjacent.setType(orig2, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
try {
|
||||||
|
if (chestBlock.getType() == Material.CHEST) {
|
||||||
|
BlockState s = chestBlock.getState();
|
||||||
|
if (s instanceof Chest c) c.getInventory().clear();
|
||||||
|
chestBlock.setType(orig1, false);
|
||||||
|
}
|
||||||
|
if (adjacent != null && adjacent.getType() == Material.CHEST) {
|
||||||
|
BlockState s2 = adjacent.getState();
|
||||||
|
if (s2 instanceof Chest c2) c2.getInventory().clear();
|
||||||
|
adjacent.setType(orig2 != null ? orig2 : Material.AIR, false);
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Block findAdjacentSpace(Location loc) {
|
||||||
|
World w = loc.getWorld();
|
||||||
|
if (w == null) return null;
|
||||||
|
int x = loc.getBlockX();
|
||||||
|
int y = loc.getBlockY();
|
||||||
|
int z = loc.getBlockZ();
|
||||||
|
|
||||||
|
int[][] offsets = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
|
||||||
|
for (int[] off : offsets) {
|
||||||
|
Block b = w.getBlockAt(x + off[0], y, z + off[1]);
|
||||||
|
Block ground = w.getBlockAt(x + off[0], y - 1, z + off[1]);
|
||||||
|
if (isReplaceable(b) && isSafeGround(ground)) {
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import com.destroystokyo.paper.event.player.PlayerPickupExperienceEvent;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Sound;
|
||||||
|
import org.bukkit.entity.Entity;
|
||||||
|
import org.bukkit.entity.ExperienceOrb;
|
||||||
|
import org.bukkit.entity.Item;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.entity.ItemSpawnEvent;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.enchantments.Enchantment;
|
||||||
|
import org.bukkit.inventory.PlayerInventory;
|
||||||
|
import org.bukkit.inventory.meta.Damageable;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimises XP and Item collection by instantly vacuuming nearby XP orbs into the player's
|
||||||
|
* XP bar (bypassing Vanilla's 2-tick per orb delay), repairing Mending equipment first,
|
||||||
|
* and consolidating spawned item drops into full stacks.
|
||||||
|
*/
|
||||||
|
public final class XpAndItemMergeListener implements Listener {
|
||||||
|
|
||||||
|
private final Canalhandia plugin;
|
||||||
|
|
||||||
|
public XpAndItemMergeListener(Canalhandia plugin) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vacuums all experience orbs within radius instantly on player contact and repairs Mending items.
|
||||||
|
*/
|
||||||
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||||
|
public void onPlayerPickupXp(PlayerPickupExperienceEvent event) {
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
Location loc = player.getLocation();
|
||||||
|
if (loc.getWorld() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int totalExp = 0;
|
||||||
|
try {
|
||||||
|
for (Entity entity : loc.getWorld().getNearbyEntities(loc, 6.0, 6.0, 6.0)) {
|
||||||
|
if (entity instanceof ExperienceOrb orb && orb.isValid()) {
|
||||||
|
totalExp += orb.getExperience();
|
||||||
|
orb.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalExp > 0) {
|
||||||
|
int leftoverExp = applyMending(player, totalExp);
|
||||||
|
if (leftoverExp > 0) {
|
||||||
|
player.giveExp(leftoverExp);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
player.playSound(loc, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 0.5f, 1.2f);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
event.setCancelled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repairs equipped items with Mending enchantment using the collected XP.
|
||||||
|
* Returns remaining XP to be added to the player's experience bar.
|
||||||
|
*/
|
||||||
|
public static int applyMending(Player player, int totalExp) {
|
||||||
|
if (player == null || totalExp <= 0) {
|
||||||
|
return totalExp;
|
||||||
|
}
|
||||||
|
int remainingXp = totalExp;
|
||||||
|
PlayerInventory inv = player.getInventory();
|
||||||
|
|
||||||
|
List<ItemStack> candidates = new ArrayList<>();
|
||||||
|
ItemStack main = inv.getItemInMainHand();
|
||||||
|
if (isDamagedMending(main)) candidates.add(main);
|
||||||
|
|
||||||
|
ItemStack off = inv.getItemInOffHand();
|
||||||
|
if (isDamagedMending(off)) candidates.add(off);
|
||||||
|
|
||||||
|
for (ItemStack armor : inv.getArmorContents()) {
|
||||||
|
if (isDamagedMending(armor)) candidates.add(armor);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ItemStack item : candidates) {
|
||||||
|
if (remainingXp <= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (item.getItemMeta() instanceof Damageable dmg && dmg.hasDamage()) {
|
||||||
|
int damage = dmg.getDamage();
|
||||||
|
int[] repair = calculateMendingRepair(damage, remainingXp);
|
||||||
|
int repaired = repair[0];
|
||||||
|
int xpUsed = repair[1];
|
||||||
|
if (repaired > 0) {
|
||||||
|
dmg.setDamage(damage - repaired);
|
||||||
|
item.setItemMeta(dmg);
|
||||||
|
remainingXp -= xpUsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remainingXp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isDamagedMending(ItemStack item) {
|
||||||
|
if (item == null || item.getType() == Material.AIR || !item.hasItemMeta()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!item.containsEnchantment(Enchantment.MENDING)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (item.getItemMeta() instanceof Damageable dmg) {
|
||||||
|
return dmg.hasDamage() && dmg.getDamage() > 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates repair amount and used XP for a given damage and available XP.
|
||||||
|
* Returns an int array: [durabilityRepaired, xpUsed].
|
||||||
|
*/
|
||||||
|
public static int[] calculateMendingRepair(int damage, int availableXp) {
|
||||||
|
if (damage <= 0 || availableXp <= 0) {
|
||||||
|
return new int[]{0, 0};
|
||||||
|
}
|
||||||
|
int durabilityToRepair = Math.min(damage, availableXp * 2);
|
||||||
|
int xpUsed = (durabilityToRepair + 1) / 2;
|
||||||
|
return new int[]{durabilityToRepair, xpUsed};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consolidates spawned items of the same type in a 4-block radius into unified stacks.
|
||||||
|
*/
|
||||||
|
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||||
|
public void onItemSpawn(ItemSpawnEvent event) {
|
||||||
|
Item item = event.getEntity();
|
||||||
|
ItemStack stack = item.getItemStack();
|
||||||
|
if (stack.getAmount() >= stack.getMaxStackSize() || item.getWorld() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (Entity e : item.getWorld().getNearbyEntities(item.getLocation(), 4.0, 4.0, 4.0)) {
|
||||||
|
if (e instanceof Item other && other != item && other.isValid()) {
|
||||||
|
ItemStack otherStack = other.getItemStack();
|
||||||
|
if (otherStack.isSimilar(stack)) {
|
||||||
|
int toTransfer = calculateTransfer(stack.getAmount(), stack.getMaxStackSize(), otherStack.getAmount());
|
||||||
|
if (toTransfer > 0) {
|
||||||
|
stack.setAmount(stack.getAmount() + toTransfer);
|
||||||
|
otherStack.setAmount(otherStack.getAmount() - toTransfer);
|
||||||
|
if (otherStack.getAmount() <= 0) {
|
||||||
|
other.remove();
|
||||||
|
} else {
|
||||||
|
other.setItemStack(otherStack);
|
||||||
|
}
|
||||||
|
item.setItemStack(stack);
|
||||||
|
if (stack.getAmount() >= stack.getMaxStackSize()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure transfer amount calculator for stack consolidation. */
|
||||||
|
public static int calculateTransfer(int targetAmount, int targetMax, int sourceAmount) {
|
||||||
|
if (targetAmount < 0 || targetMax <= 0 || sourceAmount <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int canAdd = targetMax - targetAmount;
|
||||||
|
if (canAdd <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.min(canAdd, sourceAmount);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -344,3 +344,43 @@ mortes:
|
|||||||
- "BONE | Osso da Sorte | Um ossinho pra você, campeão."
|
- "BONE | Osso da Sorte | Um ossinho pra você, campeão."
|
||||||
- "COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora."
|
- "COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora."
|
||||||
- "ROTTEN_FLESH | Carne Podre | É o que tinha sobrado na despensa."
|
- "ROTTEN_FLESH | Carne Podre | É o que tinha sobrado na despensa."
|
||||||
|
|
||||||
|
# --- Âncoras de Chunk (Chunk Loaders) ----------------------------------------
|
||||||
|
chunkloader:
|
||||||
|
# Limite máximo de âncoras ativas por jogador por padrão.
|
||||||
|
# Pode ser alterado por jogador com /chunkloader limite <jogador> <qtd>.
|
||||||
|
limite-padrao: 1
|
||||||
|
|
||||||
|
# Exibe os marcadores de âncoras ativas no BlueMap.
|
||||||
|
bluemap: true
|
||||||
|
|
||||||
|
# Configurações de simulação de entidades na chunk carregada:
|
||||||
|
simulacao:
|
||||||
|
# Se true, mantém ativas gaiolas de monstros (spawners de blaze, esqueletos, etc.)
|
||||||
|
# mesmo sem jogadores por perto.
|
||||||
|
gaiolas-ativas: true
|
||||||
|
|
||||||
|
# Se true, simula o surgimento natural de monstros em plataformas escuras,
|
||||||
|
# slime chunks e fortalezas do nether. Se false, apenas entidades existentes funcionam.
|
||||||
|
spawn-monstros: true
|
||||||
|
|
||||||
|
# Teto máximo de monstros na chunk antes de pausar o spawn natural (evita acúmulo de lag).
|
||||||
|
limite-monstros-chunk: 20
|
||||||
|
|
||||||
|
# Se true, mantém animais, aldeões, ferro-golems e colheitas sendo processados na chunk.
|
||||||
|
tick-entidades-passivas: true
|
||||||
|
|
||||||
|
# Sistema de combustível e tempo de expiração:
|
||||||
|
combustivel:
|
||||||
|
# Se true, âncoras requerem abastecimento com itens e têm tempo de expiração.
|
||||||
|
# Se false, todas as âncoras colocadas são permanentes por padrão.
|
||||||
|
requer-tempo: true
|
||||||
|
|
||||||
|
# Tempo inicial padrão dado ao criar e colocar uma nova âncora (em horas).
|
||||||
|
tempo-inicial-horas: 24
|
||||||
|
|
||||||
|
# --- Salva-Void (Resgate de Morte no Vazio) -----------------------------------
|
||||||
|
salvavoid:
|
||||||
|
# Raio máximo de busca em blocos a partir de onde o jogador caiu para achar
|
||||||
|
# terreno seguro e colocar o baú com os itens resgatados.
|
||||||
|
raio-busca: 32
|
||||||
|
|||||||
@@ -98,10 +98,20 @@ commands:
|
|||||||
description: Escolhe qual conquista você exibe como título no chat.
|
description: Escolhe qual conquista você exibe como título no chat.
|
||||||
usage: /titulo [nome|limpar]
|
usage: /titulo [nome|limpar]
|
||||||
aliases: [titulos, title]
|
aliases: [titulos, title]
|
||||||
|
chunkloader:
|
||||||
|
description: Gerenciamento de Âncoras de Chunk (chunk loaders).
|
||||||
|
usage: /chunkloader [listar|info|remover|receita]
|
||||||
|
aliases: [ancora, ancoras, chunkloaders]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
# Declared explicitly: an undeclared Bukkit permission falls back to op-only,
|
# Declared explicitly: an undeclared Bukkit permission falls back to op-only,
|
||||||
# which would silently stop normal players reacting, voting or guessing.
|
# which would silently stop normal players reacting, voting or guessing.
|
||||||
|
canalhandia.chunkloader:
|
||||||
|
description: Permite usar e criar Âncoras de Chunk.
|
||||||
|
default: true
|
||||||
|
canalhandia.chunkloader.admin:
|
||||||
|
description: Permite gerenciar e remover qualquer Âncora de Chunk no servidor.
|
||||||
|
default: op
|
||||||
canalhandia.reagir:
|
canalhandia.reagir:
|
||||||
description: Permite reagir e prestar luto.
|
description: Permite reagir e prestar luto.
|
||||||
default: true
|
default: true
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class AiTagTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allPersonasRenderDynamicTagsCorrectly() {
|
||||||
|
for (Persona persona : Persona.values()) {
|
||||||
|
Component c = Ai.style("Minha resposta", "Qualquer pergunta", persona, true, false, true);
|
||||||
|
String plain = PlainTextComponentSerializer.plainText().serialize(c);
|
||||||
|
|
||||||
|
assertTrue(plain.contains("[" + persona.displayTag() + "]"),
|
||||||
|
"Rendered component must contain tag for " + persona);
|
||||||
|
assertTrue(plain.contains("Minha resposta"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void continuationLinesUseArrowPrefix() {
|
||||||
|
Component c = Ai.style("Segunda linha", "Pergunta", Persona.JUDITE, true, false, false);
|
||||||
|
String plain = PlainTextComponentSerializer.plainText().serialize(c);
|
||||||
|
|
||||||
|
assertTrue(plain.contains("»"));
|
||||||
|
assertTrue(plain.contains("Segunda linha"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bedrockDisablesHoverEvent() {
|
||||||
|
Component c = Ai.style("Resposta Bedrock", "Pergunta", Persona.JUDITE, true, true, true);
|
||||||
|
assertNull(c.children().isEmpty() ? c.hoverEvent() : c.children().get(c.children().size() - 1).hoverEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void javaFancyIncludesHoverWithPersonaDetails() {
|
||||||
|
Component c = Ai.style("Resposta Java", "Pergunta de teste", Persona.NARRADOR, true, false, true);
|
||||||
|
assertNotNull(c);
|
||||||
|
String plain = PlainTextComponentSerializer.plainText().serialize(c);
|
||||||
|
assertTrue(plain.contains("[Narrador]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
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 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 ChunkLoaderTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
private File file;
|
||||||
|
private ChunkLoaders loaders;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
file = tempDir.resolve("chunks.yml").toFile();
|
||||||
|
loaders = new ChunkLoaders(null, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
if (loaders != null) {
|
||||||
|
loaders.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startsEmpty() {
|
||||||
|
assertEquals(0, loaders.size());
|
||||||
|
assertTrue(loaders.all().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addsAndCalculatesChunkCoordinates() {
|
||||||
|
ChunkLoader loader = loaders.add("uuid-marcos", "Marcos", "world", 100, 64, 200);
|
||||||
|
assertEquals(1, loader.id());
|
||||||
|
assertEquals("uuid-marcos", loader.ownerUuid());
|
||||||
|
assertEquals("Marcos", loader.ownerName());
|
||||||
|
assertEquals(6, loader.chunkX()); // 100 >> 4 = 6
|
||||||
|
assertEquals(12, loader.chunkZ()); // 200 >> 4 = 12
|
||||||
|
assertEquals("100, 64, 200 (world)", loader.place());
|
||||||
|
assertEquals("[6, 12]", loader.chunkCoords());
|
||||||
|
assertEquals(1, loaders.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void queriesByChunkLocationAndOwner() {
|
||||||
|
ChunkLoader l1 = loaders.add("uuid-marcos", "Marcos", "world", 16, 64, 32);
|
||||||
|
ChunkLoader l2 = loaders.add("uuid-ana", "Ana", "world_nether", 0, 70, 0);
|
||||||
|
|
||||||
|
assertEquals(l1, loaders.byLocation("world", 16, 64, 32));
|
||||||
|
assertNull(loaders.byLocation("world", 17, 64, 32));
|
||||||
|
|
||||||
|
assertEquals(l1, loaders.byChunk("world", 1, 2));
|
||||||
|
assertEquals(l2, loaders.byChunk("world_nether", 0, 0));
|
||||||
|
assertNull(loaders.byChunk("world", 0, 0));
|
||||||
|
|
||||||
|
List<ChunkLoader> marcosLoaders = loaders.byOwner("uuid-marcos");
|
||||||
|
assertEquals(1, marcosLoaders.size());
|
||||||
|
assertEquals(l1, marcosLoaders.get(0));
|
||||||
|
|
||||||
|
List<ChunkLoader> anaLoaders = loaders.byOwner("uuid-ana");
|
||||||
|
assertEquals(1, anaLoaders.size());
|
||||||
|
assertEquals(l2, anaLoaders.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removesLoaderById() {
|
||||||
|
ChunkLoader l1 = loaders.add("uuid-marcos", "Marcos", "world", 16, 64, 32);
|
||||||
|
assertTrue(loaders.remove(l1.id()));
|
||||||
|
assertEquals(0, loaders.size());
|
||||||
|
assertNull(loaders.byId(l1.id()));
|
||||||
|
assertFalse(loaders.remove(l1.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void persistsToDiskAndReloads() {
|
||||||
|
loaders.add("uuid-marcos", "Marcos", "world", 100, 64, 200);
|
||||||
|
loaders.add("uuid-ana", "Ana", "world_nether", 50, 70, -50);
|
||||||
|
loaders.flush();
|
||||||
|
|
||||||
|
ChunkLoaders reloaded = new ChunkLoaders(null, file);
|
||||||
|
assertEquals(2, reloaded.size());
|
||||||
|
|
||||||
|
ChunkLoader l1 = reloaded.byId(1);
|
||||||
|
assertNotNull(l1);
|
||||||
|
assertEquals("uuid-marcos", l1.ownerUuid());
|
||||||
|
assertEquals(6, l1.chunkX());
|
||||||
|
assertEquals(12, l1.chunkZ());
|
||||||
|
|
||||||
|
ChunkLoader l2 = reloaded.byId(2);
|
||||||
|
assertNotNull(l2);
|
||||||
|
assertEquals("uuid-ana", l2.ownerUuid());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void renamesAndTogglesEnabledState() {
|
||||||
|
ChunkLoader loader = loaders.add("uuid-marcos", "Marcos", "world", 100, 64, 200);
|
||||||
|
assertEquals("#1", loader.simpleName());
|
||||||
|
assertTrue(loader.enabled());
|
||||||
|
|
||||||
|
assertTrue(loaders.rename(loader.id(), "Farm de Ferro"));
|
||||||
|
ChunkLoader renamed = loaders.byId(loader.id());
|
||||||
|
assertNotNull(renamed);
|
||||||
|
assertEquals("Farm de Ferro", renamed.name());
|
||||||
|
assertEquals("Farm de Ferro (#1)", renamed.displayName());
|
||||||
|
assertEquals("Farm de Ferro", renamed.simpleName());
|
||||||
|
|
||||||
|
assertEquals(renamed, loaders.find("Farm de Ferro"));
|
||||||
|
assertEquals(renamed, loaders.find("#1"));
|
||||||
|
assertEquals(renamed, loaders.find("1"));
|
||||||
|
|
||||||
|
// Loader 2 named "1" should be prioritized over ID 1 when querying by plain "1"
|
||||||
|
ChunkLoader loader2 = loaders.add("uuid-marcos", "Marcos", "world", 200, 64, 300, "1", 0L);
|
||||||
|
assertEquals(loader2, loaders.find("1"));
|
||||||
|
assertEquals(renamed, loaders.find("#1"));
|
||||||
|
assertEquals(loader2, loaders.find("#2"));
|
||||||
|
|
||||||
|
assertTrue(loaders.setEnabled(loader.id(), false));
|
||||||
|
ChunkLoader disabled = loaders.byId(loader.id());
|
||||||
|
assertNotNull(disabled);
|
||||||
|
assertFalse(disabled.enabled());
|
||||||
|
|
||||||
|
loaders.flush();
|
||||||
|
ChunkLoaders reloaded = new ChunkLoaders(null, file);
|
||||||
|
ChunkLoader reloadedLoader = reloaded.byId(loader.id());
|
||||||
|
assertNotNull(reloadedLoader);
|
||||||
|
assertEquals("Farm de Ferro", reloadedLoader.name());
|
||||||
|
assertFalse(reloadedLoader.enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void managesExpirationAndFuelTime() {
|
||||||
|
ChunkLoader loader = loaders.add("uuid-marcos", "Marcos", "world", 100, 64, 200, "Base", 0L);
|
||||||
|
assertEquals("Permanente", loader.timeLeft());
|
||||||
|
assertFalse(loader.isExpired());
|
||||||
|
|
||||||
|
assertTrue(loaders.addTime(loader.id(), 2 * 3600_000L)); // +2h
|
||||||
|
ChunkLoader timed = loaders.byId(loader.id());
|
||||||
|
assertNotNull(timed);
|
||||||
|
assertTrue(timed.expiresAt() > System.currentTimeMillis());
|
||||||
|
assertFalse(timed.isExpired());
|
||||||
|
assertTrue(timed.timeLeft().contains("h"));
|
||||||
|
|
||||||
|
ChunkLoader expired = timed.withExpiresAt(System.currentTimeMillis() - 1000L);
|
||||||
|
assertTrue(expired.isExpired());
|
||||||
|
assertEquals("Expirado", expired.timeLeft());
|
||||||
|
|
||||||
|
assertTrue(loaders.setExpiresAt(loader.id(), 0L));
|
||||||
|
ChunkLoader permanent = loaders.byId(loader.id());
|
||||||
|
assertNotNull(permanent);
|
||||||
|
assertEquals(0L, permanent.expiresAt());
|
||||||
|
assertEquals("Permanente", permanent.timeLeft());
|
||||||
|
assertFalse(permanent.isExpired());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pickEntityTypeReturnsExpectedEntitiesByEnvironment() {
|
||||||
|
// Nether
|
||||||
|
assertEquals(org.bukkit.entity.EntityType.ZOMBIFIED_PIGLIN,
|
||||||
|
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 0, 10));
|
||||||
|
assertEquals(org.bukkit.entity.EntityType.WITHER_SKELETON,
|
||||||
|
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 0, 50));
|
||||||
|
assertEquals(org.bukkit.entity.EntityType.BLAZE,
|
||||||
|
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 0, 75));
|
||||||
|
assertEquals(org.bukkit.entity.EntityType.MAGMA_CUBE,
|
||||||
|
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 0, 90));
|
||||||
|
assertNull(ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NETHER, false, 64, 12, 10));
|
||||||
|
|
||||||
|
// End
|
||||||
|
assertEquals(org.bukkit.entity.EntityType.ENDERMAN,
|
||||||
|
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.THE_END, false, 64, 15, 50));
|
||||||
|
|
||||||
|
// Overworld Slime Chunk
|
||||||
|
assertEquals(org.bukkit.entity.EntityType.SLIME,
|
||||||
|
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, true, 30, 5, 0));
|
||||||
|
|
||||||
|
// Overworld Normal Darkness
|
||||||
|
assertEquals(org.bukkit.entity.EntityType.ZOMBIE,
|
||||||
|
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 10));
|
||||||
|
assertEquals(org.bukkit.entity.EntityType.SKELETON,
|
||||||
|
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 45));
|
||||||
|
assertEquals(org.bukkit.entity.EntityType.CREEPER,
|
||||||
|
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 75));
|
||||||
|
assertEquals(org.bukkit.entity.EntityType.SPIDER,
|
||||||
|
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 90));
|
||||||
|
assertEquals(org.bukkit.entity.EntityType.WITCH,
|
||||||
|
ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 0, 99));
|
||||||
|
|
||||||
|
// Overworld in Light -> Null
|
||||||
|
assertNull(ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 5, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsLoaderWithInitialExpiration() {
|
||||||
|
long targetExpiry = System.currentTimeMillis() + 86400_000L;
|
||||||
|
ChunkLoader loader = loaders.add("uuid-1", "Marcos", "world", 100, 64, 200, "Base", targetExpiry);
|
||||||
|
assertEquals(targetExpiry, loader.expiresAt());
|
||||||
|
assertFalse(loader.isExpired());
|
||||||
|
assertTrue(loader.timeLeft().contains("h"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recognizesTrapdoorsCorrectly() {
|
||||||
|
assertTrue(ChunkLoaders.isTrapdoor(org.bukkit.Material.OAK_TRAPDOOR));
|
||||||
|
assertTrue(ChunkLoaders.isTrapdoor(org.bukkit.Material.IRON_TRAPDOOR));
|
||||||
|
assertTrue(ChunkLoaders.isTrapdoor(org.bukkit.Material.SPRUCE_TRAPDOOR));
|
||||||
|
assertFalse(ChunkLoaders.isTrapdoor(org.bukkit.Material.AIR));
|
||||||
|
assertFalse(ChunkLoaders.isTrapdoor(org.bukkit.Material.STONE));
|
||||||
|
assertFalse(ChunkLoaders.isTrapdoor(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void simulationSafelyNoopsWithNullPlugin() {
|
||||||
|
loaders.startSimulation();
|
||||||
|
loaders.tickMobSimulation();
|
||||||
|
loaders.stopSimulation();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,163 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@org.junit.jupiter.api.AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
if (memory != null) {
|
||||||
|
memory.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
@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));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void extractsHeuristicFactsAutomatically() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
memory.recordTurn(id, "Marcos", "Minha base fica nas montanhas nevadas!", "Que lugar bonito!");
|
||||||
|
List<String> facts = memory.facts(id);
|
||||||
|
assertFalse(facts.isEmpty());
|
||||||
|
assertTrue(facts.get(0).toLowerCase().contains("minha base"));
|
||||||
|
|
||||||
|
String fact = PlayerMemory.extractHeuristicFact("Eu estou construindo uma pirâmide gigante?");
|
||||||
|
assertNotNull(fact);
|
||||||
|
assertTrue(fact.contains("estou construindo uma pirâmide gigante"));
|
||||||
|
assertNull(PlayerMemory.extractHeuristicFact("quantos blocos tem o mundo?"));
|
||||||
|
|
||||||
|
// Leading / trailing whitespace index alignment
|
||||||
|
assertEquals("minha base fica no topo", PlayerMemory.extractHeuristicFact(" minha base fica no topo! "));
|
||||||
|
assertEquals("estou construindo uma ponte", PlayerMemory.extractHeuristicFact(" oi, estou construindo uma ponte..."));
|
||||||
|
}
|
||||||
|
|
||||||
|
@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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,10 +25,14 @@ class TitlesTest {
|
|||||||
void matchesEarnedTitleByKeyAndByName() {
|
void matchesEarnedTitleByKeyAndByName() {
|
||||||
Achievement pedreiro = Achievement.byKey("pedreiro");
|
Achievement pedreiro = Achievement.byKey("pedreiro");
|
||||||
Achievement veterano = Achievement.byKey("veterano");
|
Achievement veterano = Achievement.byKey("veterano");
|
||||||
List<Achievement> earned = List.of(pedreiro, veterano);
|
Achievement cacador = Achievement.byKey("cacador");
|
||||||
|
List<Achievement> earned = List.of(pedreiro, veterano, cacador);
|
||||||
assertSame(pedreiro, CanalhandiaCommand.matchEarned("pedreiro", earned));
|
assertSame(pedreiro, CanalhandiaCommand.matchEarned("pedreiro", earned));
|
||||||
assertSame(veterano, CanalhandiaCommand.matchEarned("Veterano", earned)); // display name, ci
|
assertSame(veterano, CanalhandiaCommand.matchEarned("Veterano", earned)); // display name, ci
|
||||||
assertSame(pedreiro, CanalhandiaCommand.matchEarned("Pedreiro", earned));
|
assertSame(pedreiro, CanalhandiaCommand.matchEarned("Pedreiro", earned));
|
||||||
|
assertSame(cacador, CanalhandiaCommand.matchEarned("cacador", earned));
|
||||||
|
assertSame(cacador, CanalhandiaCommand.matchEarned("Caçador", earned));
|
||||||
|
assertSame(cacador, CanalhandiaCommand.matchEarned("Cacador", earned));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.bukkit.event.entity.EntityDamageEvent;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class VoidProtectionTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void identifiesVoidDeathByCause() {
|
||||||
|
assertTrue(VoidProtection.isVoidDeath(100.0, -64.0, EntityDamageEvent.DamageCause.VOID));
|
||||||
|
assertTrue(VoidProtection.isVoidDeath(-70.0, -64.0, EntityDamageEvent.DamageCause.VOID));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void identifiesVoidDeathByCoordinatesBelowMinHeight() {
|
||||||
|
assertTrue(VoidProtection.isVoidDeath(-65.0, -64.0, EntityDamageEvent.DamageCause.FALL));
|
||||||
|
assertTrue(VoidProtection.isVoidDeath(-100.0, 0.0, EntityDamageEvent.DamageCause.CUSTOM));
|
||||||
|
assertFalse(VoidProtection.isVoidDeath(50.0, -64.0, EntityDamageEvent.DamageCause.FALL));
|
||||||
|
assertFalse(VoidProtection.isVoidDeath(10.0, 0.0, EntityDamageEvent.DamageCause.LAVA));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handlesNullWorldOrLocationsGracefully() {
|
||||||
|
assertNull(VoidProtection.findSafeChestLocation(null, null, 10));
|
||||||
|
assertNull(VoidProtection.findSafeChestLocation(null, null, 0));
|
||||||
|
assertFalse(VoidProtection.rescueToChest(null, null));
|
||||||
|
assertFalse(VoidProtection.rescueToChest(List.of(), null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validatesSafeGroundMaterials() {
|
||||||
|
assertTrue(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.STONE));
|
||||||
|
assertTrue(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.GRASS_BLOCK));
|
||||||
|
assertTrue(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.DIRT));
|
||||||
|
assertTrue(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.OBSIDIAN));
|
||||||
|
|
||||||
|
assertFalse(VoidProtection.isSafeGroundMaterial(null));
|
||||||
|
assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.AIR));
|
||||||
|
assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.LAVA));
|
||||||
|
assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.FIRE));
|
||||||
|
assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.SOUL_FIRE));
|
||||||
|
assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.CACTUS));
|
||||||
|
assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.MAGMA_BLOCK));
|
||||||
|
assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.SWEET_BERRY_BUSH));
|
||||||
|
assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.WITHER_ROSE));
|
||||||
|
assertFalse(VoidProtection.isSafeGroundMaterial(org.bukkit.Material.POWDER_SNOW));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validatesReplaceableMaterials() {
|
||||||
|
assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.AIR));
|
||||||
|
assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.CAVE_AIR));
|
||||||
|
assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.VOID_AIR));
|
||||||
|
assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.SHORT_GRASS));
|
||||||
|
assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.TALL_GRASS));
|
||||||
|
assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.SNOW));
|
||||||
|
assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.FERN));
|
||||||
|
assertTrue(VoidProtection.isReplaceableMaterial(org.bukkit.Material.LARGE_FERN));
|
||||||
|
|
||||||
|
assertFalse(VoidProtection.isReplaceableMaterial(null));
|
||||||
|
assertFalse(VoidProtection.isReplaceableMaterial(org.bukkit.Material.STONE));
|
||||||
|
assertFalse(VoidProtection.isReplaceableMaterial(org.bukkit.Material.CHEST));
|
||||||
|
assertFalse(VoidProtection.isReplaceableMaterial(org.bukkit.Material.OBSIDIAN));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullBlocksAreNeitherSafeNorReplaceable() {
|
||||||
|
assertFalse(VoidProtection.isSafeGround(null));
|
||||||
|
assertFalse(VoidProtection.isReplaceable(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void calculatesRequiredSlotsAndChestFit() {
|
||||||
|
assertEquals(0, VoidProtection.calculateRequiredSlots(null));
|
||||||
|
assertEquals(0, VoidProtection.calculateRequiredSlots(List.of()));
|
||||||
|
assertTrue(VoidProtection.canFitInSingleChest(List.of()));
|
||||||
|
assertTrue(VoidProtection.canFitInDoubleChest(List.of()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class XpAndItemMergeTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handlesZeroOrNegativeAmountsGracefully() {
|
||||||
|
assertEquals(0, XpAndItemMergeListener.calculateTransfer(0, 0, 0));
|
||||||
|
assertEquals(0, XpAndItemMergeListener.calculateTransfer(-1, 16, 5));
|
||||||
|
assertEquals(0, XpAndItemMergeListener.calculateTransfer(16, 16, 5));
|
||||||
|
assertEquals(0, XpAndItemMergeListener.calculateTransfer(10, 16, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void calculatesTransfersUpToMaxCapacity() {
|
||||||
|
// Target has 10, max is 16, source has 10 -> can transfer 6
|
||||||
|
assertEquals(6, XpAndItemMergeListener.calculateTransfer(10, 16, 10));
|
||||||
|
|
||||||
|
// Target has 5, max is 64, source has 20 -> can transfer all 20
|
||||||
|
assertEquals(20, XpAndItemMergeListener.calculateTransfer(5, 64, 20));
|
||||||
|
|
||||||
|
// Target has 63, max is 64, source has 10 -> can transfer 1
|
||||||
|
assertEquals(1, XpAndItemMergeListener.calculateTransfer(63, 64, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void calculatesMendingRepairCorrectly() {
|
||||||
|
// Zero damage or zero XP -> 0 repair
|
||||||
|
int[] r1 = XpAndItemMergeListener.calculateMendingRepair(0, 50);
|
||||||
|
assertEquals(0, r1[0]);
|
||||||
|
assertEquals(0, r1[1]);
|
||||||
|
|
||||||
|
int[] r2 = XpAndItemMergeListener.calculateMendingRepair(50, 0);
|
||||||
|
assertEquals(0, r2[0]);
|
||||||
|
assertEquals(0, r2[1]);
|
||||||
|
|
||||||
|
// 10 damage, 100 XP -> repairs 10 durability, uses 5 XP
|
||||||
|
int[] r3 = XpAndItemMergeListener.calculateMendingRepair(10, 100);
|
||||||
|
assertEquals(10, r3[0]);
|
||||||
|
assertEquals(5, r3[1]);
|
||||||
|
|
||||||
|
// 5 damage, 10 XP -> repairs 5 durability, uses 3 XP
|
||||||
|
int[] r4 = XpAndItemMergeListener.calculateMendingRepair(5, 10);
|
||||||
|
assertEquals(5, r4[0]);
|
||||||
|
assertEquals(3, r4[1]);
|
||||||
|
|
||||||
|
// 10 damage, 2 XP -> repairs 4 durability (2 * 2), uses 2 XP
|
||||||
|
int[] r5 = XpAndItemMergeListener.calculateMendingRepair(10, 2);
|
||||||
|
assertEquals(4, r5[0]);
|
||||||
|
assertEquals(2, r5[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user