19 Commits

Author SHA1 Message Date
Marcos Paulo 3d263bee90 feat(i18n): Adventure GlobalTranslator foundation + Lang facade
Per-player i18n: register a TranslationStore (key canalhandia) into the
GlobalTranslator from lang/messages_{pt,en}.properties. Paper renders each
Component.translatable in the recipient's own locale at send time, so one
broadcast shows every player their own language (pt/en), Java and Bedrock
alike. defaultLocale=en; pt + pt_BR fall back to the PT bundle; unknown
locale -> en.

- I18n: loader + registry install; removeSource before addSource so
  /canalhandia reload does not stack sources.
- Lang.tr(key, args) facade over Component.translatable.
- Canalhandia.onEnable wires it; /canalhandia reload now reloadI18n().
- Proof migration: onDeath mourning lines -> Lang.tr (broadcast -> per-viewer).
- I18nTest: key parity PT/EN + per-locale render + pt_BR fallback + unknown
  -> default. 316/316 pass.

Bundles seeded with the mourning keys; bulk module-by-module migration and
per-locale number/date formatting are next, one PR each per the spec.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 11:53:01 -03:00
Marcos (via Claude) cee0b020a1 feat(mortes): comic consolation gift on respawn + fix grey title chat
- On respawn a dead player privately gets a harmless comic item (funeral
  poppy, wilted bush, ...) with a pt-BR jab. Config-driven under
  mortes.presente ("MATERIAL | Nome | mensagem"), defaults baked in,
  hot-reloadable via /canalhandia reload. Overflow drops at their feet.
- Fix: a title-holder's chat text rendered grey. The title chip was
  rooted on a DARK_GRAY component and the message, appended to it,
  inherited that colour. Root on Component.empty() so the message falls
  back to white.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 11:14:25 -03:00
Marcos (via Claude) 34a0b44a03 feat(conquistas): tiered title colours + per-mob/-block stats
- Titles carry a tier (comum..lendario) that colours their chat chip;
  optional per-title `cor` override (named or #hex). Low tier is now
  white, fixing the too-dark/unreadable default. Colour flows through
  the chat tag, /conquistas list, and unlock announcement.
- New StatRef metric grammar: matou:creeper, minerou:obsidian, etc.
  reach any per-mob/per-block vanilla counter from config, no code.
- Unified achievement reads onto the offline stats file (dropped the
  Bukkit snapshot path) so granular counters work on- and offline.
- Fixed unreachable titles: kamikaze (combate>=20 & mortes>combate),
  rato-de-caverna (20k mined, <50km). Catalogue 29 -> 38 titles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 18:37:05 -03:00
Marcos Paulo f1d210ddc4 feat(ia): agentic tool-calling — web search, stats, ranking, wiki
Turn /ia from a fixed-prompt chatbot into an agent that pulls what it needs.
Instead of one hardcoded wiki pre-fetch, the model is offered read-only tools
and MiniMax's tool_choice=auto lets it decide which to call; results are fed
back until it answers (MiniMax.answerWithTools), capped by ia.max-ferramentas.

- Search: web search via the cluster's self-hosted SearXNG (JSON API, no
  external key); results boiled down to a few "título — trecho (url)" lines.
- Tools: registry + dispatch for pesquisar_web, wiki, estatisticas_jogador,
  conquistas_jogador, ranking. All read-only and thread-safe off the main
  thread, so they run on the existing async answer worker.
- Ai.ask: agentic path when ia.ferramentas is on (default), else the previous
  behaviour untouched.
- Config: ia.ferramentas, ia.max-ferramentas, ia.searxng-url, ia.resultados-web,
  ia.trecho-web; getters in Settings. Reloadable via /canalhandia reload.

Verified live against MiniMax-M2.7 + SearXNG: the model auto-calls the tool and
answers from the result. SearchTest covers the pure result formatter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 12:52:04 -03:00
Marcos Paulo 6dda1e33d3 feat(conquistas): config-driven catalogue with /canalhandia reload
Move achievements and milestones out of code and into editable YAML, so
operators add or retune titles by editing a file and reloading — no rebuild.

- conquistas-catalogo.yml: each title is a key with titulo/descricao and a list
  of condicoes. Conditions are a tiny grammar ("metrica operador alvo", target
  may be a number, another metric, or metrica/numero), evaluated on friendly
  units (km, hours) normalised from the raw stats. Achievement is now a class
  loaded from this file, not an enum.
- marcos-catalogo.yml: milestone tracks (statistica/verbo/unidade/limiares),
  loaded by Milestones at startup and reload.
- /canalhandia reload now reloads both catalogues (plus config.yml) live and
  reports the counts.
- Shipped catalogue expanded 10 -> 29 titles and every milestone track deepened.
- Silent backfill on load/reload (Achievements.syncCatalogue, Milestones
  .resyncSilently, keyed on a stored signature): a grown catalogue banks the
  history it already implies instead of spamming returning players.

Tests: AchievementTest covers the condition grammar and validates the shipped
catalogue; TitlesTest loads the real catalogue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 12:35:36 -03:00
Marcos Paulo 29000c208b feat(conquistas): view any player's titles, /perfil card, wearable title tag
Extend the achievements module from self-only to a full RPG-status layer,
all still chat-only and derived from vanilla stats.

- /conquistas [jogador]: look up anyone's earned titles, offline included,
  computed from their stats on disk via the new OfflineStats.achievementStats
  (same keys the online snapshot uses, so identical Achievement conditions).
- /perfil [jogador]: a status card — headline stats, titles earned, worn title.
- /titulo [nome|limpar]: pick which earned title to wear; only earned ones are
  accepted and tab-completed. Stored per player in titulos.yml (Titles).
- TitleChatListener: an AsyncChat renderer that prefixes the chosen "[Título]"
  chip, for Java and Bedrock alike; gated on the conquistas module.

Tests: TitlesTest covers title selection and the offline stat-key contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 12:17:24 -03:00
marcos c54c6b22b3 feat(ia): split long/list answers into separate chat messages
Minecraft has no meaningful character cap on what the server sends —
the 256-char limit is only on what a player types. The real problem
was that AiText.sanitise flattened every \n to a space, so a
model-produced numbered list or multi-paragraph answer landed as one
wrapped wall of text instead of readable lines.

AiText.segments() keeps the model's own line breaks, re-wraps any
line still too long at a sentence boundary (falling back to a
word/char wrap for pathological input), and caps both the total
character budget (ia.max-caracteres, unchanged meaning) and the
number of chat messages (new ia.max-mensagens, default 4) so a
runaway list can't flood chat.

Ai.deliver sends one message per segment instead of one flattened
line; style() tags only the first with [IA], continuation lines get
a plain " » " marker so a 5-item list reads as one grouped answer,
not five separate replies.

295 -> 305 tests.
2026-08-09 06:06:51 +00:00
marcos 67a2cbb37d Document mail, death history, achievements, weekly rankings, spontaneous AI and BlueMap markers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-08 01:05:53 +00:00
marcos 4ec9817636 Add weekly rankings, spontaneous AI lines and BlueMap note markers
Weekly rankings (/ranking semanal [metrica]). On a server with three regulars
an all-time board is decided by whoever started first and then stops being a
contest. Subtracting a baseline taken at the start of the week makes it one
again. Rotation is time-based and idempotent — the snapshot carries the
timestamp it was taken at and is replaced only once a week has actually
elapsed, so a server that restarts nightly does not reset the week every day.
There is a test for exactly that. Players who did not move are dropped, since
the point is who is playing this week; a player missing from the baseline
counts their whole value, having joined during the week; a negative difference
is dropped rather than shown, because statistics only go up and a negative
means a stale baseline, not a result worth printing.

Spontaneous AI lines (ia.comentar-eventos, ia.saudacao — both off by default).
The persona comments on a run of deaths, and greets players as they join using
their own numbers. Both are opt-in: a chatty AI nobody asked for is the fastest
way to make players hate the feature.

Budget is what makes this tolerable rather than obnoxious, and it is the piece
worth reading. A player question is self-limiting — someone chose to spend it.
A line the AI decides to make on its own is not, and it costs money every time,
so three limits all have to pass: a gap between any two lines, a daily cap of
its own separate from the /ia cap, and a per-subject cooldown so one unlucky
player is not narrated all evening. allows() does not spend, so a caller that
decides not to fire (nobody online, the model returned nothing) has burned
nothing; saySomething spends up front rather than on success, because two
events in the same tick would otherwise both pass allows() and fire together —
the exact double-message the gap exists to prevent. The subject map drops
expired entries on each spend, or a long-lived server would leak one entry per
player who ever triggered a line.

A death streak now decays: three deaths across an evening is not a streak,
three in ten minutes is. Spontaneous lines are silent on failure — nobody asked
for it, so nobody should see it fail.

The five-minute sweep no longer hides behind the marcos toggle. It was a
milestones-only task, so turning off marcos also silenced achievements and
froze the weekly board; each of the three now checks its own module inside the
body.

BlueMap markers (notas.no-mapa, default on). Public notes already carry a world
and coordinates and the server already runs BlueMap, so this joins the two.
BlueMapBridge is the only class that touches BlueMap's API and every entry
point catches NoClassDefFoundError as well as Exception — the failure mode of a
missing optional dependency is a linkage error, not an exception — so a server
without BlueMap logs one fine-level line and carries on. The dependency is
'provided' because BlueMap ships those classes itself; a second copy inside our
jar would shadow them and break the real plugin, and preflight now fails if the
scope is ever dropped.

Markers are rebuilt rather than incrementally patched: BlueMap discards
everything when it unloads and expects addons to re-create markers on its
enable callback, and a full rebuild of a tiny list cannot drift out of sync the
way a missed delete would. Notes are matched to the map that renders their
world, or a Nether note would be drawn at the same numeric coordinates in the
overworld, pointing at nothing. Note text is player-written and lands in a web
page, so it is HTML-escaped — ampersand first, or the other replacements would
be double-escaped. Private notes are never drawn, at any setting.

295 tests, up from 263.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-08 01:03:10 +00:00
marcos 26148740ef Add offline mail, death history and named achievements
Three features that all answer questions the server could already have
answered but was throwing away.

Mail (/recado <jogador> <texto>, /recados). On a server where people rarely
overlap, "achei diamante em -400 70 200" had to go through Discord or be lost.
Messages are queued for anyone who has joined before — resolved through
usercache.json, case-insensitively, because nobody types a name with the right
capitalisation — and delivered on their next join. If the recipient is online
it is delivered immediately instead of queued, since queueing it would mean
the person standing next to you reads it only after a relog.

Delivery is destructive: a message that stayed queued would be re-read on every
single join, turning a helpful note into a nuisance. It is also delayed a few
seconds and re-checks isOnline, because a player can leave inside the delay and
the mail would otherwise be consumed with nobody there to read it. Its own
join handler, not a branch inside onJoin, which returns early when the
curiosidades module is off — mail must not depend on an unrelated module.
Inbox capped per recipient, counting every sender, so the cap cannot be
bypassed with a second account.

Death history (/mortes). The mortes module already knew where and how someone
died and discarded it once the coords were delivered on respawn. Keeping the
last ten per player answers what people actually ask a day later. Eviction is
per player, not global, or one player's bad night would erase everyone else's
history. Your own deaths only: where someone died is where their stuff is, and
a public list of that is a looting guide.

Named achievements (/conquistas). Milestones covers round numbers; this covers
the combinations that say something about how someone plays — "Casca Grossa"
(50 hours, under 10 deaths), "Turista" (100 hours, barely mined), "Imortal às
Avessas" (dies more than once per hundred blocks mined). Every condition is a
pure function of a stat map, so the catalogue is unit-tested without a server.
The ratio ones carry a floor on absolute mining, so a new player is not handed
a joke achievement on their second death — there is a test for exactly that.

First sight is silent, like Milestones: the first time a player is checked,
whatever they have already earned is recorded without announcing it. Otherwise
enabling the module would dump a dozen announcements for history earned months
ago. Players who qualify for nothing are still marked as seen, or every later
check would treat them as new and stay silent forever.

Achievements share the milestone task rather than adding a second timer: both
sweep every online player's statistics, so one pass does the work of two.
Stats.totalOf sums a material-keyed statistic into a long — the per-material
values are ints and a long-running player's total can pass Integer.MAX_VALUE.

The /conquistas checklist uses "[x]" on Bedrock instead of "✔", which renders
there as a tofu box — the same per-platform rule the reaction labels follow.

Msg.ago renders wall-clock timestamps as "há 2 dias"; a timestamp from the
future clamps to "agora" rather than printing a negative age.

263 tests, up from 218.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-08 00:49:56 +00:00
marcos c11085e95e Document the notes module
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-07 23:49:12 +00:00
marcos bc711a48c2 Add chat notes: /save and /nota, public and private
Players had nowhere to write anything down. Coordinates got pasted into
Discord, or lost. This adds notes to chat, with two scopes.

A private note is visible only to its author and everyone may write one
(canalhandia.nota, default true). A public note is broadcast and readable by
all, and writing one needs canalhandia.nota.publica (default op) — public
notes are a curated board, not a graffiti wall. Both permissions are declared
in plugin.yml: an undeclared Bukkit permission silently falls back to op-only,
which would have stopped normal players writing anything.

Every note stores where it was written. On a Minecraft server a note is nearly
always about a place — where the base is, where the spawner was found — so
coordinates are part of the model rather than optional metadata. Java players
get click-to-copy on them, the same affordance the death-coords message uses;
Bedrock renders no click event and gets the plain text. No teleport: the
plugin does not touch gameplay.

/save is the quick path. /save and /save coords pin the spot; /save <texto>
pins it with a note. Private on purpose — it is the command someone types
without reading help first, and the safe default there is the one that cannot
surprise anyone by broadcasting. /nota publica <texto> is the explicit way to
share.

Private notes are never sent to the AI, at any setting. The AI call leaves
this server for a third-party API, so a private note reaching it would be a
disclosure the author never agreed to. The filter lives inside
Notes.publicSummary — the only method the AI path calls — rather than at the
call site, so a future caller cannot get it wrong by accident. Public notes
are sent (ia.contexto-notas, default 10), which is what lets the AI answer
"onde fica a base?" from what players actually wrote down.

A note the viewer cannot see is reported as missing rather than as forbidden:
saying "that one is private" would confirm it exists, which is itself a leak.
Search runs through the same visibility filter, so it cannot become a way to
probe for someone else's text. Ids are never reused after a deletion, or
"/nota ver 2" would point at a different note than the one someone wrote down
a minute ago. Text is stripped of the section sign and control characters,
because a note is echoed into chat and could otherwise forge a line that looks
like it came from the server.

Scope parsing accepts the masculine and plural forms ("publico", "publicas")
alongside the canonical ones. Spelled out rather than derived: a blanket a-to-o
rewrite turns "privada" into "privodo", and the plural is exactly what tab
completion suggests, so it has to parse or the suggested command fails.

preflight.sh now checks all 18 commands and both new permissions.

218 tests, up from 176.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-07 23:46:55 +00:00
marcos 5653836262 Document the AI personality, chat/world context and preflight harness
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-07 22:50:30 +00:00
marcos 0726ce3794 Give the AI a personality, chat awareness and live server state
The AI could only see three static facts about the server, so it answered
"quem tá online?" and "tá chovendo?" by insisting it had no access — true of
the model, but not of the plugin, which has all of it on hand. It also had no
tone: correct answers delivered like a manual, on a server whose whole point
is people ribbing each other.

Persona: five tones (zoeiro, amigao, seco, aldeao, neutro), switchable live
with /ia personalidade <nome>. Personality is expressed only as extra system
instructions and changes how the model talks, never what it may do. Every
persona — including the blank one — carries Persona.GUARD, which restates the
no-commands/no-server-access limits inside the persona's own frame, so a
roleplay instruction cannot read as licence to claim powers the plugin does
not grant it. The guard also forbids inventing stats, which matters now that
real numbers are being fed in. The teasing personas each state where the line
is; a test asserts every one of them does.

ChatLog: a 50-line in-memory ring of public chat, the last few lines handed to
the model so a follow-up like "quem tá reclamando aí?" has a referent. Written
from the chat event (off the main thread) and read from /ia, so it is
synchronised; a concurrency test hammers it from eight threads, because an
unsynchronised deque here would throw ConcurrentModification into a player's
answer. Recorded at MONITOR priority so what is stored is what the room saw —
a zoacao swap included — and cancelled messages are never stored. Nothing
touches disk.

ServerState: who is online with their platform, dimension, time of day,
weather, and the asker's coordinates, health, hunger and XP. Captured on the
main thread before the async call — every field reads the Bukkit world API,
which is not safe off it — and only the formatted string crosses the thread
boundary. Formatting is pure and tested, including the negative-tick case a
raw modulo would drop through every band.

Styling: Java players get a hover card with the original question and the
active persona, plus a click that pre-fills "/ia " for a follow-up.
suggestCommand, not runCommand: nothing executes without the player pressing
enter. Bedrock renders neither hover nor click, so it keeps the plain line,
built through broadcastPerPlatform like every other interactive message here.

preflight.sh: a read-only pre-restart harness. It verifies the jar opens, that
plugin.yml declares all 16 commands, that the staged jar's hash matches the
local build and is owned 1000:0, that the live config parses as YAML and
carries the keys this deploy depends on, and that a rollback jar and config
backup both exist. It never restarts anything. A missing YAML parser reports
as "not checked" rather than "invalid" — a harness that cries wolf gets
ignored.

176 tests, up from 101.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-07 22:49:02 +00:00
marcos b77a38b394 Make zoacao match rule editable in-game: mode + pattern + message list
Zoacao now matches a configurable pattern under a configurable mode
(igual/contem/comeca/termina/regex) instead of only a bare 'f'. The mode,
the trigger pattern, and the gag message list are all editable live via
/canalhandia zoacao <listar|modo|padrao|adicionar|remover|limpar>; every
change writes through to config.yml immediately. config.yml gains
zoacao.correspondencia + zoacao.padrao (default igual + 'f').

Zoacao.replace/ matches gained a Mode + pattern signature; ZoacaoTest
covers all five modes (incl. invalid-regex guard) and the byKey parser.
136 tests green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 22:27:42 +00:00
marcos 72840c9770 Document zoacao module in README
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 22:23:25 +00:00
marcos d61de1b208 Add zoacao chat gag: bare 'f' replaced with a random line
New module keyed 'zoacao'. An AsyncPlayerChatEvent listener swaps any
bare 'f'/'F' (trimmed, nothing else) for a random line from the
configurable zoacao.mensagens list (default Sou gay + 5 others). Pure
chat swap — player name still prefixes it; luto tribute untouched (still
needs the [F] button or /f command). Gated by modulos.zoacao (default on).

Zoacao.replace is pure for unit tests; ZoacaoTest covers bare f, case,
whitespace, f-with-extra-text, empty/null lists. 124 tests green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 22:22:25 +00:00
marcos 95520831da Remove reaction boss bar; fix death coords; F-tribute head
- Reactions: drop the purple boss bar that sat on screen for the whole
  reaction window (curiosidades, adivinha, luto [F], /ia answers). Live
  counts still ride on the reactor's action bar and the closing tally line.
- mortes: death coordinates were sent during PlayerDeathEvent and the Java
  death screen swallowed them. Capture at death, deliver on PlayerRespawnEvent
  (1 tick later) so the player actually receives them. Respects keepInventory.
- luto: pressing F to pay respects now drops the dead player's head into the
  mourner's inventory — once per mourner per death, never to the dead player.
  First gameplay-touching feature; toggle with luto.cabeca (default on).
- Fix the /f typed twin: it fell through to help because "f" is not a
  configured reaction (the mourning set is hardcoded). Route it to reactLatest
  so Bedrock players can pay respects too.
- README + config updated. 117 tests green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 13:59:28 +00:00
marcos 68db3a7079 feat: feed asker stats to the IA + comic death messages
Two features requested after the IA grounding deploy.

IA player-stats grounding: the IA could not answer "quantos blocos eu
minerei?" because its context carried only generic server facts, never the
asker's own numbers. OfflineStats now reads one player's headline stats
(blocks mined, time played, distance, deaths, mob kills) from their vanilla
stats JSON and Ai.compose() injects them as a system turn, gated by
ia.estatisticas-jogador (default true). ~30 tokens per question; always on
so it never misses a stat question. Pure formatSummary extracted for tests.

Comic deaths (mortes module): replaces the vanilla death message with a
cause-based pt-BR comic line plus a death counter ("Fulano foi achatado
como panqueca (47ª morte)") and sends the death coordinates privately to
the dead player (Java click-to-copy, Bedrock plain text) so they can run
back to their dropped items. No storage, no command — a PlayerDeathEvent
side effect gated by modulos.mortes. DeathFlavor is a pure cause->phrase
map, unit-tested. Coexists with the luto [F] handler.

DEATHS stat timing: assumes Paper fires PlayerDeathEvent before awarding
minecraft:deaths, so the counter shows +1 to include the current death; a
log line confirms the raw stat on the first real death so the +1 can be
dropped if the server increments first.

117 tests green (101 + 13 DeathFlavor + 3 OfflineStatsSummary).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 05:06:45 +00:00
41 changed files with 229 additions and 4829 deletions
-53
View File
@@ -1,53 +0,0 @@
## 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
@@ -1,30 +0,0 @@
{
"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
@@ -1,93 +0,0 @@
# 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,5 +610,3 @@ 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
@@ -1,84 +0,0 @@
# 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
@@ -1,103 +0,0 @@
# 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`.
@@ -1,46 +0,0 @@
# 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
@@ -1,37 +0,0 @@
# 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
@@ -1,72 +0,0 @@
# 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
@@ -1,28 +0,0 @@
# 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
@@ -1,23 +0,0 @@
# Spec: Proteção de Itens no Vácuo (Void Protection)
## 1. Problema e Motivação
Quando um jogador morre no vácuo (caindo no The End, Nether ou Overworld profundo), todos os itens e armaduras caem abaixo de Y = -64 e são excluídos pelo motor do Minecraft, impossibilitando qualquer recuperação legítima e gerando frustração.
## 2. Solução
Implementar o módulo `salvavoid` no plugin Canalhandia:
1. **Detecção:** Identifica mortes por vácuo (`DamageCause.VOID` ou coordenada Y abaixo da altura mínima do mundo).
2. **Busca de Terreno Seguro:** Procura o bloco sólido mais próximo da posição horizontal onde o jogador caiu (raio configurável, padrão 32 blocos).
3. **Opção A (Baú de Resgate):** Se encontrar terreno seguro:
- Cria um baú (ou baú duplo se necessário) sobre o bloco seguro.
- Guarda todos os itens e armaduras do jogador dentro do baú.
- Limpa os drops do evento de morte (para não cair no vácuo).
- Informa ao jogador a localização exata (coordenadas X, Y, Z) do baú no chat.
4. **Opção B (Preservação Direta no Inventário):** Se NÃO houver nenhum bloco seguro por perto (ex: caiu no meio do vácuo infinito do End):
- Preserva o inventário e nível de XP do jogador (`keepInventory = true`, `keepLevel = true`).
- Limpa os drops do evento de morte.
- Envia mensagem confortando o jogador e avisando que os itens foram mantidos no inventário.
## 3. Critérios de Aceitação
- Módulo integrado ao `/canalhandia modulo salvavoid <on|off>`.
- Configurações em `Settings.java`: `voidProtectionEnabled`, `voidProtectionRadius`, `voidProtectionKeepXp`.
- Testes unitários cobrindo detecção de morte no vácuo, cálculo de busca e empacotamento de inventário.
@@ -159,17 +159,6 @@ final class Achievements {
.append(Component.text("" + achievement.description(), NamedTextColor.GRAY) .append(Component.text("" + achievement.description(), NamedTextColor.GRAY)
.decoration(TextDecoration.BOLD, false))); .decoration(TextDecoration.BOLD, false)));
plugin.getLogger().info("[conquistas] " + player.getName() + "" + achievement.key()); plugin.getLogger().info("[conquistas] " + player.getName() + "" + achievement.key());
if (plugin.settings().aiEvents() && plugin.settings().moduleEnabled(Module.IA)) {
Persona persona = plugin.playerMemory() != null
? plugin.playerMemory().persona(player.getUniqueId(), plugin.settings().aiPersona())
: plugin.settings().aiPersona();
plugin.ai().saySomething(player.getName(),
"O jogador " + player.getName() + " desbloqueou a conquista \""
+ achievement.title() + "\" (" + achievement.description()
+ "). Faça um breve comentário na sua personalidade.",
plugin.aiBudget(), persona);
}
} }
/** Which achievements this player has already unlocked. */ /** Which achievements this player has already unlocked. */
@@ -238,16 +238,12 @@ 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(persona.displayTag(), persona.tagColor()) Bukkit.broadcast(Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
.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)
@@ -258,7 +254,6 @@ 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
@@ -266,15 +261,12 @@ 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, effectivePersona, liveState, chatContext, memoryContext); compose(asker, prompt, settings, liveState, chatContext);
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,
@@ -294,8 +286,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()));
} }
} }
@@ -328,7 +320,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, effectivePersona, isPriv); deliver(id, prompt, finalAnswer, settings, isPriv);
}); });
}); });
} }
@@ -341,7 +333,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,
Persona persona, String liveState, String chatContext, String memoryContext) { String liveState, String chatContext) {
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()));
@@ -349,17 +341,13 @@ 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", persona.systemText())); messages.add(new MiniMax.Turn("system", settings.aiPersona().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
@@ -421,7 +409,7 @@ final class Ai {
} }
private void deliver(UUID askerId, String question, String answer, private void deliver(UUID askerId, String question, String answer,
Settings settings, Persona persona, boolean isPrivate) { Settings settings, 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) {
@@ -437,27 +425,20 @@ 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, persona, settings.aiFancy(), bedrock, i == 0)); asker.sendMessage(style(segments.get(i), question, settings, bedrock, i == 0));
} }
} }
return; return;
@@ -469,32 +450,48 @@ 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, persona, settings.aiFancy(), bedrock, first)); plugin.broadcastPerPlatform(bedrock -> style(segment, question, settings, bedrock, first));
} }
plugin.openAiReactions(askerId); plugin.openAiReactions(askerId);
} }
/** /**
* Renders one answer for chat. * Renders one answer for chat.
*
* <p>Java players get a hover card naming the persona and the question that
* produced the answer, plus a click that pre-fills {@code /ia } so a
* follow-up is one keystroke away — {@code suggestCommand}, never
* {@code runCommand}, so nothing executes without the player pressing enter.
*
* <p>Bedrock gets the same text with no hover and no click, because it
* renders neither; the styling is decoration and its absence costs nothing.
* {@code ia.estilo-rico: false} forces the plain form everywhere.
*
* <p>A long or list-shaped answer arrives as several segments ({@link
* AiText#segments}); the first carries the full {@code [IA]} tag, the rest
* carry a plain grey continuation mark instead of repeating the tag on
* every line, so a five-item list reads as one grouped answer rather than
* five separate IA replies.
*/ */
static Component style(String answer, String question, Persona persona, boolean fancy, boolean bedrock, boolean firstLine) { private Component style(String answer, String question, Settings settings, boolean bedrock, boolean firstLine) {
Component body = Component.text(answer, NamedTextColor.WHITE) Component body = Component.text(answer, NamedTextColor.WHITE)
.decoration(TextDecoration.BOLD, false); .decoration(TextDecoration.BOLD, false);
if (!bedrock && fancy) { if (!bedrock && settings.aiFancy()) {
Persona persona = settings.aiPersona();
body = body body = body
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText( .hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
Component.text("Pergunta: ", NamedTextColor.GRAY) Component.text("Pergunta: ", NamedTextColor.GRAY)
.append(Component.text(AiText.forLog(question), NamedTextColor.WHITE)) .append(Component.text(AiText.forLog(question), NamedTextColor.WHITE))
.append(Component.newline()) .append(Component.newline())
.append(Component.text("Personalidade: ", NamedTextColor.GRAY)) .append(Component.text("Personalidade: ", NamedTextColor.GRAY))
.append(Component.text(persona.displayName(), persona.tagColor())) .append(Component.text(persona.key(), NamedTextColor.LIGHT_PURPLE))
.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(persona.displayTag(), persona.tagColor()) ? Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
: Component.text(" » ", NamedTextColor.DARK_GRAY); : Component.text(" » ", NamedTextColor.DARK_GRAY);
return prefix.append(body); return prefix.append(body);
} }
@@ -504,15 +501,18 @@ 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,11 +530,9 @@ 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", persona.systemText())); messages.add(new MiniMax.Turn("system", settings.aiPersona().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));
@@ -562,7 +560,7 @@ final class Ai {
return; return;
} }
Bukkit.getScheduler().runTask(plugin, () -> Bukkit.broadcast( Bukkit.getScheduler().runTask(plugin, () -> Bukkit.broadcast(
Msg.tag(persona.displayTag(), persona.tagColor()) Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
.append(Component.text(clean, NamedTextColor.WHITE) .append(Component.text(clean, NamedTextColor.WHITE)
.decoration(TextDecoration.BOLD, false)))); .decoration(TextDecoration.BOLD, false))));
}); });
@@ -27,26 +27,18 @@ 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, java.util.function.Supplier<ChunkLoaders> chunkLoaders, BlueMapBridge(Notes notes, Logger logger, java.util.function.BooleanSupplier enabled) {
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;
} }
/** /**
@@ -55,19 +47,20 @@ final class BlueMapBridge {
*/ */
void hook() { void hook() {
try { try {
de.bluecolored.bluemap.api.BlueMapAPI.onEnable(api -> { de.bluecolored.bluemap.api.BlueMapAPI.onEnable(api -> sync());
sync(); logger.info("BlueMap encontrado — anotações públicas vão para o mapa.");
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; marcadores desativados."); logger.fine("BlueMap não está instalado; anotações ficam só no chat.");
} }
} }
/** /**
* 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()) {
@@ -86,6 +79,9 @@ 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;
} }
@@ -100,66 +96,13 @@ final class BlueMapBridge {
map.getMarkerSets().put(SET_ID, set); map.getMarkerSets().put(SET_ID, set);
} }
} catch (NoClassDefFoundError | Exception e) { } catch (NoClassDefFoundError | Exception e) {
// One line, then stop trying: a broken bridge must never turn into
// a log flood on every note edit.
available = false; available = false;
logger.warning("Não consegui atualizar os marcadores do BlueMap: " + e); logger.warning("Não consegui atualizar os marcadores do BlueMap: " + e);
} }
} }
void syncChunkLoaders() {
if (!available || !chunkLoadersEnabled.getAsBoolean() || chunkLoaders == null) {
return;
}
ChunkLoaders loaders = chunkLoaders.get();
if (loaders == null) {
return;
}
try {
var maybeApi = de.bluecolored.bluemap.api.BlueMapAPI.getInstance();
if (maybeApi.isEmpty()) {
return;
}
var api = maybeApi.get();
for (var map : api.getMaps()) {
var set = de.bluecolored.bluemap.api.markers.MarkerSet.builder()
.label(SET_LOADERS_LABEL)
.build();
for (ChunkLoader loader : loaders.all()) {
if (!mapMatchesWorld(map, loader.world())) {
continue;
}
String statusHtml = loader.enabled()
? "<span style='color:#55ff55'>Ativo</span>"
: "<span style='color:#ff5555'>Pausado</span>";
var marker = de.bluecolored.bluemap.api.markers.POIMarker.builder()
.label(loader.simpleName() + " (" + loader.ownerName() + ")" + (loader.enabled() ? "" : " [Pausado]"))
.detail("<b>" + escape(loader.displayName()) + "</b><br>Status: "
+ statusHtml + "<br>Dono: "
+ escape(loader.ownerName()) + "<br>Chunk: " + loader.chunkCoords())
.position(loader.x(), loader.y(), loader.z())
.build();
set.getMarkers().put("loader-" + loader.id(), marker);
}
map.getMarkerSets().put(SET_LOADERS_ID, set);
}
} catch (NoClassDefFoundError | Exception ignored) {
}
}
private boolean mapMatchesWorld(de.bluecolored.bluemap.api.BlueMapMap map, String world) {
if (world == null || world.isBlank()) {
return false;
}
String mapId = map.getId().toLowerCase(java.util.Locale.ROOT);
String w = world.toLowerCase(java.util.Locale.ROOT);
if (w.endsWith("_nether") || w.equals("nether") || w.equals("world_nether")) {
return mapId.contains("nether");
}
if (w.endsWith("_the_end") || w.equals("the_end") || w.equals("world_the_end") || w.equals("end")) {
return mapId.contains("end");
}
return !mapId.contains("nether") && !mapId.contains("end");
}
/** /**
* Whether a map renders the world a note was written in. * Whether a map renders the world a note was written in.
* *
@@ -85,8 +85,6 @@ 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;
@@ -127,21 +125,13 @@ 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, () -> chunkLoaders, getLogger(), blueMap = new BlueMapBridge(notes, 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
@@ -154,14 +144,12 @@ 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", "chunkloader")) { "perfil", "titulo")) {
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();
@@ -193,12 +181,6 @@ 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() {
@@ -213,11 +195,6 @@ 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;
@@ -243,11 +220,6 @@ 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;
@@ -695,14 +667,11 @@ 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 e na sua personalidade, em uma frase curta.", + " Dê as boas-vindas do seu jeito, em uma frase.",
aiBudget, persona); aiBudget);
}, Math.max(1, settings.joinDelaySeconds()) * 20L); }, Math.max(1, settings.joinDelaySeconds()) * 20L);
} }
@@ -840,60 +809,6 @@ 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
@@ -937,7 +852,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 = event.getKeepInventory() || Boolean.TRUE.equals( boolean keepInventory = 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));
@@ -958,14 +873,11 @@ 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 na sua personalidade, sem ofender de verdade.", + ". Comente com deboche, sem ofender.",
aiBudget, persona); aiBudget);
// 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());
File diff suppressed because it is too large Load Diff
@@ -1,97 +0,0 @@
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) {
}
}
}
@@ -1,85 +0,0 @@
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);
}
}
@@ -1,317 +0,0 @@
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);
}
}
}
@@ -1,639 +0,0 @@
package dev.marcospaulo.canalhandia;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockState;
import org.bukkit.block.CreatureSpawner;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Mob;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.CreatureSpawnEvent;
import org.bukkit.permissions.PermissionAttachmentInfo;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitTask;
import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
/**
* Manages active chunk loader anchors, ticket registration in Paper, and persistence in chunks.yml.
*/
final class ChunkLoaders {
private static final String PERM_LIMIT_PREFIX = "canalhandia.chunkloader.limite.";
private final Canalhandia plugin;
private final File file;
private final List<ChunkLoader> loaders = new ArrayList<>();
private final AtomicLong nextId = new AtomicLong(1);
private BukkitTask simulationTask;
private final ExecutorService io = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "canalhandia-chunkloaders-io");
t.setDaemon(true);
return t;
});
ChunkLoaders(Canalhandia plugin, File file) {
this.plugin = plugin;
this.file = file;
load();
}
void load() {
YamlConfiguration yaml = file.exists() ? YamlConfiguration.loadConfiguration(file) : null;
synchronized (loaders) {
loaders.clear();
if (yaml == null) {
return;
}
ConfigurationSection root = yaml.getConfigurationSection("loaders");
if (root == null) {
return;
}
long maxId = 0;
for (String key : root.getKeys(false)) {
try {
long id = Long.parseLong(key);
String name = root.getString(key + ".name", "");
boolean enabled = root.getBoolean(key + ".enabled", true);
long expiresAt = root.getLong(key + ".expires_at", 0L);
String ownerUuid = root.getString(key + ".owner_uuid", "");
String ownerName = root.getString(key + ".owner_name", "?");
String world = root.getString(key + ".world", "world");
int x = root.getInt(key + ".x");
int y = root.getInt(key + ".y");
int z = root.getInt(key + ".z");
int chunkX = root.getInt(key + ".chunk_x", x >> 4);
int chunkZ = root.getInt(key + ".chunk_z", z >> 4);
long createdAt = root.getLong(key + ".created_at", System.currentTimeMillis());
loaders.add(new ChunkLoader(id, name, enabled, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, createdAt));
if (id > maxId) {
maxId = id;
}
} catch (NumberFormatException ignored) {
}
}
nextId.set(maxId + 1);
}
}
/**
* Registers a new chunk loader and activates the ticket in the world.
*/
ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z) {
return add(ownerUuid, ownerName, world, x, y, z, "", defaultInitialExpiration());
}
ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z, String name) {
return add(ownerUuid, ownerName, world, x, y, z, name, defaultInitialExpiration());
}
private long defaultInitialExpiration() {
if (plugin != null && plugin.settings() != null && plugin.settings().chunkLoaderRequiresTime()) {
double hours = plugin.settings().chunkLoaderInitialHours();
if (hours > 0) {
return System.currentTimeMillis() + (long) (hours * 3600_000L);
}
}
return 0L;
}
ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z, String name, long expiresAt) {
int chunkX = x >> 4;
int chunkZ = z >> 4;
long id = nextId.getAndIncrement();
ChunkLoader loader = new ChunkLoader(id, name == null ? "" : name.trim(), true, expiresAt, ownerUuid, ownerName, world, x, y, z, chunkX, chunkZ, System.currentTimeMillis());
synchronized (loaders) {
loaders.add(loader);
}
addTicket(loader);
save();
return loader;
}
/**
* Adds duration (in milliseconds) to a chunk loader's expiration timer.
*/
boolean addTime(long id, long millisToAdd) {
ChunkLoader updated = null;
synchronized (loaders) {
for (int i = 0; i < loaders.size(); i++) {
ChunkLoader curr = loaders.get(i);
if (curr.id() == id) {
long now = System.currentTimeMillis();
long base = (curr.expiresAt() > now) ? curr.expiresAt() : now;
long newExpires = base + millisToAdd;
updated = curr.withExpiresAt(newExpires).withEnabled(true);
loaders.set(i, updated);
break;
}
}
}
if (updated != null) {
addTicket(updated);
save();
return true;
}
return false;
}
/**
* Sets a specific expiration timestamp (or 0 for permanent) for a chunk loader.
*/
boolean setExpiresAt(long id, long expiresAt) {
ChunkLoader updated = null;
synchronized (loaders) {
for (int i = 0; i < loaders.size(); i++) {
ChunkLoader curr = loaders.get(i);
if (curr.id() == id) {
updated = curr.withExpiresAt(expiresAt).withEnabled(true);
loaders.set(i, updated);
break;
}
}
}
if (updated != null) {
addTicket(updated);
save();
return true;
}
return false;
}
/**
* Renames an existing chunk loader.
*/
boolean rename(long id, String newName) {
synchronized (loaders) {
for (int i = 0; i < loaders.size(); i++) {
ChunkLoader curr = loaders.get(i);
if (curr.id() == id) {
loaders.set(i, curr.withName(newName));
save();
return true;
}
}
}
return false;
}
/**
* Enables or disables (pauses) a chunk loader without destroying the block.
*/
boolean setEnabled(long id, boolean enabled) {
ChunkLoader oldLoader = null;
ChunkLoader updated = null;
synchronized (loaders) {
for (int i = 0; i < loaders.size(); i++) {
ChunkLoader curr = loaders.get(i);
if (curr.id() == id) {
oldLoader = curr;
updated = curr.withEnabled(enabled);
loaders.set(i, updated);
break;
}
}
}
if (oldLoader != null && updated != null) {
if (enabled) {
addTicket(updated);
} else {
removeTicket(oldLoader);
}
save();
return true;
}
return false;
}
/**
* Finds a chunk loader by its numeric ID (with '#' prefix or plain ID) or custom name.
* Custom names take precedence over raw numeric IDs to avoid shadowing named loaders.
*/
ChunkLoader find(String query) {
if (query == null || query.isBlank()) {
return null;
}
String trimmed = query.trim();
if (trimmed.startsWith("#")) {
try {
long id = Long.parseLong(trimmed.substring(1));
ChunkLoader loader = byId(id);
if (loader != null) {
return loader;
}
} catch (NumberFormatException ignored) {
}
}
synchronized (loaders) {
for (ChunkLoader loader : loaders) {
if (loader.name() != null && loader.name().equalsIgnoreCase(trimmed)) {
return loader;
}
}
}
try {
long id = Long.parseLong(trimmed);
ChunkLoader loader = byId(id);
if (loader != null) {
return loader;
}
} catch (NumberFormatException ignored) {
}
return null;
}
/**
* Removes a chunk loader by its unique ID and releases the chunk ticket.
*/
boolean remove(long id) {
ChunkLoader removed = null;
synchronized (loaders) {
for (int i = 0; i < loaders.size(); i++) {
if (loaders.get(i).id() == id) {
removed = loaders.remove(i);
break;
}
}
}
if (removed != null) {
removeTicket(removed);
save();
return true;
}
return false;
}
ChunkLoader byId(long id) {
synchronized (loaders) {
for (ChunkLoader loader : loaders) {
if (loader.id() == id) {
return loader;
}
}
return null;
}
}
ChunkLoader byChunk(String world, int chunkX, int chunkZ) {
synchronized (loaders) {
for (ChunkLoader loader : loaders) {
if (loader.world().equalsIgnoreCase(world) && loader.chunkX() == chunkX && loader.chunkZ() == chunkZ) {
return loader;
}
}
return null;
}
}
ChunkLoader byLocation(String world, int x, int y, int z) {
synchronized (loaders) {
for (ChunkLoader loader : loaders) {
if (loader.world().equalsIgnoreCase(world) && loader.x() == x && loader.y() == y && loader.z() == z) {
return loader;
}
}
return null;
}
}
List<ChunkLoader> byOwner(String ownerUuid) {
List<ChunkLoader> out = new ArrayList<>();
synchronized (loaders) {
for (ChunkLoader loader : loaders) {
if (loader.ownerUuid().equalsIgnoreCase(ownerUuid)) {
out.add(loader);
}
}
}
return Collections.unmodifiableList(out);
}
List<ChunkLoader> all() {
synchronized (loaders) {
return Collections.unmodifiableList(new ArrayList<>(loaders));
}
}
int size() {
synchronized (loaders) {
return loaders.size();
}
}
/**
* Computes the maximum chunk loaders allowed for a player based on LuckPerms permissions.
*/
static int playerLimit(Player player, int defaultLimit) {
if (player == null) {
return 0;
}
if (player.hasPermission("canalhandia.admin") || player.hasPermission("canalhandia.chunkloader.admin")) {
return Integer.MAX_VALUE;
}
if (!player.hasPermission("canalhandia.chunkloader")) {
return 0;
}
int max = -1;
for (PermissionAttachmentInfo info : player.getEffectivePermissions()) {
String perm = info.getPermission().toLowerCase(Locale.ROOT);
if (info.getValue() && perm.startsWith(PERM_LIMIT_PREFIX)) {
String valStr = perm.substring(PERM_LIMIT_PREFIX.length());
try {
int val = Integer.parseInt(valStr);
if (val > max) {
max = val;
}
} catch (NumberFormatException ignored) {
}
}
}
return max >= 0 ? max : defaultLimit;
}
void loadAllTickets() {
synchronized (loaders) {
for (ChunkLoader loader : loaders) {
if (loader.enabled()) {
addTicket(loader);
}
}
}
}
void unloadAllTickets() {
synchronized (loaders) {
for (ChunkLoader loader : loaders) {
removeTicket(loader);
}
}
}
void startSimulation() {
if (plugin == null || simulationTask != null) {
return;
}
simulationTask = Bukkit.getScheduler().runTaskTimer(plugin, this::tickMobSimulation, 20L, 20L);
}
void stopSimulation() {
if (simulationTask != null) {
simulationTask.cancel();
simulationTask = null;
}
}
void tickMobSimulation() {
if (plugin == null) {
return;
}
List<ChunkLoader> currentLoaders;
synchronized (loaders) {
currentLoaders = new ArrayList<>(loaders);
}
for (ChunkLoader loader : currentLoaders) {
if (loader.isExpired() && loader.enabled()) {
setEnabled(loader.id(), false);
continue;
}
if (!loader.enabled()) {
continue;
}
try {
World w = Bukkit.getWorld(loader.world());
if (w == null || !w.isChunkLoaded(loader.chunkX(), loader.chunkZ())) {
continue;
}
Chunk chunk = w.getChunkAt(loader.chunkX(), loader.chunkZ());
boolean spawnersActive = plugin.settings() == null || plugin.settings().chunkLoaderSpawnersActive();
boolean monsterSpawning = plugin.settings() == null || plugin.settings().chunkLoaderMonsterSpawning();
int monsterCap = plugin.settings() != null ? plugin.settings().chunkLoaderMonsterCap() : 20;
// 1. Keep mob spawners (dungeon / blaze / skeleton cages) active if enabled
if (spawnersActive) {
for (BlockState state : chunk.getTileEntities()) {
if (state instanceof CreatureSpawner spawner) {
if (spawner.getRequiredPlayerRange() < 1024) {
spawner.setRequiredPlayerRange(2048);
spawner.update(true, false);
}
}
}
}
// 2. Simulate natural mob spawning (dark platforms / slime / nether farms) if enabled
if (monsterSpawning) {
long mobCount = 0;
for (Entity entity : chunk.getEntities()) {
if (entity instanceof Mob) {
mobCount++;
}
}
if (mobCount < monsterCap) {
simulateNaturalSpawning(w, loader, chunk);
}
}
} catch (Exception ignored) {
}
}
}
private void simulateNaturalSpawning(World w, ChunkLoader loader, Chunk chunk) {
int baseX = loader.chunkX() << 4;
int baseZ = loader.chunkZ() << 4;
ThreadLocalRandom rnd = ThreadLocalRandom.current();
for (int i = 0; i < 4; i++) {
int rx = baseX + rnd.nextInt(16);
int rz = baseZ + rnd.nextInt(16);
int topY = Math.max(w.getMinHeight() + 2, Math.min(w.getMaxHeight() - 2, loader.y() + rnd.nextInt(-24, 25)));
Block ground = w.getBlockAt(rx, topY - 1, rz);
Block space = w.getBlockAt(rx, topY, rz);
Block spaceAbove = w.getBlockAt(rx, topY + 1, rz);
if (!ground.getType().isSolid() || ground.isLiquid() || ground.getType() == Material.LAVA || ground.getType() == Material.WATER) {
continue;
}
if (!space.getType().isAir()) {
continue;
}
boolean isAirAbove = spaceAbove.getType().isAir();
boolean isTrapdoorAbove = isTrapdoor(spaceAbove.getType());
if (!isAirAbove && !isTrapdoorAbove) {
continue;
}
int light = space.getLightLevel();
EntityType toSpawn;
if (isTrapdoorAbove) {
if (light > 0) {
continue;
}
toSpawn = EntityType.CREEPER;
} else {
toSpawn = pickEntityType(w, space, light);
}
if (toSpawn == null) {
continue;
}
try {
Entity spawned = w.spawnEntity(space.getLocation().add(0.5, 0, 0.5), toSpawn, CreatureSpawnEvent.SpawnReason.NATURAL);
if (spawned instanceof Mob mob) {
mob.setRemoveWhenFarAway(false);
}
} catch (Exception ignored) {
}
}
}
static boolean isTrapdoor(Material mat) {
if (mat == null) return false;
return mat.name().endsWith("_TRAPDOOR");
}
static EntityType pickEntityType(World w, Block space, int light) {
boolean isSlime = false;
try {
isSlime = space.getChunk().isSlimeChunk();
} catch (Exception ignored) {
}
return pickEntityType(w.getEnvironment(), isSlime, space.getY(), light, ThreadLocalRandom.current().nextInt(100));
}
static EntityType pickEntityType(World.Environment env, boolean isSlimeChunk, int y, int light, int roll) {
if (env == World.Environment.NETHER) {
if (light > 11) return null;
int r = Math.floorMod(roll, 100);
if (r < 45) return EntityType.ZOMBIFIED_PIGLIN;
if (r < 65) return EntityType.WITHER_SKELETON;
if (r < 85) return EntityType.BLAZE;
return EntityType.MAGMA_CUBE;
}
if (env == World.Environment.THE_END) {
return EntityType.ENDERMAN;
}
if (isSlimeChunk && y < 40 && light <= 7 && roll % 3 == 0) {
return EntityType.SLIME;
}
if (light > 0) {
return null;
}
int r = Math.floorMod(roll, 100);
if (r < 35) return EntityType.ZOMBIE;
if (r < 65) return EntityType.SKELETON;
if (r < 85) return EntityType.CREEPER;
if (r < 95) return EntityType.SPIDER;
return EntityType.WITCH;
}
private void addTicket(ChunkLoader loader) {
if (plugin == null) {
return;
}
try {
World w = Bukkit.getWorld(loader.world());
if (w != null) {
w.setChunkForceLoaded(loader.chunkX(), loader.chunkZ(), true);
w.addPluginChunkTicket(loader.chunkX(), loader.chunkZ(), plugin);
}
} catch (Exception ignored) {
}
}
private void removeTicket(ChunkLoader loader) {
if (plugin == null) {
return;
}
try {
World w = Bukkit.getWorld(loader.world());
if (w != null) {
w.removePluginChunkTicket(loader.chunkX(), loader.chunkZ(), plugin);
w.setChunkForceLoaded(loader.chunkX(), loader.chunkZ(), false);
if (w.isChunkLoaded(loader.chunkX(), loader.chunkZ())) {
Chunk chunk = w.getChunkAt(loader.chunkX(), loader.chunkZ());
for (BlockState state : chunk.getTileEntities()) {
if (state instanceof CreatureSpawner spawner && spawner.getRequiredPlayerRange() > 16) {
spawner.setRequiredPlayerRange(16);
spawner.update(true, false);
}
}
}
}
} catch (Exception ignored) {
}
}
private YamlConfiguration buildYaml() {
YamlConfiguration yaml = new YamlConfiguration();
synchronized (loaders) {
for (ChunkLoader l : loaders) {
String key = "loaders." + l.id();
yaml.set(key + ".name", l.name());
yaml.set(key + ".enabled", l.enabled());
yaml.set(key + ".expires_at", l.expiresAt());
yaml.set(key + ".owner_uuid", l.ownerUuid());
yaml.set(key + ".owner_name", l.ownerName());
yaml.set(key + ".world", l.world());
yaml.set(key + ".x", l.x());
yaml.set(key + ".y", l.y());
yaml.set(key + ".z", l.z());
yaml.set(key + ".chunk_x", l.chunkX());
yaml.set(key + ".chunk_z", l.chunkZ());
yaml.set(key + ".created_at", l.createdAt());
}
}
return yaml;
}
private void save() {
YamlConfiguration yaml = buildYaml();
io.execute(() -> {
try {
yaml.save(file);
} catch (Exception ignored) {
}
});
}
void flush() {
try {
io.submit(() -> {}).get(2, TimeUnit.SECONDS);
} catch (Exception ignored) {
}
}
void close() {
flush();
io.shutdown();
}
}
@@ -14,9 +14,7 @@ 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,43 +31,20 @@ 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;
@@ -1,7 +1,5 @@
package dev.marcospaulo.canalhandia; package dev.marcospaulo.canalhandia;
import net.kyori.adventure.text.format.NamedTextColor;
import java.util.Locale; import java.util.Locale;
/** /**
@@ -14,8 +12,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>} or per-player with * <p>Switchable live with {@code /ia personalidade <nome>}; no restart, because
* {@code /ia persona <nome>}; no restart needed. * {@link Settings#aiPersona()} is read on every question.
*/ */
enum Persona { enum Persona {
@@ -23,15 +21,13 @@ 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", "IA", "Assistente direto, sem personalidade marcante.", NEUTRO("neutro", "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", "Zoeiro", "Veterano brincalhão que zoa os jogadores (padrão).", 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. "
@@ -47,8 +43,7 @@ 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", "Amigão", "Simpático e paciente, brinca pouco.", AMIGAO("amigao", "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 "
@@ -57,8 +52,7 @@ 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", "Seco", "Curto, seco e sarcástico.", 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 "
@@ -67,39 +61,11 @@ enum Persona {
/** /**
* In character as an ancient villager. Pure flavour; still answers. * In character as an ancient villager. Pure flavour; still answers.
*/ */
ALDEAO("aldeao", "Aldeão", "Fala como um aldeão antigo e misterioso.", ALDEAO("aldeao", "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}.
@@ -120,16 +86,12 @@ 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 displayName, String description, NamedTextColor tagColor, String instructions) { Persona(String key, String description, 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;
} }
@@ -137,18 +99,6 @@ 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;
} }
@@ -1,343 +0,0 @@
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,90 +579,6 @@ final class Settings {
set("categorias." + category.key(), enabled); set("categorias." + category.key(), enabled);
} }
// --- chunkloader --------------------------------------------------------
int chunkLoaderDefaultLimit() {
return Math.max(0, plugin.getConfig().getInt("chunkloader.limite-padrao", 1));
}
void chunkLoaderDefaultLimit(int limit) {
set("chunkloader.limite-padrao", Math.max(0, limit));
}
boolean chunkLoaderBlueMap() {
return plugin.getConfig().getBoolean("chunkloader.bluemap", true);
}
void chunkLoaderBlueMap(boolean enabled) {
set("chunkloader.bluemap", enabled);
}
boolean chunkLoaderSpawnersActive() {
return plugin.getConfig().getBoolean("chunkloader.simulacao.gaiolas-ativas", true);
}
void chunkLoaderSpawnersActive(boolean active) {
set("chunkloader.simulacao.gaiolas-ativas", active);
}
boolean chunkLoaderMonsterSpawning() {
return plugin.getConfig().getBoolean("chunkloader.simulacao.spawn-monstros", true);
}
void chunkLoaderMonsterSpawning(boolean spawning) {
set("chunkloader.simulacao.spawn-monstros", spawning);
}
int chunkLoaderMonsterCap() {
return Math.max(1, plugin.getConfig().getInt("chunkloader.simulacao.limite-monstros-chunk", 20));
}
void chunkLoaderMonsterCap(int cap) {
set("chunkloader.simulacao.limite-monstros-chunk", Math.max(1, cap));
}
boolean chunkLoaderPassiveEntities() {
return plugin.getConfig().getBoolean("chunkloader.simulacao.tick-entidades-passivas", true);
}
void chunkLoaderPassiveEntities(boolean active) {
set("chunkloader.simulacao.tick-entidades-passivas", active);
}
boolean chunkLoaderRequiresTime() {
return plugin.getConfig().getBoolean("chunkloader.combustivel.requer-tempo", true);
}
void chunkLoaderRequiresTime(boolean required) {
set("chunkloader.combustivel.requer-tempo", required);
}
double chunkLoaderInitialHours() {
return Math.max(0.0, plugin.getConfig().getDouble("chunkloader.combustivel.tempo-inicial-horas", 24.0));
}
void chunkLoaderInitialHours(double hours) {
set("chunkloader.combustivel.tempo-inicial-horas", Math.max(0.0, hours));
}
// --- salvavoid ----------------------------------------------------------
int voidProtectionRadius() {
return Math.max(1, plugin.getConfig().getInt("salvavoid.raio-busca", 32));
}
void voidProtectionRadius(int radius) {
set("salvavoid.raio-busca", Math.max(1, radius));
}
boolean voidProtectionKeepXp() {
return plugin.getConfig().getBoolean("salvavoid.preservar-xp", true);
}
void voidProtectionKeepXp(boolean keep) {
set("salvavoid.preservar-xp", keep);
}
// --- plumbing ----------------------------------------------------------- // --- plumbing -----------------------------------------------------------
private void set(String path, Object value) { private void set(String path, Object value) {
@@ -49,12 +49,6 @@ 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.",
@@ -97,7 +91,6 @@ 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;
}; };
@@ -139,58 +132,6 @@ 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) {
@@ -1,273 +0,0 @@
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;
}
}
@@ -1,187 +0,0 @@
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);
}
}
-40
View File
@@ -344,43 +344,3 @@ mortes:
- "BONE | Osso da Sorte | Um ossinho pra você, campeão." - "BONE | Osso da Sorte | Um ossinho pra você, campeão."
- "COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora." - "COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora."
- "ROTTEN_FLESH | Carne Podre | É o que tinha sobrado na despensa." - "ROTTEN_FLESH | Carne Podre | É o que tinha sobrado na despensa."
# --- Âncoras de Chunk (Chunk Loaders) ----------------------------------------
chunkloader:
# Limite máximo de âncoras ativas por jogador por padrão.
# Pode ser alterado por jogador com /chunkloader limite <jogador> <qtd>.
limite-padrao: 1
# Exibe os marcadores de âncoras ativas no BlueMap.
bluemap: true
# Configurações de simulação de entidades na chunk carregada:
simulacao:
# Se true, mantém ativas gaiolas de monstros (spawners de blaze, esqueletos, etc.)
# mesmo sem jogadores por perto.
gaiolas-ativas: true
# Se true, simula o surgimento natural de monstros em plataformas escuras,
# slime chunks e fortalezas do nether. Se false, apenas entidades existentes funcionam.
spawn-monstros: true
# Teto máximo de monstros na chunk antes de pausar o spawn natural (evita acúmulo de lag).
limite-monstros-chunk: 20
# Se true, mantém animais, aldeões, ferro-golems e colheitas sendo processados na chunk.
tick-entidades-passivas: true
# Sistema de combustível e tempo de expiração:
combustivel:
# Se true, âncoras requerem abastecimento com itens e têm tempo de expiração.
# Se false, todas as âncoras colocadas são permanentes por padrão.
requer-tempo: true
# Tempo inicial padrão dado ao criar e colocar uma nova âncora (em horas).
tempo-inicial-horas: 24
# --- Salva-Void (Resgate de Morte no Vazio) -----------------------------------
salvavoid:
# Raio máximo de busca em blocos a partir de onde o jogador caiu para achar
# terreno seguro e colocar o baú com os itens resgatados.
raio-busca: 32
+1 -112
View File
@@ -4,115 +4,4 @@
# Mourning (Canalhandia.onDeath) — F button under each death message. # Mourning (Canalhandia.onDeath) — F button under each death message.
canalhandia.morte.luto.prestar=pay respects for {0} canalhandia.morte.luto.prestar=pay respects for {0}
canalhandia.morte.luto.digitar=type /f to pay respects for {0} canalhandia.morte.luto.digitar=type /f to pay respects for {0}
canalhandia.morte.luto.resumo={0} paid respects for {1}. 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
+3 -116
View File
@@ -1,120 +1,7 @@
# Canalhandia — português (fonte de verdade). Padrões MessageFormat: {0}, {1}, ... # Canalhandia — portugues (fonte de verdade). Padroes MessageFormat: {0}, {1}, ...
# NUNCA altere os placeholders {0}/{1} nem as tags MiniMessage <...>. # NUNCA altere os placeholders {0}/{1} nem as tags MiniMessage <...>.
# Luto (Canalhandia.onDeath) — botão F sob cada mensagem de morte. # Luto (Canalhandia.onDeath) — botao F sob cada mensagem de morte.
canalhandia.morte.luto.prestar=prestar luto por {0} canalhandia.morte.luto.prestar=prestar luto por {0}
canalhandia.morte.luto.digitar=digite /f para prestar luto por {0} canalhandia.morte.luto.digitar=digite /f para prestar luto por {0}
canalhandia.morte.luto.resumo={0} prestaram luto por {1}. 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,20 +98,10 @@ 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,47 +0,0 @@
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]"));
}
}
@@ -1,231 +0,0 @@
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();
}
}
@@ -1,82 +0,0 @@
package dev.marcospaulo.canalhandia;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.nio.file.Path;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class EventTest {
@TempDir
Path tempDir;
private PlayerMemory memory;
@BeforeEach
void setUp() {
File file = tempDir.resolve("ia-memoria.yml").toFile();
memory = new PlayerMemory(file);
}
@Test
void eventResolvesPlayerCustomPersonaWhenSet() {
UUID id = UUID.randomUUID();
Persona defaultPersona = Persona.ZOEIRO;
// Default fallback
assertEquals(Persona.ZOEIRO, memory.persona(id, defaultPersona));
// Player custom choice
memory.setPersona(id, "Marcos", Persona.JUDITE);
assertEquals(Persona.JUDITE, memory.persona(id, defaultPersona));
// Switch to Narrador
memory.setPersona(id, "Marcos", Persona.NARRADOR);
assertEquals(Persona.NARRADOR, memory.persona(id, defaultPersona));
}
@Test
void joinWelcomePromptContainsPlayerAndStats() {
String name = "Marcos";
String stats = "100 diamantes minerados, 2 mortes";
String prompt = "O jogador " + name + " acabou de entrar no servidor."
+ " Estatísticas dele: " + stats
+ " Dê as boas-vindas do seu jeito e na sua personalidade, em uma frase curta.";
assertTrue(prompt.contains("Marcos"));
assertTrue(prompt.contains("100 diamantes"));
assertTrue(prompt.contains("personalidade"));
}
@Test
void deathStreakPromptContainsCountAndFlavor() {
String name = "Marcos";
int count = 4;
String flavor = "abraçou um Creeper";
String prompt = "O jogador " + name + " morreu " + count
+ " vezes seguidas em poucos minutos. A última foi assim: " + flavor
+ ". Comente na sua personalidade, sem ofender de verdade.";
assertTrue(prompt.contains("morreu 4 vezes seguidas"));
assertTrue(prompt.contains("abraçou um Creeper"));
}
@Test
void achievementUnlockPromptContainsTitleAndDescription() {
String title = "Mestre dos Diamantes";
String desc = "Minerou 1000 diamantes";
String prompt = "O jogador Marcos desbloqueou a conquista \""
+ title + "\" (" + desc
+ "). Faça um breve comentário na sua personalidade.";
assertTrue(prompt.contains(title));
assertTrue(prompt.contains(desc));
}
}
@@ -122,31 +122,6 @@ 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
@@ -1,163 +0,0 @@
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,14 +25,10 @@ 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");
Achievement cacador = Achievement.byKey("cacador"); List<Achievement> earned = List.of(pedreiro, veterano);
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
@@ -1,78 +0,0 @@
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"));
}
}
@@ -1,84 +0,0 @@
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()));
}
}
@@ -1,55 +0,0 @@
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]);
}
}