36 Commits

Author SHA1 Message Date
Marcos Paulo 3a21d3cfd5 fix(xp): prioritize repairing equipped mending items before giving leftover xp to player 2026-08-22 13:44:54 -03:00
Marcos Paulo 2a63a04e4f fix(chunkloader): support trapdoor ceilings and creeper-specific spawning on simulated platforms 2026-08-20 20:20:59 -03:00
Marcos Paulo f35e4a5bff feat(chunkloader): add comprehensive configuration for simulation, passive entities, mob cap, and fuel 2026-08-20 17:43:50 -03:00
Marcos Paulo 45f129df56 feat(xp-items): implement instant xp vacuum and spawned item consolidation listener 2026-08-20 16:55:19 -03:00
Marcos Paulo aa56f385f8 fix(titles): improve title matching with accent normalization and removal aliases 2026-08-20 16:12:58 -03:00
pragent-bot 60989cd72f docs: add CONTRIBUTING.md (testing requirements + pragent notes)
Specifies what counts as "tested" for this repo, the local test
commands the bot treats as gates, and how pragent reads the repo
config (`.pr-review.json` + static architecture.md from Nexus
raw-hosted).
2026-08-20 18:49:06 +00:00
pragent-bot 6b2a73678f chore(pr): add .gitea/pull_request_template.md (pragent workflow)
Auto-loaded by Gitea on every new PR. Lists the surfaces and risks
pragent scores you on; tells the author to add the AI-REVIEW label
to trigger a bot review.
2026-08-20 18:48:58 +00:00
Marcos Paulo 8ddc22409e Merge pull request #5 from feat/void-protection 2026-08-20 15:09:50 -03:00
Marcos Paulo bc7a88b9e2 chore: sync .pr-review.json with main 2026-08-20 15:08:41 -03:00
marcos 8a34565f3c chore(pr-review): re-trigger bot review against updated .pr-review.json 2026-08-20 17:56:03 +00:00
marcos f24af7ab08 chore(pr-review): add .pr-review.json with canalhandia-specific steer + repo context
Wires the new ADDITIONAL_CONTEXT_URL feature on the central webhook service
(see gitea_admin/pragent e8ebc54) — fetches a static maintainer-curated brief
from the Nexus raw-hosted repo at review time. The doc distils the house
rules so the agent doesn't have to re-derive them from the diff each PR:

* chat messages are immutable after send (counts baked into buttons freeze)
* translatable components + singular-form rule for pt-BR sentences
* Geyser/Bedrock can't click — every click has a typed fallback
* vanilla stats only — <world>/players/stats/<uuid>.json, NOT <world>/stats
* reactions keep counting late (reacao-validade-minutos)
* Floodgate is optional runtime, never compile-time

Plus the canonical focus/instructions/languages config: java, balanced style,
claude-sonnet-5 cost target, JDK 25 build note.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 17:54:32 +00:00
Marcos Paulo b6e508c8c9 fix(pr-reviews): complete all PR comments resolutions and test coverage 2026-08-20 14:51:06 -03:00
Marcos Paulo cc5c983e41 fix(pr-reviews): address spawner reset, numeric name resolution, and activation validation 2026-08-20 14:48:21 -03:00
pragent test 07e5bb6018 chore: retrigger pragent review (post format-overhaul) 2026-08-20 17:17:44 +00:00
pragent test f2a19a729a chore: retrigger pragent review 2026-08-20 16:38:42 +00:00
pragent test dfb84047ec chore: retrigger pragent review 2026-08-20 16:38:18 +00:00
pragent test 8821e6d2c0 chore: retrigger pragent review (no-op) 2026-08-20 16:36:28 +00:00
Marcos Paulo 1ff830537e feat(chunkloader): add fuel charging, duration timers, and /chunkloader tempo 2026-08-20 13:19:57 -03:00
pragent-bot da4a1777a7 chore: add .pr-review.json for pragent 2026-08-20 16:14:05 +00:00
Marcos Paulo ce3b1ebbd9 feat(chunkloader): support custom names, anvil placement, and pause/resume 2026-08-20 13:13:06 -03:00
Marcos Paulo 5a6bb9a13d feat(chunkloader): add /chunkloader limite command and tab completion 2026-08-20 12:55:38 -03:00
Marcos Paulo 4b9507b36e feat(chunkloader): add mob spawning simulation, spawner activation and despawn protection 2026-08-20 12:53:19 -03:00
Marcos Paulo 4261e8b660 fix(chunkloader): use setChunkForceLoaded for full block/entity/redstone ticking 2026-08-20 11:16:24 -03:00
Marcos Paulo 2c7f5f98ad fix(review): check event.getKeepInventory(), equipment slot hand, and world height bounds 2026-08-20 10:21:11 -03:00
Marcos Paulo c3c4906e11 fix(review): address review comments and add .pr-review.json for pragent 2026-08-20 10:14:18 -03:00
Marcos Paulo d93711e5c1 fix(chunkloader): deliver anchor directly to inventory on break and protect against accidental interaction 2026-08-20 10:01:59 -03:00
Marcos Paulo 24ab63b9be feat(void): add void death item protection with safe chest and inventory fallback 2026-08-20 09:52:48 -03:00
Marcos Paulo e059ca6563 feat(chunkloader): Módulo nativo de Chunk Loader com limites LuckPerms e BlueMap (#4) 2026-08-19 15:31:22 -03:00
Marcos Paulo e70df329b3 fix(chunkloader): release tickets on disable/toggle, fix BlueMap world matching, and prevent item loss on remove 2026-08-19 15:09:44 -03:00
Marcos Paulo e71babca1c fix(memory): align substring index with trimmed input in extractHeuristicFact 2026-08-18 22:00:42 -03:00
Marcos Paulo 01ada6d987 feat(chunkloader): add native chunk loader module with LuckPerms limits and BlueMap support 2026-08-18 21:45:51 -03:00
masi 01e6c28fef Merge pull request 'feat(ia): add Judite & Narrador personas, per-player AI selection, persistent memory, and event reactivity' (#3) from feat/ia-personalities-and-memory into main
Reviewed-on: #3
2026-08-18 22:52:02 +00:00
Marcos Paulo 998757d610 fix(ia): make memory persistence async, wire heuristic fact extraction and /ia lembrar, and add tag and event tests 2026-08-18 19:42:46 -03:00
Marcos Paulo 2220f11e64 fix(ia): prevent private notes leak in places tool, guard recordTurn against disk failure, and add tests 2026-08-18 19:15:02 -03:00
Marcos Paulo ea55813019 feat(ia): add Judite & Narrador personas, per-player AI selection, persistent memory, and event reactivity 2026-08-18 19:00:11 -03:00
masi dafd96a4b6 i18n: per-player EN/PT via Adventure GlobalTranslator (#1)
Foundation + commands module of the i18n spec.

- I18n registry/loader + Lang.tr facade + reloadI18n
- PT source-of-truth bundle + EN translation
- CanalhandiaCommand player-facing strings migrated; admin-tuning/help/enum-labels deferred
- I18nTest: parity + per-locale render + pt_BR fallback; 316/316 green

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-12 15:53:27 +00:00
52 changed files with 5621 additions and 319 deletions
+53
View File
@@ -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.
+30
View File
@@ -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."
}
+93
View File
@@ -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.
+2
View File
@@ -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.
+84
View File
@@ -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.
+103
View File
@@ -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`.
+37
View File
@@ -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.
+72
View File
@@ -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.
+28
View File
@@ -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`.
+23
View File
@@ -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.
@@ -1,12 +1,16 @@
package dev.marcospaulo.canalhandia; package dev.marcospaulo.canalhandia;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.ConfigurationSection;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.logging.Logger; import java.util.logging.Logger;
/** /**
@@ -23,11 +27,18 @@ import java.util.logging.Logger;
* a number, another metric, or {@code metrica/numero}. Metrics are written in * a number, another metric, or {@code metrica/numero}. Metrics are written in
* friendly units — distance in kilometres, time in hours — normalised from the * friendly units — distance in kilometres, time in hours — normalised from the
* raw statistics before evaluation, so the file reads the way a person thinks. * raw statistics before evaluation, so the file reads the way a person thinks.
* A metric may also be a {@link StatRef} like {@code matou:creeper}, reaching any
* per-mob or per-block vanilla counter with no code change.
*
* <p>Each title also carries a {@code tier} (comum…lendario) that colours its
* chat tag, and an optional {@code cor} override (named or {@code #hex}), so a
* legendary reads gold and a rare reads aqua without touching Java.
*/ */
final class Achievement { final class Achievement {
/** Metrics a condition may read, in the units the config is written in. /** Friendly metrics a condition may read, in the units the config is written in.
* mineracao/combate/mortes/pesca/pulos are raw counts; distancia is km; tempo is hours. */ * mineracao/combate/mortes/pesca/pulos are raw counts; distancia is km; tempo is hours.
* A condition may also name a {@link StatRef} (e.g. {@code matou:creeper}). */
private static final List<String> METRICS = List.of( private static final List<String> METRICS = List.of(
"mineracao", "combate", "mortes", "pesca", "pulos", "distancia", "tempo"); "mineracao", "combate", "mortes", "pesca", "pulos", "distancia", "tempo");
@@ -38,12 +49,17 @@ final class Achievement {
private final String title; private final String title;
private final String description; private final String description;
private final Condition condition; private final Condition condition;
private final TextColor color;
private final Set<String> statRefs;
private Achievement(String key, String title, String description, Condition condition) { private Achievement(String key, String title, String description, Condition condition,
TextColor color, Set<String> statRefs) {
this.key = key; this.key = key;
this.title = title; this.title = title;
this.description = description; this.description = description;
this.condition = condition; this.condition = condition;
this.color = color;
this.statRefs = Set.copyOf(statRefs);
} }
String key() { String key() {
@@ -58,6 +74,11 @@ final class Achievement {
return description; return description;
} }
/** The colour this title's chat tag is drawn in, from its tier or {@code cor} override. */
TextColor color() {
return color;
}
/** True when this player's raw statistics satisfy the condition. */ /** True when this player's raw statistics satisfy the condition. */
boolean met(Map<String, Long> rawStats) { boolean met(Map<String, Long> rawStats) {
return rawStats != null && condition.met(normalise(rawStats)); return rawStats != null && condition.met(normalise(rawStats));
@@ -75,6 +96,16 @@ final class Achievement {
return catalog.toArray(new Achievement[0]); return catalog.toArray(new Achievement[0]);
} }
/** Every vanilla {@link StatRef} the live catalogue mentions, so the stats
* reader knows which per-mob/per-block counters to fetch. Empty until load. */
static Set<String> referencedStats() {
Set<String> all = new HashSet<>();
for (Achievement achievement : catalog) {
all.addAll(achievement.statRefs);
}
return all;
}
static Achievement byKey(String key) { static Achievement byKey(String key) {
if (key == null) { if (key == null) {
return null; return null;
@@ -123,7 +154,8 @@ final class Achievement {
} }
try { try {
out.add(parse(key, entry.getString("titulo", ""), out.add(parse(key, entry.getString("titulo", ""),
entry.getString("descricao", ""), entry.getStringList("condicoes"))); entry.getString("descricao", ""), entry.getStringList("condicoes"),
entry.getString("tier"), entry.getString("cor")));
} catch (IllegalArgumentException bad) { } catch (IllegalArgumentException bad) {
log.warning("Conquista '" + key + "' ignorada: " + bad.getMessage()); log.warning("Conquista '" + key + "' ignorada: " + bad.getMessage());
} }
@@ -131,8 +163,14 @@ final class Achievement {
return out; return out;
} }
/** Builds one achievement, parsing its condition clauses. Visible for tests. */ /** Builds one achievement with the default (comum) colour. Visible for tests. */
static Achievement parse(String key, String title, String description, List<String> conditions) { static Achievement parse(String key, String title, String description, List<String> conditions) {
return parse(key, title, description, conditions, null, null);
}
/** Builds one achievement, parsing its condition clauses and resolving its colour. */
static Achievement parse(String key, String title, String description, List<String> conditions,
String tier, String cor) {
String normalizedKey = key == null ? "" : key.trim().toLowerCase(Locale.ROOT); String normalizedKey = key == null ? "" : key.trim().toLowerCase(Locale.ROOT);
if (!normalizedKey.matches("[a-z-]+")) { if (!normalizedKey.matches("[a-z-]+")) {
throw new IllegalArgumentException("chave inválida (use apenas a-z e '-'): " + key); throw new IllegalArgumentException("chave inválida (use apenas a-z e '-'): " + key);
@@ -144,8 +182,16 @@ final class Achievement {
throw new IllegalArgumentException("sem condicoes"); throw new IllegalArgumentException("sem condicoes");
} }
List<Clause> clauses = new ArrayList<>(); List<Clause> clauses = new ArrayList<>();
Set<String> refs = new HashSet<>();
for (String raw : conditions) { for (String raw : conditions) {
clauses.add(Clause.parse(raw)); Clause clause = Clause.parse(raw);
clauses.add(clause);
if (StatRef.isRef(clause.metric())) {
refs.add(clause.metric());
}
if (clause.rhsMetric() != null && StatRef.isRef(clause.rhsMetric())) {
refs.add(clause.rhsMetric());
}
} }
Condition condition = stats -> { Condition condition = stats -> {
for (Clause clause : clauses) { for (Clause clause : clauses) {
@@ -155,12 +201,41 @@ final class Achievement {
} }
return true; return true;
}; };
return new Achievement(normalizedKey, title, description, condition); return new Achievement(normalizedKey, title, description, condition,
resolveColor(tier, cor), refs);
} }
/** Raw statistics → the friendly units the conditions are written in. */ /** Tier or explicit {@code cor} → the colour of the chat tag. Bad input falls
* back to the tier colour, and an unknown tier to a readable white. */
private static TextColor resolveColor(String tier, String cor) {
if (cor != null && !cor.isBlank()) {
String value = cor.trim();
TextColor explicit = value.startsWith("#")
? TextColor.fromHexString(value)
: NamedTextColor.NAMES.value(value.toLowerCase(Locale.ROOT));
if (explicit != null) {
return explicit;
}
}
return tierColor(tier);
}
/** Default colour for each difficulty tier. Higher tiers read cooler/brighter. */
private static TextColor tierColor(String tier) {
String name = tier == null ? "" : tier.trim().toLowerCase(Locale.ROOT);
return switch (name) {
case "incomum" -> NamedTextColor.GREEN;
case "raro" -> NamedTextColor.AQUA;
case "epico", "épico" -> NamedTextColor.LIGHT_PURPLE;
case "lendario", "lendário" -> NamedTextColor.GOLD;
default -> NamedTextColor.WHITE; // comum / unset — always legible
};
}
/** Raw statistics → the friendly units the conditions are written in. Any
* {@link StatRef} counts (matou:*, minerou:*) pass through untouched. */
private static Map<String, Long> normalise(Map<String, Long> raw) { private static Map<String, Long> normalise(Map<String, Long> raw) {
Map<String, Long> out = new HashMap<>(); Map<String, Long> out = new HashMap<>(raw);
out.put("mineracao", raw.getOrDefault("mineracao", 0L)); out.put("mineracao", raw.getOrDefault("mineracao", 0L));
out.put("combate", raw.getOrDefault("combate", 0L)); out.put("combate", raw.getOrDefault("combate", 0L));
out.put("mortes", raw.getOrDefault("mortes", 0L)); out.put("mortes", raw.getOrDefault("mortes", 0L));
@@ -215,7 +290,7 @@ final class Achievement {
throw new IllegalArgumentException("condicao mal formada: '" + raw + "'"); throw new IllegalArgumentException("condicao mal formada: '" + raw + "'");
} }
String metric = parts[0].toLowerCase(Locale.ROOT); String metric = parts[0].toLowerCase(Locale.ROOT);
if (!METRICS.contains(metric)) { if (!METRICS.contains(metric) && !StatRef.isValid(metric)) {
throw new IllegalArgumentException("metrica desconhecida: " + metric); throw new IllegalArgumentException("metrica desconhecida: " + metric);
} }
Op op = Op.of(parts[1]); Op op = Op.of(parts[1]);
@@ -237,7 +312,7 @@ final class Achievement {
throw new IllegalArgumentException("divisão por zero: " + target); throw new IllegalArgumentException("divisão por zero: " + target);
} }
} }
if (!METRICS.contains(rhsMetric)) { if (!METRICS.contains(rhsMetric) && !StatRef.isValid(rhsMetric)) {
throw new IllegalArgumentException("metrica desconhecida: " + rhsMetric); throw new IllegalArgumentException("metrica desconhecida: " + rhsMetric);
} }
return new Clause(metric, op, rhsMetric, 0, divisor); return new Clause(metric, op, rhsMetric, 0, divisor);
@@ -4,14 +4,12 @@ import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration; import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Statistic;
import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -113,7 +111,14 @@ final class Achievements {
/** @return true if anything was recorded, so the caller can save once */ /** @return true if anything was recorded, so the caller can save once */
private boolean check(Player player) { private boolean check(Player player) {
Map<String, Long> stats = snapshot(player); // Read straight off the stats file, the same source /perfil and /conquistas
// use, so a title can hinge on any per-mob or per-block counter (matou:creeper)
// that Bukkit's typed API would make us enumerate by hand. The file lags a
// live session by seconds — invisible for cumulative threshold titles.
Map<String, Long> stats = plugin.offlineStats().achievementStats(player.getUniqueId());
if (stats == null) {
return false; // no stats file written yet — nothing to bank, retry next tick
}
String base = player.getUniqueId().toString(); String base = player.getUniqueId().toString();
// A player with no record yet is being seen for the first time: bank // A player with no record yet is being seen for the first time: bank
// what they have without announcing it. // what they have without announcing it.
@@ -149,11 +154,22 @@ final class Achievements {
.decoration(TextDecoration.BOLD, false)) .decoration(TextDecoration.BOLD, false))
.append(Component.text(" desbloqueou ", NamedTextColor.WHITE) .append(Component.text(" desbloqueou ", NamedTextColor.WHITE)
.decoration(TextDecoration.BOLD, false)) .decoration(TextDecoration.BOLD, false))
.append(Component.text(achievement.title(), NamedTextColor.AQUA) .append(Component.text(achievement.title(), achievement.color())
.decoration(TextDecoration.BOLD, false)) .decoration(TextDecoration.BOLD, false))
.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. */
@@ -168,36 +184,6 @@ final class Achievements {
return out; return out;
} }
/**
* The stat map an {@link Achievement} condition reads, keyed the same way
* as the config's category names.
*
* <p>Statistic constants get renamed between Minecraft releases, so each is
* resolved by name through {@link Stats#resolve} rather than referenced
* directly — a missing one yields zero instead of failing to load the class.
*/
static Map<String, Long> snapshot(Player player) {
Map<String, Long> stats = new HashMap<>();
stats.put("mineracao", total(player, "MINE_BLOCK"));
stats.put("tempo", untyped(player, "PLAY_TIME"));
stats.put("distancia", untyped(player, "WALK_ONE_CM"));
stats.put("mortes", untyped(player, "DEATHS"));
stats.put("combate", untyped(player, "MOB_KILLS"));
stats.put("pesca", untyped(player, "FISH_CAUGHT"));
stats.put("pulos", untyped(player, "JUMP"));
return stats;
}
private static long untyped(Player player, String name) {
Statistic statistic = Stats.resolve(name);
return statistic == null ? 0L : Stats.untyped(player, statistic);
}
private static long total(Player player, String name) {
Statistic statistic = Stats.resolve(name);
return statistic == null ? 0L : Stats.totalOf(player, statistic);
}
private void save() { private void save() {
try { try {
data.save(file); data.save(file);
@@ -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,
@@ -286,8 +294,8 @@ final class Ai {
Wiki.Article article = term == null ? null : wiki.lookup(term); Wiki.Article article = term == null ? null : wiki.lookup(term);
if (article != null) { if (article != null) {
messages.add(messages.size() - 1, new MiniMax.Turn("system", messages.add(messages.size() - 1, new MiniMax.Turn("system",
"Artigo da Minecraft Wiki pt-BR — '" + article.title() + "':\n" "Artigo da Minecraft Wiki pt-BR — '" + article.title() + "':\n"
+ article.text())); + article.text()));
} }
} }
@@ -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.
* *
@@ -5,6 +5,7 @@ import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration; import net.kyori.adventure.text.format.TextDecoration;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import net.kyori.adventure.translation.TranslationStore;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.GameRule; import org.bukkit.GameRule;
import org.bukkit.Location; import org.bukkit.Location;
@@ -84,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;
@@ -98,6 +101,8 @@ public final class Canalhandia extends JavaPlugin implements Listener {
private GuessRound guessRound; private GuessRound guessRound;
private Poll poll; private Poll poll;
private Titles titles; private Titles titles;
private DeathGift deathGift;
private TranslationStore<?> i18n;
@Override @Override
public void onEnable() { public void onEnable() {
@@ -105,6 +110,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
// Ship the editable catalogues; false = never overwrite the operator's copy. // Ship the editable catalogues; false = never overwrite the operator's copy.
saveResource("conquistas-catalogo.yml", false); saveResource("conquistas-catalogo.yml", false);
saveResource("marcos-catalogo.yml", false); saveResource("marcos-catalogo.yml", false);
i18n = I18n.install(i18n, getLogger());
settings = new Settings(this); settings = new Settings(this);
notes = new Notes(new java.io.File(getDataFolder(), "notas.yml")); notes = new Notes(new java.io.File(getDataFolder(), "notas.yml"));
mail = new Mail(new java.io.File(getDataFolder(), "recados.yml")); mail = new Mail(new java.io.File(getDataFolder(), "recados.yml"));
@@ -113,6 +119,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
milestones = new Milestones(this); milestones = new Milestones(this);
achievements = new Achievements(this); achievements = new Achievements(this);
titles = new Titles(this); titles = new Titles(this);
deathGift = new DeathGift(this);
// Load the achievement catalogue from config, then silently bank any // Load the achievement catalogue from config, then silently bank any
// history the current definitions already imply (both here and for // history the current definitions already imply (both here and for
// milestones), so an expanded catalogue never spams returning players. // milestones), so an expanded catalogue never spams returning players.
@@ -120,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
@@ -139,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();
@@ -176,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() {
@@ -190,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;
@@ -215,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;
@@ -247,6 +280,12 @@ public final class Canalhandia extends JavaPlugin implements Listener {
Achievement.load(Achievement.loadFrom(conquistasCatalogo(), getLogger())); Achievement.load(Achievement.loadFrom(conquistasCatalogo(), getLogger()));
milestones.reload(); milestones.reload();
achievements.syncCatalogue(); achievements.syncCatalogue();
deathGift.reload();
}
/** Reloads the i18n bundles from the jar and re-registers the translator. */
void reloadI18n() {
i18n = I18n.install(i18n, getLogger());
} }
/** The weekly ranking baseline. Never null. */ /** The weekly ranking baseline. Never null. */
@@ -656,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);
} }
@@ -772,11 +814,12 @@ public final class Canalhandia extends JavaPlugin implements Listener {
button = button.clickEvent(ClickEvent.runCommand( button = button.clickEvent(ClickEvent.runCommand(
"/canalhandia reagir " + mourning.id() + " f")); "/canalhandia reagir " + mourning.id() + " f"));
} }
Component prompt = Lang.tr(bedrock
? "canalhandia.morte.luto.digitar"
: "canalhandia.morte.luto.prestar",
Component.text(name));
return Component.text(" ").append(button) return Component.text(" ").append(button)
.append(Component.text(bedrock .append(prompt.color(NamedTextColor.GRAY));
? "digite /f para prestar luto por " + name
: "prestar luto por " + name,
NamedTextColor.GRAY));
}), 2L); }), 2L);
getServer().getScheduler().runTaskLater(this, () -> { getServer().getScheduler().runTaskLater(this, () -> {
@@ -786,9 +829,10 @@ public final class Canalhandia extends JavaPlugin implements Listener {
int shown = Math.min(who.size(), settings.summaryNames()); int shown = Math.min(who.size(), settings.summaryNames());
String text = String.join(", ", who.subList(0, shown)) String text = String.join(", ", who.subList(0, shown))
+ (who.size() > shown ? " +" + (who.size() - shown) : ""); + (who.size() > shown ? " +" + (who.size() - shown) : "");
Component summary = Lang.tr("canalhandia.morte.luto.resumo",
Component.text(text), Component.text(name));
Bukkit.broadcast(Component.text(" ") Bukkit.broadcast(Component.text(" ")
.append(Component.text(text + " prestaram luto por " + name + ".", .append(summary.color(NamedTextColor.GRAY)));
NamedTextColor.GRAY)));
} }
if (liveReactions == mourning) { if (liveReactions == mourning) {
liveReactions = null; liveReactions = null;
@@ -796,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
@@ -839,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));
@@ -860,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());
@@ -895,6 +996,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
.append(Component.text(tail, NamedTextColor.AQUA)); .append(Component.text(tail, NamedTextColor.AQUA));
} }
player.sendMessage(msg); player.sendMessage(msg);
// A comic consolation item, given once they can actually hold it.
// Gameplay-neutral by design (a poppy, a wilted bush) — just a laugh.
if (deathGift.active()) {
deathGift.give(player);
}
}, 1L); }, 1L);
} }
File diff suppressed because it is too large Load Diff
@@ -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();
}
}
@@ -0,0 +1,111 @@
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.Material;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Random;
import java.util.logging.Logger;
/**
* A small consolation prize handed to a player when they respawn a funeral
* poppy, a wilted bush, whatever. Comic, never useful: the point is a laugh at
* the death, not a leg up, so gifts are cosmetic-tier items given one at a time.
*
* <p>Config-driven like the achievement catalogue. If {@code mortes.presente} is
* absent the built-in list is used, so it works the moment the plugin loads;
* operators expand or mute it under that key and {@code /canalhandia reload}
* picks it up. Each line is {@code "MATERIAL | Nome | mensagem"}.
*/
final class DeathGift {
/** Baked-in default so a fresh server has something without editing config. */
private static final List<String> DEFAULTS = List.of(
"POPPY | Flor do Velório | Uma florzinha pro seu velório. Sentimos muito.",
"DEAD_BUSH | Buquê Murcho | Um buquê à altura do seu último desempenho.",
"WET_SPONGE | Esponja das Lágrimas | Toma, pra enxugar as lágrimas.",
"BONE | Osso da Sorte | Um ossinho pra você, campeão.",
"COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora.",
"ROTTEN_FLESH | Carne Podre | É o que tinha sobrado na despensa.");
/** One gift: an item, the name it wears, and the line shown when it is given. */
record Gift(Material material, String name, String message) {
}
private final Canalhandia plugin;
private final Random random = new Random();
private volatile boolean active;
private volatile List<Gift> gifts = List.of();
DeathGift(Canalhandia plugin) {
this.plugin = plugin;
reload();
}
/** Re-reads the gift list from config (or the defaults). Driven by reload. */
void reload() {
ConfigurationSection section = plugin.getConfig().getConfigurationSection("mortes.presente");
boolean on = section == null || section.getBoolean("ativo", true);
List<String> raw = section == null ? DEFAULTS : section.getStringList("itens");
if (raw.isEmpty()) {
raw = DEFAULTS;
}
gifts = parse(raw, plugin.getLogger());
active = on && !gifts.isEmpty();
}
/** True when a gift should be handed out on respawn. */
boolean active() {
return active;
}
/** Hands the player a random gift and a private comic line. Overflow is dropped
* at their feet rather than lost, so a full inventory never eats the joke. */
void give(Player player) {
Gift gift = pick(gifts, random);
if (gift == null) {
return;
}
ItemStack item = new ItemStack(gift.material());
item.editMeta(meta -> meta.displayName(Component.text(gift.name(), NamedTextColor.LIGHT_PURPLE)
.decoration(TextDecoration.ITALIC, false)));
Map<Integer, ItemStack> overflow = player.getInventory().addItem(item);
for (ItemStack leftover : overflow.values()) {
player.getWorld().dropItemNaturally(player.getLocation(), leftover);
}
player.sendMessage(Msg.tag("Consolação", NamedTextColor.LIGHT_PURPLE)
.append(Component.text(gift.message(), NamedTextColor.GRAY)));
}
/** Picks one gift at random, or null if the list is empty. Pure, for tests. */
static Gift pick(List<Gift> gifts, Random random) {
return gifts.isEmpty() ? null : gifts.get(random.nextInt(gifts.size()));
}
/** Parses {@code "MATERIAL | Nome | mensagem"} lines, skipping bad ones. */
static List<Gift> parse(List<String> raw, Logger log) {
List<Gift> out = new ArrayList<>();
for (String line : raw) {
String[] parts = line.split("\\|", 3);
if (parts.length != 3) {
log.warning("Presente de morte ignorado (formato 'ITEM | Nome | mensagem'): " + line);
continue;
}
Material material = Material.matchMaterial(parts[0].trim().toUpperCase(Locale.ROOT));
if (material == null || !material.isItem()) {
log.warning("Presente de morte ignorado (item inválido): " + parts[0].trim());
continue;
}
out.add(new Gift(material, parts[1].trim(), parts[2].trim()));
}
return List.copyOf(out);
}
}
@@ -0,0 +1,77 @@
package dev.marcospaulo.canalhandia;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.translation.GlobalTranslator;
import net.kyori.adventure.translation.TranslationStore;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.PropertyResourceBundle;
import java.util.ResourceBundle;
import java.util.logging.Logger;
/**
* i18n: registers an Adventure {@link TranslationStore} (key
* {@code canalhandia}) into the {@link GlobalTranslator}, populated from the
* bundled {@code lang/messages_pt.properties} (source of truth) and
* {@code lang/messages_en.properties}.
*
* <p>Per-viewer rendering is automatic: Paper runs every component sent to an
* audience through the {@code GlobalTranslator} in the viewer's own locale, so
* one broadcast shows each player their own language. No per-player lookup.
*
* <p>Language resolution: registry default is {@code en} (the base bundle).
* {@code pt} and {@code pt_BR} fall back to the PT bundle; the store does the
* locale fallback, unknown locales hit the default. That's the whole rule.
*/
final class I18n {
static final Key SOURCE = Key.key("canalhandia");
static final Locale DEFAULT = Locale.ENGLISH;
/** Escape single quotes so MessageFormat does not swallow apostrophes. */
private static final boolean ESCAPE_QUOTES = true;
private static final String PT = "lang/messages_pt.properties";
private static final String EN = "lang/messages_en.properties";
private I18n() {
}
/**
* Loads both bundles and registers the store with the global translator.
* Removes any previously registered store first, so {@code /canalhandia
* reload} does not stack sources.
*/
static TranslationStore<?> install(TranslationStore<?> previous, Logger logger) {
if (previous != null) {
GlobalTranslator.translator().removeSource(previous);
}
TranslationStore.StringBased<java.text.MessageFormat> store = TranslationStore.messageFormat(SOURCE);
store.defaultLocale(DEFAULT);
load(EN, store, Locale.ENGLISH, logger);
load(PT, store, Locale.of("pt"), logger);
GlobalTranslator.translator().addSource(store);
return store;
}
private static void load(String resource,
TranslationStore.StringBased<java.text.MessageFormat> store,
Locale locale, Logger logger) {
try (InputStream in = I18n.class.getClassLoader().getResourceAsStream(resource)) {
if (in == null) {
logger.warning("i18n: recurso ausente: " + resource);
return;
}
// PropertyResourceBundle(Reader) honours the reader's encoding; the
// InputStream constructor is fixed to ISO-8859-1 and would mojibake
// the PT accents.
ResourceBundle bundle = new PropertyResourceBundle(
new InputStreamReader(in, StandardCharsets.UTF_8));
store.registerAll(locale, bundle, ESCAPE_QUOTES);
} catch (IOException e) {
logger.warning("i18n: falha ao carregar " + resource + ": " + e.getMessage());
}
}
}
@@ -0,0 +1,21 @@
package dev.marcospaulo.canalhandia;
import net.kyori.adventure.text.Component;
/**
* Terse facade over {@link Component#translatable} so call sites read as i18n
* rather than as an Adventure call: {@code Lang.tr("canalhandia.morte.luto.prestar", name)}.
*
* <p>The key is rendered per-viewer by the {@link GlobalTranslator} source that
* {@link I18n} registers; args are inserted by {@code MessageFormat} ({@code {0}},
* {@code {1}}, ).
*/
final class Lang {
private Lang() {
}
static Component tr(String key, Component... args) {
return Component.translatable(key, args);
}
}
@@ -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;
@@ -31,20 +31,43 @@ final class Msg {
.append(Component.text(text, NamedTextColor.GREEN).decoration(TextDecoration.BOLD, false))); .append(Component.text(text, NamedTextColor.GREEN).decoration(TextDecoration.BOLD, false)));
} }
static void ok(CommandSender sender, Component text) {
sender.sendMessage(tag("Canalhandia", NamedTextColor.GOLD)
.append(text.color(NamedTextColor.GREEN).decoration(TextDecoration.BOLD, false)));
}
static void error(CommandSender sender, String text) { static void error(CommandSender sender, String text) {
sender.sendMessage(tag("Canalhandia", NamedTextColor.GOLD) sender.sendMessage(tag("Canalhandia", NamedTextColor.GOLD)
.append(Component.text(text, NamedTextColor.RED).decoration(TextDecoration.BOLD, false))); .append(Component.text(text, NamedTextColor.RED).decoration(TextDecoration.BOLD, false)));
} }
static void error(CommandSender sender, Component text) {
sender.sendMessage(tag("Canalhandia", NamedTextColor.GOLD)
.append(text.color(NamedTextColor.RED).decoration(TextDecoration.BOLD, false)));
}
static void header(CommandSender sender, String text) { static void header(CommandSender sender, String text) {
sender.sendMessage(Component.text("" + text + "", NamedTextColor.GOLD, TextDecoration.BOLD)); sender.sendMessage(Component.text("" + text + "", NamedTextColor.GOLD, TextDecoration.BOLD));
} }
static void header(CommandSender sender, Component text) {
sender.sendMessage(Component.text("", NamedTextColor.GOLD, TextDecoration.BOLD)
.append(text)
.append(Component.text("", NamedTextColor.GOLD, TextDecoration.BOLD)));
}
static void line(CommandSender sender, String key, String value) { static void line(CommandSender sender, String key, String value) {
sender.sendMessage(Component.text(" " + key + ": ", NamedTextColor.GRAY) sender.sendMessage(Component.text(" " + key + ": ", NamedTextColor.GRAY)
.append(Component.text(value, NamedTextColor.AQUA))); .append(Component.text(value, NamedTextColor.AQUA)));
} }
static void line(CommandSender sender, Component key, Component value) {
sender.sendMessage(Component.text(" ", NamedTextColor.GRAY)
.append(key.color(NamedTextColor.GRAY))
.append(Component.text(": ", NamedTextColor.GRAY))
.append(value.color(NamedTextColor.AQUA)));
}
/** Renders a duration in ticks as "3 dias e 4 horas" / "1 hora" / "12 minutos". */ /** Renders a duration in ticks as "3 dias e 4 horas" / "1 hora" / "12 minutos". */
static String duration(long ticks) { static String duration(long ticks) {
long minutes = ticks / 20L / 60L; long minutes = ticks / 20L / 60L;
@@ -125,10 +125,10 @@ final class OfflineStats {
/** /**
* The full stat map an {@link Achievement} reads, for a player who may be * The full stat map an {@link Achievement} reads, for a player who may be
* offline. Keyed by {@link RankingMetric#commandKey()} the same names the * offline. Keyed by {@link RankingMetric#commandKey()} plus any {@link StatRef}
* online {@link Achievements#snapshot} produces so the identical pure * the catalogue references (matou:creeper, minerou:obsidian). This is the one
* conditions in {@link Achievement} evaluate the same whether the player is * source {@link Achievements} reads for on- and offline players alike, so the
* on- or offline. * pure conditions in {@link Achievement} evaluate identically either way.
* *
* @return null when there is no stats file for this player (never played, or * @return null when there is no stats file for this player (never played, or
* the directory is missing), which the caller shows as "sem dados". * the directory is missing), which the caller shows as "sem dados".
@@ -142,42 +142,59 @@ final class OfflineStats {
if (!file.isFile()) { if (!file.isFile()) {
return null; return null;
} }
JsonObject statsObject = statsObject(file);
Map<String, Long> stats = new HashMap<>(); Map<String, Long> stats = new HashMap<>();
for (RankingMetric metric : RankingMetric.values()) { for (RankingMetric metric : RankingMetric.values()) {
stats.put(metric.commandKey(), read(file, metric)); stats.put(metric.commandKey(), valueIn(statsObject, metric.section(), metric.statKey()));
}
// The catalogue may reach into per-mob/per-block counters (matou:creeper,
// minerou:obsidian); fetch exactly the ones some title references.
for (String ref : Achievement.referencedStats()) {
StatRef resolved = StatRef.of(ref);
stats.put(ref, valueIn(statsObject, resolved.section(), resolved.statKey()));
} }
return stats; return stats;
} }
private long read(File file, RankingMetric metric) { private long read(File file, RankingMetric metric) {
return valueIn(statsObject(file), metric.section(), metric.statKey());
}
/** The {@code stats} object of a player file, parsed once, or null on any problem. */
private JsonObject statsObject(File file) {
try (Reader reader = new FileReader(file)) { try (Reader reader = new FileReader(file)) {
JsonElement root = JsonParser.parseReader(reader); JsonElement root = JsonParser.parseReader(reader);
if (!root.isJsonObject()) { if (!root.isJsonObject()) {
return 0; return null;
} }
JsonElement stats = root.getAsJsonObject().get("stats"); JsonElement stats = root.getAsJsonObject().get("stats");
if (stats == null || !stats.isJsonObject()) { return stats != null && stats.isJsonObject() ? stats.getAsJsonObject() : null;
return 0;
}
JsonElement section = stats.getAsJsonObject().get(metric.section());
if (section == null || !section.isJsonObject()) {
return 0;
}
JsonObject object = section.getAsJsonObject();
if (metric.statKey() == null) {
// Sum the whole section, e.g. every block ever mined.
long total = 0;
for (String key : object.keySet()) {
total += object.get(key).getAsLong();
}
return total;
}
JsonElement value = object.get(metric.statKey());
return value == null ? 0 : value.getAsLong();
} catch (Exception e) { } catch (Exception e) {
plugin.getLogger().warning("Não consegui ler " + file.getName() + ": " + e.getMessage()); plugin.getLogger().warning("Não consegui ler " + file.getName() + ": " + e.getMessage());
return null;
}
}
/** One value out of a parsed stats object. A null {@code statKey} sums the
* whole section, e.g. every block ever mined. */
private static long valueIn(JsonObject statsObject, String section, String statKey) {
if (statsObject == null) {
return 0; return 0;
} }
JsonElement sectionElement = statsObject.get(section);
if (sectionElement == null || !sectionElement.isJsonObject()) {
return 0;
}
JsonObject object = sectionElement.getAsJsonObject();
if (statKey == null) {
long total = 0;
for (String key : object.keySet()) {
total += object.get(key).getAsLong();
}
return total;
}
JsonElement value = object.get(statKey);
return value == null ? 0 : value.getAsLong();
} }
/** UUID to last known name, from usercache.json. */ /** UUID to last known name, from usercache.json. */
@@ -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) {
@@ -0,0 +1,75 @@
package dev.marcospaulo.canalhandia;
import java.util.Locale;
import java.util.Map;
/**
* A reference to one raw vanilla statistic, written in config as
* {@code prefixo:chave} e.g. {@code matou:creeper} or {@code minerou:obsidian}.
*
* <p>This is what lets the achievement catalogue reach past the seven headline
* metrics into any per-mob or per-block counter Minecraft keeps, without a code
* change: a title for "matou 100 creepers" is one line of YAML. The prefix picks
* the stats-JSON section; the key becomes {@code minecraft:<key>} inside it.
*
* <p>Counts are per exact block/entity id vanilla splits e.g. deepslate ores
* from their stone form so a ref reads one id, not a family. Pure strings, no
* Bukkit: {@link Achievement} validates the shape, {@link OfflineStats} reads it.
*/
final class StatRef {
/** Friendly prefix → stats-JSON section. */
private static final Map<String, String> SECTIONS = Map.of(
"matou", "minecraft:killed",
"morto-por", "minecraft:killed_by",
"minerou", "minecraft:mined",
"usou", "minecraft:used",
"craftou", "minecraft:crafted",
"pegou", "minecraft:picked_up",
"largou", "minecraft:dropped",
"custom", "minecraft:custom");
private final String section;
private final String statKey;
private StatRef(String section, String statKey) {
this.section = section;
this.statKey = statKey;
}
String section() {
return section;
}
String statKey() {
return statKey;
}
/** True when a metric token is a vanilla reference (has a {@code prefix:key} shape). */
static boolean isRef(String token) {
return token != null && token.indexOf(':') > 0;
}
/** True when the token is a reference with a known prefix and a clean key. */
static boolean isValid(String token) {
if (!isRef(token)) {
return false;
}
int colon = token.indexOf(':');
String prefix = token.substring(0, colon).toLowerCase(Locale.ROOT);
String key = token.substring(colon + 1).toLowerCase(Locale.ROOT);
return SECTIONS.containsKey(prefix) && key.matches("[a-z0-9_]+");
}
/** Resolves a validated token to its JSON section and key. */
static StatRef of(String token) {
int colon = token.indexOf(':');
String prefix = token.substring(0, colon).toLowerCase(Locale.ROOT);
String key = token.substring(colon + 1).toLowerCase(Locale.ROOT);
String section = SECTIONS.get(prefix);
if (section == null) {
throw new IllegalArgumentException("prefixo de estatística desconhecido: " + prefix);
}
return new StatRef(section, "minecraft:" + key);
}
}
@@ -44,10 +44,17 @@ final class TitleChatListener implements Listener {
tag.append(previous.render(source, sourceDisplayName, message, viewer))); tag.append(previous.render(source, sourceDisplayName, message, viewer)));
} }
/** The bracketed title chip that sits before the name. Pure, so it is testable. */ /** The bracketed title chip that sits before the name, drawn in the title's
* own tier colour so a legendary reads gold and a rare aqua. Pure, so testable.
*
* <p>Rooted on an empty, colourless component on purpose: the chat message is
* appended to this in {@link #onChat}, and a coloured root would bleed its
* colour into any unstyled message text which turned title-holders' chat
* grey. Empty root the message falls back to the client default (white). */
static Component tag(Achievement achievement) { static Component tag(Achievement achievement) {
return Component.text("[", NamedTextColor.DARK_GRAY) return Component.empty()
.append(Component.text(achievement.title(), NamedTextColor.AQUA)) .append(Component.text("[", NamedTextColor.DARK_GRAY))
.append(Component.text(achievement.title(), achievement.color()))
.append(Component.text("] ", NamedTextColor.DARK_GRAY)) .append(Component.text("] ", NamedTextColor.DARK_GRAY))
.decoration(TextDecoration.BOLD, false); .decoration(TextDecoration.BOLD, false);
} }
@@ -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);
}
}
+55
View File
@@ -329,3 +329,58 @@ ia:
- "O servidor se chama Canalhandia e roda Minecraft 26.2 (Paper)." - "O servidor se chama Canalhandia e roda Minecraft 26.2 (Paper)."
- "Jogadores de Bedrock entram pelo Geyser e o nome deles começa com ponto." - "Jogadores de Bedrock entram pelo Geyser e o nome deles começa com ponto."
- "O servidor tem BlueMap, voice chat e Distant Horizons." - "O servidor tem BlueMap, voice chat e Distant Horizons."
# Presente de consolação: quando um jogador renasce, ganha um item cômico e
# inofensivo (uma flor de velório, um arbusto murcho). Parte do módulo "mortes".
# Sem esta seção, uma lista padrão embutida é usada. Rode /canalhandia reload
# depois de editar. Cada item é "MATERIAL | Nome | mensagem".
mortes:
presente:
ativo: true
itens:
- "POPPY | Flor do Velório | Uma florzinha pro seu velório. Sentimos muito."
- "DEAD_BUSH | Buquê Murcho | Um buquê à altura do seu último desempenho."
- "WET_SPONGE | Esponja das Lágrimas | Toma, pra enxugar as lágrimas."
- "BONE | Osso da Sorte | Um ossinho pra você, campeão."
- "COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora."
- "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
+104 -14
View File
@@ -4,25 +4,36 @@
# /canalhandia reload # /canalhandia reload
# e o servidor recarrega tudo sem reiniciar (igual à whitelist). # e o servidor recarrega tudo sem reiniciar (igual à whitelist).
# #
# Cada conquista tem: titulo, descricao e uma lista de condicoes. TODAS as # Cada conquista tem: titulo, descricao, uma lista de condicoes e um tier.
# condicoes precisam valer para o jogador desbloquear o título. # TODAS as condicoes precisam valer para o jogador desbloquear o título.
# #
# Métricas disponíveis (nas unidades abaixo): # Métricas simples (nas unidades abaixo):
# mineracao blocos minerados # mineracao blocos minerados combate monstros derrotados
# combate monstros derrotados # mortes mortes pesca peixes pescados
# mortes mortes # pulos pulos distancia quilômetros caminhados
# pesca peixes pescados
# pulos pulos
# distancia quilômetros caminhados
# tempo horas jogadas # tempo horas jogadas
# #
# Métricas detalhadas: "prefixo:coisa" alcança qualquer contador do Minecraft,
# por mob ou por bloco, sem mexer no código. Ex.: matou:creeper, minerou:obsidian.
# prefixos: matou (mobs mortos), morto-por, minerou (blocos), usou, craftou,
# pegou, largou, custom
# a "coisa" é o id do mob/bloco em minúsculas: creeper, spider, ancient_debris…
# OBS: o contador é por id exato — matou:spider não inclui cave_spider, e
# minerou:diamond_ore não inclui deepslate_diamond_ore.
#
# Cada condicao é "metrica operador alvo". # Cada condicao é "metrica operador alvo".
# operadores: >= > <= < == != # operadores: >= > <= < == !=
# alvo pode ser: um número, outra métrica, ou metrica/numero (divisão). # alvo pode ser: um número, outra métrica, ou metrica/numero (divisão).
# Exemplos: # Exemplos:
# "mineracao >= 10000" minerou pelo menos 10 mil blocos # "mineracao >= 10000" minerou pelo menos 10 mil blocos
# "matou:creeper >= 100" derrotou 100 creepers
# "mortes > combate" morreu mais do que matou # "mortes > combate" morreu mais do que matou
# "mortes > mineracao/100" morreu mais de uma vez a cada 100 blocos #
# tier define a cor do título no chat (do mais comum ao mais raro):
# comum → branco incomum → verde raro → azul-claro
# epico → roxo lendario → dourado
# cor (opcional) força uma cor específica, sobrepondo o tier:
# um nome do Minecraft (gold, red, aqua…) ou hex "#RRGGBB".
conquistas: conquistas:
@@ -31,127 +42,206 @@ conquistas:
titulo: "Pedreiro" titulo: "Pedreiro"
descricao: "minerou 10.000 blocos" descricao: "minerou 10.000 blocos"
condicoes: ["mineracao >= 10000"] condicoes: ["mineracao >= 10000"]
tier: comum
escavadeira: escavadeira:
titulo: "Escavadeira Humana" titulo: "Escavadeira Humana"
descricao: "minerou 100.000 blocos" descricao: "minerou 100.000 blocos"
condicoes: ["mineracao >= 100000"] condicoes: ["mineracao >= 100000"]
tier: raro
terraplanagem: terraplanagem:
titulo: "Terraplanagem" titulo: "Terraplanagem"
descricao: "minerou 500.000 blocos" descricao: "minerou 500.000 blocos"
condicoes: ["mineracao >= 500000"] condicoes: ["mineracao >= 500000"]
tier: epico
# --- combate --- # --- combate ---
cacador: cacador:
titulo: "Caçador" titulo: "Caçador"
descricao: "derrotou 100 monstros" descricao: "derrotou 100 monstros"
condicoes: ["combate >= 100"] condicoes: ["combate >= 100"]
tier: comum
exterminador: exterminador:
titulo: "Exterminador" titulo: "Exterminador"
descricao: "derrotou 1.000 monstros" descricao: "derrotou 1.000 monstros"
condicoes: ["combate >= 1000"] condicoes: ["combate >= 1000"]
tier: raro
ceifador: ceifador:
titulo: "Ceifador" titulo: "Ceifador"
descricao: "derrotou 10.000 monstros" descricao: "derrotou 10.000 monstros"
condicoes: ["combate >= 10000"] condicoes: ["combate >= 10000"]
tier: epico
# --- combate por mob (métricas detalhadas) ---
aracnofobia:
titulo: "Aracnofobia"
descricao: "derrotou 100 aranhas"
condicoes: ["matou:spider >= 100"]
tier: raro
desarmador:
titulo: "Desarmador"
descricao: "derrotou 100 creepers e viveu para contar"
condicoes: ["matou:creeper >= 100"]
tier: raro
necromante:
titulo: "Necromante"
descricao: "derrotou 300 zumbis"
condicoes: ["matou:zombie >= 300"]
tier: incomum
pontaria:
titulo: "Pontaria de Ferro"
descricao: "derrotou 200 esqueletos"
condicoes: ["matou:skeleton >= 200"]
tier: raro
encara-o-vazio:
titulo: "Encara o Vazio"
descricao: "derrotou 60 endermen"
condicoes: ["matou:enderman >= 60"]
tier: raro
apaga-fogo:
titulo: "Apaga-Fogo"
descricao: "derrotou 50 blazes"
condicoes: ["matou:blaze >= 50"]
tier: raro
insone:
titulo: "Insone"
descricao: "derrotou 50 phantoms"
condicoes: ["matou:phantom >= 50"]
tier: incomum
# --- viagem --- # --- viagem ---
maratonista: maratonista:
titulo: "Maratonista" titulo: "Maratonista"
descricao: "caminhou 42 km (uma maratona)" descricao: "caminhou 42 km (uma maratona)"
condicoes: ["distancia >= 42"] condicoes: ["distancia >= 42"]
tier: comum
andarilho: andarilho:
titulo: "Andarilho" titulo: "Andarilho"
descricao: "caminhou 100 km" descricao: "caminhou 100 km"
condicoes: ["distancia >= 100"] condicoes: ["distancia >= 100"]
tier: incomum
explorador: explorador:
titulo: "Explorador" titulo: "Explorador"
descricao: "caminhou 500 km" descricao: "caminhou 500 km"
condicoes: ["distancia >= 500"] condicoes: ["distancia >= 500"]
tier: raro
volta-ao-mundo: volta-ao-mundo:
titulo: "Volta ao Mundo" titulo: "Volta ao Mundo"
descricao: "caminhou 1.000 km" descricao: "caminhou 1.000 km"
condicoes: ["distancia >= 1000"] condicoes: ["distancia >= 1000"]
tier: epico
# --- tempo --- # --- tempo ---
residente: residente:
titulo: "Residente" titulo: "Residente"
descricao: "passou de 50 horas jogadas" descricao: "passou de 50 horas jogadas"
condicoes: ["tempo >= 50"] condicoes: ["tempo >= 50"]
tier: comum
veterano: veterano:
titulo: "Veterano" titulo: "Veterano"
descricao: "passou de 200 horas jogadas" descricao: "passou de 200 horas jogadas"
condicoes: ["tempo >= 200"] condicoes: ["tempo >= 200"]
tier: raro
morador-fixo: morador-fixo:
titulo: "Morador Fixo" titulo: "Morador Fixo"
descricao: "passou de 500 horas jogadas" descricao: "passou de 500 horas jogadas"
condicoes: ["tempo >= 500"] condicoes: ["tempo >= 500"]
tier: epico
lenda-viva: lenda-viva:
titulo: "Lenda Viva" titulo: "Lenda Viva"
descricao: "passou de 1.000 horas jogadas" descricao: "passou de 1.000 horas jogadas"
condicoes: ["tempo >= 1000"] condicoes: ["tempo >= 1000"]
tier: lendario
# --- pesca --- # --- pesca ---
pescador-amador: pescador-amador:
titulo: "Pescador Amador" titulo: "Pescador Amador"
descricao: "pescou 100 peixes" descricao: "pescou 100 peixes"
condicoes: ["pesca >= 100"] condicoes: ["pesca >= 100"]
tier: comum
pescador: pescador:
titulo: "Pescador Profissional" titulo: "Pescador Profissional"
descricao: "pescou 500 peixes" descricao: "pescou 500 peixes"
condicoes: ["pesca >= 500"] condicoes: ["pesca >= 500"]
tier: incomum
mestre-da-vara: mestre-da-vara:
titulo: "Mestre da Vara" titulo: "Mestre da Vara"
descricao: "pescou 2.000 peixes" descricao: "pescou 2.000 peixes"
condicoes: ["pesca >= 2000"] condicoes: ["pesca >= 2000"]
tier: raro
# --- pulos --- # --- pulos ---
pula-pula: pula-pula:
titulo: "Pula-Pula" titulo: "Pula-Pula"
descricao: "deu 10.000 pulos" descricao: "deu 10.000 pulos"
condicoes: ["pulos >= 10000"] condicoes: ["pulos >= 10000"]
tier: comum
saltitante: saltitante:
titulo: "Saltitante" titulo: "Saltitante"
descricao: "deu 50.000 pulos" descricao: "deu 50.000 pulos"
condicoes: ["pulos >= 50000"] condicoes: ["pulos >= 50000"]
tier: incomum
canguru: canguru:
titulo: "Canguru" titulo: "Canguru"
descricao: "deu 100.000 pulos" descricao: "deu 100.000 pulos"
condicoes: ["pulos >= 100000"] condicoes: ["pulos >= 100000"]
tier: raro
# --- blocos raros (métricas detalhadas) ---
escavador-de-obsidiana:
titulo: "Escavador de Obsidiana"
descricao: "minerou 64 obsidianas"
condicoes: ["minerou:obsidian >= 64"]
tier: epico
netherita-bruta:
titulo: "Netherita Bruta"
descricao: "minerou 16 restos antigos"
condicoes: ["minerou:ancient_debris >= 16"]
tier: lendario
# --- mortes e as engraçadas --- # --- mortes e as engraçadas ---
gato-sete-vidas: gato-sete-vidas:
titulo: "Gato de Sete Vidas" titulo: "Gato de Sete Vidas"
descricao: "morreu 50 vezes e continua tentando" descricao: "morreu 50 vezes e continua tentando"
condicoes: ["mortes >= 50"] condicoes: ["mortes >= 50"]
tier: incomum
vida-dura: vida-dura:
titulo: "Vida Dura" titulo: "Vida Dura"
descricao: "morreu 100 vezes" descricao: "morreu 100 vezes"
condicoes: ["mortes >= 100"] condicoes: ["mortes >= 100"]
tier: raro
casca-grossa: casca-grossa:
titulo: "Casca Grossa" titulo: "Casca Grossa"
descricao: "passou de 50 horas com menos de 10 mortes" descricao: "passou de 50 horas com menos de 10 mortes"
condicoes: ["tempo >= 50", "mortes < 10"] condicoes: ["tempo >= 50", "mortes < 10"]
tier: raro
intocavel: intocavel:
titulo: "Intocável" titulo: "Intocável"
descricao: "passou de 100 horas sem morrer nenhuma vez" descricao: "passou de 100 horas sem morrer nenhuma vez"
condicoes: ["tempo >= 100", "mortes == 0"] condicoes: ["tempo >= 100", "mortes == 0"]
tier: lendario
cor: "#ff5555"
turista: turista:
titulo: "Turista" titulo: "Turista"
descricao: "passou de 100 horas jogadas sem minerar 5.000 blocos" descricao: "passou de 100 horas jogadas sem minerar 5.000 blocos"
condicoes: ["tempo >= 100", "mineracao < 5000"] condicoes: ["tempo >= 100", "mineracao < 5000"]
tier: incomum
imortal-as-avessas: imortal-as-avessas:
titulo: "Imortal às Avessas" titulo: "Imortal às Avessas"
descricao: "morreu mais de uma vez a cada 100 blocos minerados" descricao: "morreu mais de uma vez a cada 100 blocos minerados"
condicoes: ["mineracao >= 2000", "mortes > mineracao/100"] condicoes: ["mineracao >= 2000", "mortes > mineracao/100"]
tier: epico
kamikaze: kamikaze:
titulo: "Kamikaze" titulo: "Kamikaze"
descricao: "morreu mais vezes do que derrotou monstros" descricao: "derrotou 20 monstros mas morreu mais vezes ainda"
condicoes: ["combate >= 100", "mortes > combate"] condicoes: ["combate >= 20", "mortes > combate"]
tier: epico
rato-de-caverna: rato-de-caverna:
titulo: "Rato de Caverna" titulo: "Rato de Caverna"
descricao: "minerou 50.000 blocos sem caminhar 10 km" descricao: "minerou 20.000 blocos sem caminhar 50 km"
condicoes: ["mineracao >= 50000", "distancia < 10"] condicoes: ["mineracao >= 20000", "distancia < 50"]
tier: raro
nomade: nomade:
titulo: "Nômade" titulo: "Nômade"
descricao: "caminhou 100 km sem minerar 1.000 blocos" descricao: "caminhou 100 km sem minerar 1.000 blocos"
condicoes: ["distancia >= 100", "mineracao < 1000"] condicoes: ["distancia >= 100", "mineracao < 1000"]
tier: raro
@@ -0,0 +1,118 @@
# Canalhandia — English (translated from messages_pt.properties).
# Never alter {0}/{1} placeholders or MiniMessage <...> tags.
# Mourning (Canalhandia.onDeath) — F button under each death message.
canalhandia.morte.luto.prestar=pay respects for {0}
canalhandia.morte.luto.digitar=type /f to pay respects for {0}
canalhandia.morte.luto.resumo={0} paid respects for {1}.
# Commands — messages any player sees (not just the operator).
canalhandia.cmd.negado=You don't have permission for that.
canalhandia.cmd.sojogador.reagir=Only players can react.
canalhandia.cmd.sojogador.usar=Only players can use this.
canalhandia.cmd.sojogador.ia=Only players can use /ia.
canalhandia.cmd.sojogador.nota=Only players can take notes (a note stores where you are).
canalhandia.cmd.sojogador.recado=Only players can leave a message.
canalhandia.cmd.sojogador.recados=Only players have messages.
canalhandia.cmd.sojogador.mortes=Only players have a death history.
canalhandia.cmd.sojogador.conquistas=Only players have achievements. Use /conquistas <player>.
canalhandia.cmd.sojogador.titulo=Only players use titles.
canalhandia.cmd.modulo.desligado=The {0} module is off.
canalhandia.cmd.modulo.desativado=The {0} module is disabled.
canalhandia.cmd.jogador.informe=Specify a player.
canalhandia.cmd.jogador.offline=Player ''{0}'' is not online.
canalhandia.cmd.jogador.desconhecido=I don't know anyone called "{0}".
# /ranking
canalhandia.cmd.ranking.desconhecido=Unknown ranking. Use /ranking to see the list.
canalhandia.cmd.ranking.sem-dados=(no data yet)
# /conquistas
canalhandia.cmd.conquistas.sem-stats=I don't have stats for {0} yet.
canalhandia.cmd.conquistas.cabecalho=Achievements of {0} ({1}/{2})
# /perfil
canalhandia.cmd.perfil.diga=Say who: /perfil <player>.
canalhandia.cmd.perfil.cabecalho=Profile of {0}
canalhandia.cmd.perfil.rotulo.estatisticas=Stats
canalhandia.cmd.perfil.sem-dados=no data yet
canalhandia.cmd.perfil.rotulo.conquistas=Achievements
canalhandia.cmd.perfil.rotulo.titulo=Title
canalhandia.cmd.perfil.titulo.nenhum=none
# /titulo
canalhandia.cmd.titulo.atual=Current title
canalhandia.cmd.titulo.disponiveis=Available
canalhandia.cmd.titulo.uso=Use /titulo <name> to wear one, or /titulo limpar to clear it.
canalhandia.cmd.titulo.nenhum-bloqueado=You haven't unlocked a title yet. See /conquistas.
canalhandia.cmd.titulo.nao-tem=You don't have the title "{0}". See /titulo for the list.
canalhandia.cmd.titulo.removido=Title removed.
canalhandia.cmd.titulo.definido=Title set: {0}.
# /mortes
canalhandia.cmd.mortes.cabecalho=Your last deaths ({0})
canalhandia.cmd.mortes.nenhuma=You haven't died yet. Enjoy it while it lasts.
canalhandia.cmd.mortes.copiar=Click to copy the coordinates
# /recado and /recados
canalhandia.cmd.recado.uso=Usage: /recado <player> <text>
canalhandia.cmd.recado.vazio=The message is empty.
canalhandia.cmd.recado.mesmo=A message to yourself? Use /save.
canalhandia.cmd.recado.caixa-cheia={0}'s mailbox is full ({1} messages). Wait for them to join.
canalhandia.cmd.recado.guardado=Message saved for {0}. It'll arrive when {0} joins.
canalhandia.cmd.recado.online={0} is online — message delivered now.
canalhandia.cmd.recados.tudo-entregue=All your messages have been delivered.
canalhandia.cmd.recados.pendentes-singular={0} of your messages hasn't been read yet.
canalhandia.cmd.recados.pendentes-plural={0} of your messages haven't been read yet.
canalhandia.cmd.recado.desconhecido=I don't know anyone called "{0}". (You can only leave a message for someone who has joined the server.)
canalhandia.cmd.reagir.uso=Usage: /reagir <{0}>
canalhandia.cmd.reagir.nada=Nothing to react to right now.
canalhandia.cmd.reagir.invalida=That reaction doesn't apply to the last message.
canalhandia.cmd.reagir.expirou=That message has expired.
canalhandia.cmd.reagir.desconhecida=Unknown reaction.
canalhandia.cmd.reacoes.nenhuma=Nobody has reacted to the last message yet.
canalhandia.cmd.reacoes.cabecalho=Who reacted ({0})
# guess / poll (action bars)
canalhandia.cmd.palpite.uso=Usage: /palpite <name>
canalhandia.cmd.palpite.nenhuma=No guess round open.
canalhandia.cmd.adivinha.rodada-acabou=That round is already over.
canalhandia.cmd.votar.uso=Usage: /votar <number>
canalhandia.cmd.votar.encerrada=That poll has already closed.
canalhandia.cmd.votar.opcao-inexistente=That option doesn't exist.
canalhandia.cmd.enquete.nenhuma=No poll open.
# /ia (messages the player sees; tone tuning is operator-only)
canalhandia.cmd.ia.uso=Usage: /{0} <question>
# /nota and /save
canalhandia.cmd.nota.publica-negado=You can't create public notes. Use /nota add <text> for a private one.
canalhandia.cmd.nota.uso-escopo=Usage: /nota {0} <text>
canalhandia.cmd.nota.vazia=The note is empty.
canalhandia.cmd.nota.cheia=You already have {0} notes. Delete one with /nota remover <n>.
canalhandia.cmd.nota.salva=Note #{0} saved ({1}) at {2}.
canalhandia.cmd.nota.listar-uso=Usage: /nota listar [publicas|privadas]
canalhandia.cmd.nota.buscar-uso=Usage: /nota buscar <text>
canalhandia.cmd.nota.cabecalho=Note #{0}
canalhandia.cmd.nota.autor=author
canalhandia.cmd.nota.escopo=scope
canalhandia.cmd.nota.lugar=place
canalhandia.cmd.nota.remover-uso=Usage: /nota remover <n>
canalhandia.cmd.nota.de-outro=That note belongs to {0}.
canalhandia.cmd.nota.apagada=Note #{0} deleted.
canalhandia.cmd.nota.nenhuma=No notes.
canalhandia.cmd.nota.e-mais=… and {0} more. Use /nota buscar <text> to filter.
canalhandia.cmd.nota.lista.tudo=Your notes and the public ones
canalhandia.cmd.nota.lista.escopo={0} notes
canalhandia.cmd.nota.lista.busca=Notes containing "{0}"
canalhandia.cmd.nota.nao-encontrada=Note not found.
# /curiosidade (seen by players)
canalhandia.cmd.curiosidade.nenhum-elegivel=Nobody eligible is online (or without enough stats).
canalhandia.cmd.curiosidade.sem-stats={0} doesn't have enough stats yet.
canalhandia.cmd.curiosidade.sem-curiosidade=No curiosity available for {0}.
canalhandia.cmd.curiosidade.toggle-off=You won't appear in curiosities anymore.
canalhandia.cmd.curiosidade.toggle-on=You're back in the curiosities.
canalhandia.cmd.curiosidade.subdesconhecido=Unknown subcommand or player. Use /curiosidade ajuda
@@ -0,0 +1,120 @@
# Canalhandia — português (fonte de verdade). Padrões MessageFormat: {0}, {1}, ...
# NUNCA altere os placeholders {0}/{1} nem as tags MiniMessage <...>.
# Luto (Canalhandia.onDeath) — botão F sob cada mensagem de morte.
canalhandia.morte.luto.prestar=prestar luto por {0}
canalhandia.morte.luto.digitar=digite /f para prestar luto por {0}
canalhandia.morte.luto.resumo={0} prestaram luto por {1}.
# Comandos — mensagens que qualquer jogador vê (não só o operador).
canalhandia.cmd.negado=Você não tem permissão para isso.
canalhandia.cmd.sojogador.reagir=Só jogadores podem reagir.
canalhandia.cmd.sojogador.usar=Só jogadores podem usar isso.
canalhandia.cmd.sojogador.ia=Só jogadores podem usar /ia.
canalhandia.cmd.sojogador.nota=Só jogadores podem anotar (a anotação guarda onde você está).
canalhandia.cmd.sojogador.recado=Só jogadores podem mandar recado.
canalhandia.cmd.sojogador.recados=Só jogadores têm recados.
canalhandia.cmd.sojogador.mortes=Só jogadores têm histórico de mortes.
canalhandia.cmd.sojogador.conquistas=Só jogadores têm conquistas. Use /conquistas <jogador>.
canalhandia.cmd.sojogador.titulo=Só jogadores usam títulos.
canalhandia.cmd.modulo.desligado=O módulo de {0} está desligado.
canalhandia.cmd.modulo.desativado=O módulo {0} está desativado.
canalhandia.cmd.jogador.informe=Informe um jogador.
canalhandia.cmd.jogador.offline=Jogador ''{0}'' não está online.
canalhandia.cmd.jogador.desconhecido=Não conheço ninguém chamado "{0}".
# /ranking
canalhandia.cmd.ranking.desconhecido=Ranking desconhecido. Use /ranking para ver a lista.
canalhandia.cmd.ranking.sem-dados=(sem dados ainda)
# /conquistas
canalhandia.cmd.conquistas.sem-stats=Ainda não tenho estatísticas de {0}.
canalhandia.cmd.conquistas.cabecalho=Conquistas de {0} ({1}/{2})
# /perfil
canalhandia.cmd.perfil.diga=Diga de quem: /perfil <jogador>.
canalhandia.cmd.perfil.cabecalho=Perfil de {0}
canalhandia.cmd.perfil.rotulo.estatisticas=Estatísticas
canalhandia.cmd.perfil.sem-dados=sem dados ainda
canalhandia.cmd.perfil.rotulo.conquistas=Conquistas
canalhandia.cmd.perfil.rotulo.titulo=Título
canalhandia.cmd.perfil.titulo.nenhum=nenhum
# /titulo
canalhandia.cmd.titulo.atual=Título atual
canalhandia.cmd.titulo.disponiveis=Disponíveis
canalhandia.cmd.titulo.uso=Use /titulo <nome> para usar, ou /titulo limpar para tirar.
canalhandia.cmd.titulo.nenhum-bloqueado=Você ainda não desbloqueou nenhum título. Veja /conquistas.
canalhandia.cmd.titulo.nao-tem=Você não tem o título "{0}". Veja /titulo para a lista.
canalhandia.cmd.titulo.removido=Título removido.
canalhandia.cmd.titulo.definido=Título definido: {0}.
# /mortes
canalhandia.cmd.mortes.cabecalho=Suas últimas mortes ({0})
canalhandia.cmd.mortes.nenhuma=Você ainda não morreu. Aproveite enquanto dura.
canalhandia.cmd.mortes.copiar=Clique para copiar as coordenadas
# /recado e /recados
canalhandia.cmd.recado.uso=Uso: /recado <jogador> <texto>
canalhandia.cmd.recado.vazio=O recado está vazio.
canalhandia.cmd.recado.mesmo=Recado para você mesmo? Use /save.
canalhandia.cmd.recado.caixa-cheia=A caixa de {0} está cheia ({1} recados). Espere ela entrar.
canalhandia.cmd.recado.guardado=Recado guardado para {0}. Vai chegar quando {0} entrar.
canalhandia.cmd.recado.online={0} está online — recado entregue na hora.
canalhandia.cmd.recados.tudo-entregue=Todos os seus recados já foram entregues.
canalhandia.cmd.recados.pendentes-singular={0} recado seu ainda não foi lido.
canalhandia.cmd.recados.pendentes-plural={0} recados seus ainda não foram lidos.
canalhandia.cmd.recado.desconhecido=Não conheço ninguém chamado "{0}". (Só dá para mandar recado para quem já entrou no servidor.)
# /reagir e /reacoes
canalhandia.cmd.reagir.uso=Uso: /reagir <{0}>
canalhandia.cmd.reagir.nada=Nada para reagir agora.
canalhandia.cmd.reagir.invalida=Essa reação não vale para a última mensagem.
canalhandia.cmd.reagir.expirou=Essa mensagem já expirou.
canalhandia.cmd.reagir.desconhecida=Reação desconhecida.
canalhandia.cmd.reacoes.nenhuma=Ninguém reagiu à última mensagem ainda.
canalhandia.cmd.reacoes.cabecalho=Quem reagiu ({0})
# adivinha / enquete (action bars)
canalhandia.cmd.palpite.uso=Uso: /palpite <nome>
canalhandia.cmd.palpite.nenhuma=Nenhuma adivinha aberta.
canalhandia.cmd.adivinha.rodada-acabou=Essa rodada já acabou.
canalhandia.cmd.votar.uso=Uso: /votar <número>
canalhandia.cmd.votar.encerrada=Essa enquete já foi encerrada.
canalhandia.cmd.votar.opcao-inexistente=Essa opção não existe.
canalhandia.cmd.enquete.nenhuma=Nenhuma enquete aberta.
# /ia (mensagens que o jogador vê; o ajuste de tom é só do operador)
canalhandia.cmd.ia.uso=Uso: /{0} <pergunta>
# /nota e /save
canalhandia.cmd.nota.publica-negado=Você não pode criar anotações públicas. Use /nota add <texto> para uma anotação só sua.
canalhandia.cmd.nota.uso-escopo=Uso: /nota {0} <texto>
canalhandia.cmd.nota.vazia=A anotação está vazia.
canalhandia.cmd.nota.cheia=Você já tem {0} anotações. Apague alguma com /nota remover <n>.
canalhandia.cmd.nota.salva=Anotação #{0} salva ({1}) em {2}.
canalhandia.cmd.nota.listar-uso=Uso: /nota listar [publicas|privadas]
canalhandia.cmd.nota.buscar-uso=Uso: /nota buscar <texto>
canalhandia.cmd.nota.cabecalho=Anotação #{0}
canalhandia.cmd.nota.autor=autor
canalhandia.cmd.nota.escopo=escopo
canalhandia.cmd.nota.lugar=lugar
canalhandia.cmd.nota.remover-uso=Uso: /nota remover <n>
canalhandia.cmd.nota.de-outro=Essa anotação é de {0}.
canalhandia.cmd.nota.apagada=Anotação #{0} apagada.
canalhandia.cmd.nota.nenhuma=Nenhuma anotação.
canalhandia.cmd.nota.e-mais=… e mais {0}. Use /nota buscar <texto> para filtrar.
canalhandia.cmd.nota.lista.tudo=Suas anotações e as públicas
canalhandia.cmd.nota.lista.escopo=Anotações {0}
canalhandia.cmd.nota.lista.busca=Anotações com "{0}"
canalhandia.cmd.nota.nao-encontrada=Anotação não encontrada.
# /curiosidade (vistas por jogador)
canalhandia.cmd.curiosidade.nenhum-elegivel=Ninguém elegível online (ou sem estatísticas suficientes).
canalhandia.cmd.curiosidade.sem-stats={0} ainda não tem estatísticas suficientes.
canalhandia.cmd.curiosidade.sem-curiosidade=Nenhuma curiosidade disponível para {0}.
canalhandia.cmd.curiosidade.toggle-off=Você não aparecerá mais nas curiosidades.
canalhandia.cmd.curiosidade.toggle-on=Você voltou a aparecer nas curiosidades.
canalhandia.cmd.curiosidade.subdesconhecido=Subcomando ou jogador desconhecido. Use /curiosidade ajuda
+10
View File
@@ -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
@@ -1,10 +1,14 @@
package dev.marcospaulo.canalhandia; package dev.marcospaulo.canalhandia;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -123,6 +127,54 @@ class AchievementTest {
() -> Achievement.parse("k", "T", "d", List.of())); () -> Achievement.parse("k", "T", "d", List.of()));
} }
// --- colours and tiers -------------------------------------------------
@Test
void tierPicksTheColourAndCorOverridesIt() {
assertEquals(NamedTextColor.GOLD,
Achievement.parse("l", "L", "d", List.of("tempo >= 1"), "lendario", null).color());
assertEquals(NamedTextColor.LIGHT_PURPLE,
Achievement.parse("e", "E", "d", List.of("tempo >= 1"), "epico", null).color());
// A missing or unknown tier stays legible white never the dark tone
// that started this: the default must always read on chat.
assertEquals(NamedTextColor.WHITE,
Achievement.parse("c", "C", "d", List.of("tempo >= 1"), null, null).color());
// An explicit cor wins over the tier, by name or by hex.
assertEquals(NamedTextColor.RED,
Achievement.parse("n", "N", "d", List.of("tempo >= 1"), "comum", "red").color());
assertEquals(TextColor.fromHexString("#ff5555"),
Achievement.parse("o", "O", "d", List.of("tempo >= 1"), "comum", "#ff5555").color());
// Garbage cor falls back to the tier colour rather than blowing up.
assertEquals(NamedTextColor.AQUA,
Achievement.parse("b", "B", "d", List.of("tempo >= 1"), "raro", "notacolor").color());
}
// --- detailed per-mob / per-block metrics ------------------------------
@Test
void statRefMetricsReadRawCounts() {
Achievement spiders = Achievement.parse("a", "A", "d", List.of("matou:spider >= 100"));
Map<String, Long> s = raw();
s.put("matou:spider", 99L);
assertFalse(spiders.met(s));
s.put("matou:spider", 100L);
assertTrue(spiders.met(s));
}
@Test
void referencedStatsListsEveryRefTheCatalogueUses() {
Achievement.load(List.of(
Achievement.parse("a", "A", "d", List.of("matou:creeper >= 1")),
Achievement.parse("b", "B", "d", List.of("minerou:obsidian >= 1", "tempo >= 1"))));
assertEquals(Set.of("matou:creeper", "minerou:obsidian"), Achievement.referencedStats());
}
@Test
void rejectsUnknownStatPrefix() {
assertThrows(IllegalArgumentException.class,
() -> Achievement.parse("x", "X", "d", List.of("voou:creeper >= 1")));
}
// --- the shipped catalogue loads and is sane --------------------------- // --- the shipped catalogue loads and is sane ---------------------------
@Test @Test
@@ -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,38 @@
package dev.marcospaulo.canalhandia;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Random;
import java.util.logging.Logger;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
/**
* The consolation-gift parsing and pick. Deliberately avoids the happy path of
* {@link DeathGift#parse}, which calls {@code Material.isItem()} that throws in
* a unit JVM without a bootstrapped registry (same limit RecipeBookTest notes),
* so the item-resolution branch is verified live instead.
*/
class DeathGiftTest {
private static final Logger LOG = Logger.getAnonymousLogger();
@Test
void skipsMalformedAndUnknownLines() {
assertTrue(DeathGift.parse(List.of("sem as barras certas"), LOG).isEmpty());
assertTrue(DeathGift.parse(List.of("SÓ | DUAS_PARTES"), LOG).isEmpty());
// Unknown material name is rejected at matchMaterial, before isItem().
assertTrue(DeathGift.parse(List.of("ITEM_QUE_NAO_EXISTE_XYZ | Nome | msg"), LOG).isEmpty());
}
@Test
void pickIsNullOnEmptyAndAMemberOtherwise() {
assertNull(DeathGift.pick(List.of(), new Random()));
DeathGift.Gift only = new DeathGift.Gift(Material.POPPY, "Flor do Velório", "oi");
assertSame(only, DeathGift.pick(List.of(only), new Random()));
}
}
@@ -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));
}
}
@@ -0,0 +1,109 @@
package dev.marcospaulo.canalhandia;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import net.kyori.adventure.translation.GlobalTranslator;
import net.kyori.adventure.translation.TranslationStore;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.text.MessageFormat;
import java.util.Locale;
import java.util.PropertyResourceBundle;
import java.util.ResourceBundle;
import java.util.TreeSet;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* The two contracts the i18n bundles must hold: identical key sets across PT
* and EN, and one translatable rendering differently per locale. The store's
* own {@code translate(key, locale)} is an exact-locale lookup (no fallback);
* the fallback chain runs in {@link GlobalTranslator#render}, which is what
* the locale-resolution tests exercise.
*/
class I18nTest {
private TranslationStore.StringBased<MessageFormat> store;
@BeforeEach
void registerStore() throws IOException {
store = TranslationStore.messageFormat(Key.key("canalhandia"));
store.defaultLocale(Locale.ENGLISH);
store.registerAll(Locale.ENGLISH, bundle("lang/messages_en.properties"), true);
store.registerAll(Locale.of("pt"), bundle("lang/messages_pt.properties"), true);
GlobalTranslator.translator().addSource(store);
}
@AfterEach
void unregisterStore() {
GlobalTranslator.translator().removeSource(store);
}
private static ResourceBundle bundle(String resource) throws IOException {
try (var in = I18nTest.class.getClassLoader().getResourceAsStream(resource)) {
assertNotNull(in, "bundle ausente no classpath: " + resource);
return new PropertyResourceBundle(new InputStreamReader(in, StandardCharsets.UTF_8));
}
}
private static String plain(Component c) {
return PlainTextComponentSerializer.plainText().serialize(c);
}
/** Every key in one bundle must exist in the other, or a locale shows the raw key. */
@Test
void bothBundlesHaveTheSameKeys() throws IOException {
var pt = new TreeSet<>(bundle("lang/messages_pt.properties").keySet());
var en = new TreeSet<>(bundle("lang/messages_en.properties").keySet());
assertEquals(pt, en,
"chaves divergentes — so no PT: " + only(pt, en) + ", so no EN: " + only(en, pt));
}
private static java.util.Set<String> only(java.util.Set<String> a, java.util.Set<String> b) {
var diff = new TreeSet<>(a);
diff.removeAll(b);
return diff;
}
/**
* A pt client and an en client see different text from one component.
* Rendering runs through {@link GlobalTranslator}, the same path Paper uses
* on send the store itself only resolves a key to a {@link MessageFormat}.
*/
@Test
void rendersDifferentlyPerLocale() {
Component translatable = Component.translatable("canalhandia.morte.luto.prestar",
Component.text("Steve"));
Component pt = GlobalTranslator.render(translatable, Locale.of("pt"));
Component en = GlobalTranslator.render(translatable, Locale.ENGLISH);
assertEquals("prestar luto por Steve", plain(pt));
assertEquals("pay respects for Steve", plain(en));
}
/** pt_BR falls back to pt via the GlobalTranslator chain. */
@Test
void ptBrFallsBackToPt() {
Component rendered = GlobalTranslator.render(
Component.translatable("canalhandia.morte.luto.resumo",
Component.text("Ana, Bob"), Component.text("Steve")),
Locale.forLanguageTag("pt-BR"));
assertEquals("Ana, Bob prestaram luto por Steve.", plain(rendered));
}
/** An unknown locale renders in the default (en), not as the raw key. */
@Test
void unknownLocaleFallsBackToDefault() {
Component rendered = GlobalTranslator.render(
Component.translatable("canalhandia.morte.luto.prestar", Component.text("Steve")),
Locale.forLanguageTag("ja"));
assertEquals("pay respects for Steve", plain(rendered));
}
}
@@ -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
@@ -54,4 +58,11 @@ class TitlesTest {
void tagCarriesTheTitle() { void tagCarriesTheTitle() {
assertNotNull(TitleChatListener.tag(Achievement.byKey("pedreiro"))); assertNotNull(TitleChatListener.tag(Achievement.byKey("pedreiro")));
} }
@Test
void tagRootIsColourlessSoMessageStaysWhite() {
// The chat message is appended to this tag; a coloured root would bleed
// into unstyled message text and grey it out. Root must carry no colour.
assertNull(TitleChatListener.tag(Achievement.byKey("pedreiro")).color());
}
} }
@@ -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]);
}
}