Compare commits
17 Commits
ee0d93385d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a21d3cfd5 | |||
| 2a63a04e4f | |||
| f35e4a5bff | |||
| 45f129df56 | |||
| aa56f385f8 | |||
| 60989cd72f | |||
| 6b2a73678f | |||
| 8ddc22409e | |||
| bc7a88b9e2 | |||
| 8a34565f3c | |||
| f24af7ab08 | |||
| b6e508c8c9 | |||
| cc5c983e41 | |||
| 07e5bb6018 | |||
| dfb84047ec | |||
| 8821e6d2c0 | |||
| da4a1777a7 |
@@ -0,0 +1,53 @@
|
||||
## What
|
||||
|
||||
<!-- 1-2 sentences. What does this PR change? -->
|
||||
|
||||
## Why
|
||||
|
||||
<!-- Link the issue, design doc, or chat thread. Without a "why" the reviewer
|
||||
has to guess whether the change is wanted at all. -->
|
||||
|
||||
Refs: #<!-- issue or N/A -->
|
||||
|
||||
## How to test
|
||||
|
||||
```bash
|
||||
# Commands the reviewer can run to reproduce / verify:
|
||||
docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \
|
||||
maven:3.9-eclipse-temurin-25 mvn -B test
|
||||
```
|
||||
|
||||
<!-- Manual test steps (if any): load a save, join a server, trigger a
|
||||
death in the void, etc. State what you observed. -->
|
||||
|
||||
<!-- Server console output if relevant (translatable components render
|
||||
in English there — that's normal). -->
|
||||
|
||||
## Risk
|
||||
|
||||
<!-- Which surface is touched? chat-only / item-loss / chunk-load / permissions.
|
||||
Is a rollback plan needed? Does it need a config.yml default? -->
|
||||
|
||||
- [ ] chat message flow (immutable after send)
|
||||
- [ ] pt-BR singular-form rule (`5.966 blocos de Pedra`, not `5.966 Pedras`)
|
||||
- [ ] Bedrock equivalent (`comando:` typed fallback, `texto:` ASCII label)
|
||||
- [ ] stats path (`<world>/players/stats`, not `<world>/stats`)
|
||||
- [ ] reaction late-window (`reacao-validade-minutos`)
|
||||
- [ ] Bukkit main thread (no async chunk loads in event handlers)
|
||||
- [ ] new `comando:` added to `plugin.yml`
|
||||
- [ ] new permission declared in `plugin.yml`
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `mvn -B test` passes locally (JDK 25)
|
||||
- [ ] new behaviour has a test (`src/test/java/...`)
|
||||
- [ ] no hand edits to generated sources (none in this repo)
|
||||
- [ ] PR title is conventional-commit style
|
||||
- [ ] branch is up to date with `main`
|
||||
|
||||
## AI review
|
||||
|
||||
Label this PR **`AI-REVIEW`** to trigger `pragent`. Add **`AI-USAGE`** for a
|
||||
token/cost report on the review. The bot reads `.pr-review.json` from `main`
|
||||
+ a static `architecture.md` from Nexus raw-hosted — so house rules (chat
|
||||
immutability, singular-form, Bedrock fallback, JDK 25) are baked in.
|
||||
+23
-14
@@ -1,21 +1,30 @@
|
||||
{
|
||||
"languages": [
|
||||
"java",
|
||||
"yaml",
|
||||
"markdown"
|
||||
],
|
||||
"focus": [
|
||||
"thread-safety",
|
||||
"paper-chunk-ticketing",
|
||||
"item-loss-prevention",
|
||||
"resource-cleanup",
|
||||
"performance-and-chunk-loading",
|
||||
"null-safety-and-unit-tests"
|
||||
"correctness",
|
||||
"security",
|
||||
"performance"
|
||||
],
|
||||
"exclude_paths": [
|
||||
"target/**",
|
||||
"*.bak*",
|
||||
"docs/**"
|
||||
"*.class"
|
||||
],
|
||||
"instructions": "Canalhandia is a Minecraft Paper 1.21.x server plugin written in modern Java 25. Enforce these core invariants:\n1. Thread Safety: Bukkit API, World, Entity, and Inventory mutations MUST run on the main server thread. Async threads only do pure calculation or async file I/O.\n2. Item & Inventory Safety: Never discard player items. Always handle full-inventory overflow by dropping excess items at the player's location. On multi-step container placement (e.g. chests), roll back blocks if not all items fit.\n3. Lifecycle & Cleanup: All registered chunk tickets, recipes, schedulers, and I/O executors must be cleanly flushed and unloaded in onDisable() and module toggles.\n4. Chunk Loading: Never trigger synchronous chunk generation or loading inside event handlers. Always check world.isChunkLoaded() before querying blocks.\n5. Test Coverage: All domain logic, coordinates math, parsers, and pure helpers must have corresponding JUnit tests in src/test/java."
|
||||
"languages": [
|
||||
"java"
|
||||
],
|
||||
"style": "balanced",
|
||||
"require_tests": true,
|
||||
"exclude_tests": false,
|
||||
"max_findings": 15,
|
||||
"severity_threshold": "low",
|
||||
"patterns": {
|
||||
"deny": [
|
||||
"**/README.md",
|
||||
"**/*.md"
|
||||
]
|
||||
},
|
||||
"cost_target": "claude-sonnet-5",
|
||||
"additional_context_urls": [
|
||||
"http://nexus-service.nexus.svc.cluster.local:8081/repository/raw-hosted/canalhandia/architecture.md"
|
||||
],
|
||||
"instructions": "Minecraft plugin (Paper 26.2, pt-BR, JDK 25 build). Chat-only — never touch gameplay. Watch thread-safety on event handlers (PlayerDeathEvent, PlayerInteractEvent) — the Bukkit main thread is single-threaded but async chunks/events cross it. Avoid main-thread I/O; defer expensive scans (chunk loading, spiral search) to scheduled tasks or async paths. Flag mutable shared state across listener invocations. Hard constraints: chat messages are immutable after send (counts baked into buttons freeze at send time); names go out as translatable components so the singular-form rule applies (number never agrees with the noun); Geyser/Bedrock cannot click and cannot show emoji (every click has a typed fallback); vanilla statistics are the only data source (offline path is <world>/players/stats/<uuid>.json, NOT <world>/stats); reactions keep counting late (reacao-validade-minutos); Floodgate is optional runtime dep. Flag: real bugs, missing persistence of new settings, comando/permission not in plugin.yml, breaking Bedrock equivalent invariant, removing the frozen-at-send assumption, violating singular-form rule, missing Stats.resolve() on renames."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Contributing — Canalhandia (Canalhandia Minecraft plugin)
|
||||
|
||||
Canalhandia is a Minecraft Paper plugin that adds PT-BR chat games, item
|
||||
protection, and other in-game commands for the Canalhandia server. This repo
|
||||
holds the plugin source. The companion server config lives at
|
||||
`gitea_admin/canalhandia-config`.
|
||||
|
||||
## Before you open a PR
|
||||
|
||||
1. **Fill in the PR template** (`.gitea/pull_request_template.md`). The
|
||||
*Risk* checklist is the part pragent scores you on — check the boxes that
|
||||
apply, delete the ones that don't.
|
||||
2. **Update the static context** if your change introduces new house rules.
|
||||
See `## Adding house rules` below.
|
||||
3. **Run the gates locally** (see *Test commands*). CI is a single
|
||||
`mvn -B test` against JDK 25 — there's no other infra in this repo.
|
||||
|
||||
## Test commands
|
||||
|
||||
```bash
|
||||
# Run the test suite (JDK 25 required). Same command CI uses:
|
||||
mvn -B test
|
||||
|
||||
# Compile only (faster):
|
||||
mvn -B -DskipTests package
|
||||
|
||||
# Integration: copy the resulting jar into a test Paper server's plugins/
|
||||
# directory, start the server, exercise the affected commands. The chat
|
||||
# and stats paths are NOT covered by unit tests — they need a live server.
|
||||
```
|
||||
|
||||
### What counts as "tested"
|
||||
|
||||
For pragent (and reviewers) to consider a change tested, the PR must show:
|
||||
|
||||
- [ ] `mvn -B test` output from your machine pasted in the PR, **OR** a CI
|
||||
run URL.
|
||||
- [ ] A new behaviour has a JUnit test in `src/test/java/...` that exercises
|
||||
the new code path. New `comando:` blocks need at least one test that
|
||||
sends a fake event and asserts the message format.
|
||||
- [ ] If the change touches the chat immutability rule, the singular-form
|
||||
rule, or the Bedrock fallback, a manual test on a Paper server is
|
||||
required **and** a one-line note pasted in the PR.
|
||||
- [ ] If the change touches `<world>/players/stats`, you must show the
|
||||
file path on a running server (`ls <world>/players/stats/*.json`)
|
||||
and confirm the stats file is updated, not the wrong path.
|
||||
|
||||
### Out of scope for tests
|
||||
|
||||
- Performance / load tests (none in this repo).
|
||||
- E2E against the live server (manual; verify in `canalhandia-config` repo).
|
||||
- UI / browser tests (no web surface).
|
||||
|
||||
## Commit messages
|
||||
|
||||
Conventional commits. Examples:
|
||||
|
||||
- `feat: add per-player cobblestone counter command`
|
||||
- `fix(void): keep inventory when dying in lava void`
|
||||
- `docs: update README build instructions`
|
||||
- `chore: bump pom parent to 1.4.2`
|
||||
|
||||
## Branch names
|
||||
|
||||
`<type>/<short-kebab-description>` matching commit type. Example:
|
||||
`fix/void-keep-inventory`.
|
||||
|
||||
## PR review
|
||||
|
||||
- The bot (`pragent`) reviews every PR labelled `AI-REVIEW`. It reads
|
||||
`.pr-review.json` from `main` + the static `architecture.md` from Nexus
|
||||
raw-hosted. Adding a new house rule? Update both.
|
||||
- Add the `AI-USAGE` label if you want the bot to also post a token /
|
||||
cost-equivalent report on the review.
|
||||
|
||||
## Adding house rules
|
||||
|
||||
If your PR changes how translatable components behave, how chat edits work,
|
||||
how stats are read, or how the Bedrock fallback is rendered, you must:
|
||||
|
||||
1. Update the relevant section in the static `architecture.md` (upload a new
|
||||
copy to Nexus raw-hosted at `canalhandia/architecture.md`).
|
||||
2. Bump `.pr-review.json:instructions` with a one-paragraph summary.
|
||||
3. Add a finding-checklist item to `.gitea/pull_request_template.md` *Risk*
|
||||
section so PR authors know to confirm the rule.
|
||||
|
||||
Bot does not detect house-rule drift automatically — these three updates
|
||||
together are the maintainer contract.
|
||||
|
||||
## Rollback
|
||||
|
||||
A single PR that bumps the plugin jar in `canalhandia-config` rolls back.
|
||||
Plugins are jar-swapped, no migrations.
|
||||
@@ -611,3 +611,4 @@ Statistic constants get renamed between Minecraft releases, so resolve them via
|
||||
`Stats.resolve("NEW_NAME", "OLD_NAME")` — a rename then degrades one curiosity
|
||||
instead of breaking the whole announcement.
|
||||
|
||||
|
||||
|
||||
@@ -161,6 +161,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
getServer().getPluginManager().registerEvents(this, this);
|
||||
getServer().getPluginManager().registerEvents(new TitleChatListener(this), this);
|
||||
getServer().getPluginManager().registerEvents(new ChunkLoaderListener(this), this);
|
||||
getServer().getPluginManager().registerEvents(new XpAndItemMergeListener(this), this);
|
||||
rescheduleTimer();
|
||||
rescheduleMilestones();
|
||||
|
||||
@@ -868,8 +869,6 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
int x = safeLoc.getBlockX();
|
||||
int y = safeLoc.getBlockY();
|
||||
int z = safeLoc.getBlockZ();
|
||||
pendingDeathCoords.put(player.getUniqueId(), new DeathCoords(
|
||||
x + " " + y + " " + z + " (" + safeLoc.getWorld().getName() + " - Baú do Vácuo)", false));
|
||||
getServer().getScheduler().runTaskLater(this, () -> {
|
||||
if (player.isOnline()) {
|
||||
player.sendMessage(Component.text("[Canalhandia] ", NamedTextColor.GOLD)
|
||||
@@ -886,9 +885,6 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
event.setKeepLevel(true);
|
||||
event.setDroppedExp(0);
|
||||
}
|
||||
pendingDeathCoords.put(player.getUniqueId(), new DeathCoords(
|
||||
player.getLocation().getBlockX() + " " + player.getLocation().getBlockY() + " " + player.getLocation().getBlockZ()
|
||||
+ " (" + player.getWorld().getName() + ")", true));
|
||||
getServer().getScheduler().runTaskLater(this, () -> {
|
||||
if (player.isOnline()) {
|
||||
player.sendMessage(Component.text("[Canalhandia] ", NamedTextColor.GOLD)
|
||||
@@ -910,7 +906,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
* {@code [F]} mourning row and never touches {@code deathMessage}, so both
|
||||
* fire on the same event without conflict.
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
@EventHandler
|
||||
public void onDeathComic(PlayerDeathEvent event) {
|
||||
if (!settings.moduleEnabled(Module.MORTES)) {
|
||||
return;
|
||||
|
||||
@@ -1530,8 +1530,10 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
String arg = String.join(" ", args);
|
||||
if (arg.equalsIgnoreCase("limpar") || arg.equalsIgnoreCase("nenhum")) {
|
||||
String arg = String.join(" ", args).trim();
|
||||
if (arg.equalsIgnoreCase("limpar") || arg.equalsIgnoreCase("nenhum")
|
||||
|| arg.equalsIgnoreCase("remover") || arg.equalsIgnoreCase("remove")
|
||||
|| arg.equalsIgnoreCase("clear")) {
|
||||
plugin.titles().clear(player.getUniqueId());
|
||||
Msg.ok(player, Lang.tr("canalhandia.cmd.titulo.removido"));
|
||||
return true;
|
||||
@@ -1546,20 +1548,37 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Matches typed text to an earned achievement by key or (case-insensitive) title. */
|
||||
/** Matches typed text to an earned achievement by key, title, or accent-normalized text. */
|
||||
static Achievement matchEarned(String text, List<Achievement> earned) {
|
||||
Achievement byKey = Achievement.byKey(text);
|
||||
if (text == null || text.isBlank() || earned == null || earned.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String clean = text.trim();
|
||||
Achievement byKey = Achievement.byKey(clean);
|
||||
if (byKey != null && earned.contains(byKey)) {
|
||||
return byKey;
|
||||
}
|
||||
for (Achievement achievement : earned) {
|
||||
if (achievement.title().equalsIgnoreCase(text.trim())) {
|
||||
if (achievement.title().equalsIgnoreCase(clean)) {
|
||||
return achievement;
|
||||
}
|
||||
}
|
||||
String normalizedClean = normalizeText(clean);
|
||||
for (Achievement achievement : earned) {
|
||||
if (normalizeText(achievement.title()).equalsIgnoreCase(normalizedClean)
|
||||
|| normalizeText(achievement.key()).equalsIgnoreCase(normalizedClean)) {
|
||||
return achievement;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String normalizeText(String input) {
|
||||
if (input == null) return "";
|
||||
return java.text.Normalizer.normalize(input, java.text.Normalizer.Form.NFD)
|
||||
.replaceAll("\\p{M}", "");
|
||||
}
|
||||
|
||||
private static String titlesList(List<Achievement> earned) {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (Achievement achievement : earned) {
|
||||
@@ -1975,6 +1994,10 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
||||
Msg.error(sender, "A âncora " + loader.displayName() + " já está " + (enabled ? "ativa" : "pausada") + ".");
|
||||
return;
|
||||
}
|
||||
if (enabled && loader.isExpired()) {
|
||||
Msg.error(sender, "A âncora " + loader.displayName() + " está expirada! Adicione tempo (/chunkloader tempo " + loader.id() + " <horas>) ou abasteça com combustível antes de reativar.");
|
||||
return;
|
||||
}
|
||||
plugin.chunkLoaders().setEnabled(loader.id(), enabled);
|
||||
plugin.blueMap().syncChunkLoaders();
|
||||
Msg.ok(sender, "Âncora " + loader.displayName() + " " + (enabled ? "ATIVADA." : "PAUSADA/DESATIVADA."));
|
||||
@@ -1998,16 +2021,20 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
||||
}
|
||||
try {
|
||||
double hours = Double.parseDouble(args[1]);
|
||||
if (hours <= 0 && isAdmin) {
|
||||
plugin.chunkLoaders().setExpiresAt(loader.id(), 0L); // permanent
|
||||
Msg.ok(sender, "Âncora " + loader.displayName() + " definida como PERMANENTE.");
|
||||
} else {
|
||||
long millis = (long) (hours * 3600_000L);
|
||||
plugin.chunkLoaders().addTime(loader.id(), millis);
|
||||
ChunkLoader updated = plugin.chunkLoaders().byId(loader.id());
|
||||
String time = (updated != null) ? updated.timeLeft() : "Permanente";
|
||||
Msg.ok(sender, "Adicionado " + hours + "h à âncora " + loader.displayName() + ". Tempo restante: " + time);
|
||||
if (hours <= 0) {
|
||||
if (isAdmin) {
|
||||
plugin.chunkLoaders().setExpiresAt(loader.id(), 0L); // permanent
|
||||
Msg.ok(sender, "Âncora " + loader.displayName() + " definida como PERMANENTE.");
|
||||
} else {
|
||||
Msg.error(sender, "A quantidade de horas deve ser maior que zero.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
long millis = (long) (hours * 3600_000L);
|
||||
plugin.chunkLoaders().addTime(loader.id(), millis);
|
||||
ChunkLoader updated = plugin.chunkLoaders().byId(loader.id());
|
||||
String time = (updated != null) ? updated.timeLeft() : "Permanente";
|
||||
Msg.ok(sender, "Adicionado " + hours + "h à âncora " + loader.displayName() + ". Tempo restante: " + time);
|
||||
plugin.blueMap().syncChunkLoaders();
|
||||
} catch (NumberFormatException e) {
|
||||
Msg.error(sender, "Horas inválidas: " + args[1]);
|
||||
@@ -2202,7 +2229,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
||||
}
|
||||
try {
|
||||
int newLimit = Math.max(0, Integer.parseInt(args[1]));
|
||||
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "lp user " + target.getUniqueId() + " permission set canalhandia.chunkloader.limite." + newLimit);
|
||||
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "lp user " + target.getName() + " permission set canalhandia.chunkloader.limite." + newLimit);
|
||||
Msg.ok(sender, "Limite de " + target.getName() + " definido para " + newLimit + " âncoras.");
|
||||
} catch (NumberFormatException e) {
|
||||
Msg.error(sender, "Quantidade inválida: " + args[1]);
|
||||
|
||||
@@ -209,6 +209,10 @@ final class ChunkLoaderListener implements Listener {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -300,17 +304,13 @@ final class ChunkLoaderListener implements Listener {
|
||||
if (!plugin.settings().moduleEnabled(Module.CHUNKLOADER)) {
|
||||
return;
|
||||
}
|
||||
CreatureSpawnEvent.SpawnReason reason = event.getSpawnReason();
|
||||
if (reason != CreatureSpawnEvent.SpawnReason.SPAWNER && reason != CreatureSpawnEvent.SpawnReason.CUSTOM) {
|
||||
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 && loader.enabled() && event.getEntity() instanceof Mob mob) {
|
||||
if (loader != null && event.getEntity() instanceof Mob mob) {
|
||||
mob.setRemoveWhenFarAway(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ final class ChunkLoaders {
|
||||
|
||||
private static final String PERM_LIMIT_PREFIX = "canalhandia.chunkloader.limite.";
|
||||
|
||||
private final Plugin plugin;
|
||||
private final Canalhandia plugin;
|
||||
private final File file;
|
||||
private final List<ChunkLoader> loaders = new ArrayList<>();
|
||||
private final AtomicLong nextId = new AtomicLong(1);
|
||||
@@ -48,7 +48,7 @@ final class ChunkLoaders {
|
||||
return t;
|
||||
});
|
||||
|
||||
ChunkLoaders(Plugin plugin, File file) {
|
||||
ChunkLoaders(Canalhandia plugin, File file) {
|
||||
this.plugin = plugin;
|
||||
this.file = file;
|
||||
load();
|
||||
@@ -97,11 +97,21 @@ final class ChunkLoaders {
|
||||
* 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, "", 0L);
|
||||
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, 0L);
|
||||
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) {
|
||||
@@ -214,20 +224,23 @@ final class ChunkLoaders {
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a chunk loader by its numeric ID (with or without '#') or custom name.
|
||||
* 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();
|
||||
try {
|
||||
long id = Long.parseLong(trimmed.startsWith("#") ? trimmed.substring(1) : trimmed);
|
||||
ChunkLoader loader = byId(id);
|
||||
if (loader != null) {
|
||||
return loader;
|
||||
if (trimmed.startsWith("#")) {
|
||||
try {
|
||||
long id = Long.parseLong(trimmed.substring(1));
|
||||
ChunkLoader loader = byId(id);
|
||||
if (loader != null) {
|
||||
return loader;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
|
||||
synchronized (loaders) {
|
||||
@@ -237,6 +250,15 @@ final class ChunkLoaders {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
long id = Long.parseLong(trimmed);
|
||||
ChunkLoader loader = byId(id);
|
||||
if (loader != null) {
|
||||
return loader;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -405,28 +427,34 @@ final class ChunkLoaders {
|
||||
}
|
||||
Chunk chunk = w.getChunkAt(loader.chunkX(), loader.chunkZ());
|
||||
|
||||
// 1. Keep mob spawners (dungeon / blaze / skeleton cages) active
|
||||
for (BlockState state : chunk.getTileEntities()) {
|
||||
if (state instanceof CreatureSpawner spawner) {
|
||||
if (spawner.getRequiredPlayerRange() < 1024) {
|
||||
spawner.setRequiredPlayerRange(2048);
|
||||
spawner.update(true, false);
|
||||
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)
|
||||
long mobCount = 0;
|
||||
for (Entity entity : chunk.getEntities()) {
|
||||
if (entity instanceof Mob) {
|
||||
mobCount++;
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
if (mobCount >= 20) {
|
||||
continue; // Respect chunk mob cap
|
||||
}
|
||||
|
||||
simulateNaturalSpawning(w, loader, chunk);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
@@ -437,7 +465,7 @@ final class ChunkLoaders {
|
||||
int baseZ = loader.chunkZ() << 4;
|
||||
ThreadLocalRandom rnd = ThreadLocalRandom.current();
|
||||
|
||||
for (int i = 0; i < 2; i++) {
|
||||
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)));
|
||||
@@ -446,15 +474,30 @@ final class ChunkLoaders {
|
||||
Block space = w.getBlockAt(rx, topY, rz);
|
||||
Block spaceAbove = w.getBlockAt(rx, topY + 1, rz);
|
||||
|
||||
if (!ground.getType().isSolid() || !space.getType().isAir() || !spaceAbove.getType().isAir()) {
|
||||
if (!ground.getType().isSolid() || ground.isLiquid() || ground.getType() == Material.LAVA || ground.getType() == Material.WATER) {
|
||||
continue;
|
||||
}
|
||||
if (ground.isLiquid() || ground.getType() == Material.LAVA || ground.getType() == Material.WATER) {
|
||||
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 = pickEntityType(w, space, light);
|
||||
EntityType toSpawn;
|
||||
if (isTrapdoorAbove) {
|
||||
if (light > 0) {
|
||||
continue;
|
||||
}
|
||||
toSpawn = EntityType.CREEPER;
|
||||
} else {
|
||||
toSpawn = pickEntityType(w, space, light);
|
||||
}
|
||||
|
||||
if (toSpawn == null) {
|
||||
continue;
|
||||
}
|
||||
@@ -469,6 +512,11 @@ final class ChunkLoaders {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -531,6 +579,15 @@ final class ChunkLoaders {
|
||||
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) {
|
||||
}
|
||||
|
||||
@@ -597,6 +597,54 @@ final class Settings {
|
||||
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() {
|
||||
|
||||
@@ -140,6 +140,31 @@ public final class VoidProtection {
|
||||
|| 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
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import com.destroystokyo.paper.event.player.PlayerPickupExperienceEvent;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Sound;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.ExperienceOrb;
|
||||
import org.bukkit.entity.Item;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.ItemSpawnEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.enchantments.Enchantment;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.bukkit.inventory.meta.Damageable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Optimises XP and Item collection by instantly vacuuming nearby XP orbs into the player's
|
||||
* XP bar (bypassing Vanilla's 2-tick per orb delay), repairing Mending equipment first,
|
||||
* and consolidating spawned item drops into full stacks.
|
||||
*/
|
||||
public final class XpAndItemMergeListener implements Listener {
|
||||
|
||||
private final Canalhandia plugin;
|
||||
|
||||
public XpAndItemMergeListener(Canalhandia plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vacuums all experience orbs within radius instantly on player contact and repairs Mending items.
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||
public void onPlayerPickupXp(PlayerPickupExperienceEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
Location loc = player.getLocation();
|
||||
if (loc.getWorld() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
int totalExp = 0;
|
||||
try {
|
||||
for (Entity entity : loc.getWorld().getNearbyEntities(loc, 6.0, 6.0, 6.0)) {
|
||||
if (entity instanceof ExperienceOrb orb && orb.isValid()) {
|
||||
totalExp += orb.getExperience();
|
||||
orb.remove();
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
if (totalExp > 0) {
|
||||
int leftoverExp = applyMending(player, totalExp);
|
||||
if (leftoverExp > 0) {
|
||||
player.giveExp(leftoverExp);
|
||||
}
|
||||
try {
|
||||
player.playSound(loc, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 0.5f, 1.2f);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repairs equipped items with Mending enchantment using the collected XP.
|
||||
* Returns remaining XP to be added to the player's experience bar.
|
||||
*/
|
||||
public static int applyMending(Player player, int totalExp) {
|
||||
if (player == null || totalExp <= 0) {
|
||||
return totalExp;
|
||||
}
|
||||
int remainingXp = totalExp;
|
||||
PlayerInventory inv = player.getInventory();
|
||||
|
||||
List<ItemStack> candidates = new ArrayList<>();
|
||||
ItemStack main = inv.getItemInMainHand();
|
||||
if (isDamagedMending(main)) candidates.add(main);
|
||||
|
||||
ItemStack off = inv.getItemInOffHand();
|
||||
if (isDamagedMending(off)) candidates.add(off);
|
||||
|
||||
for (ItemStack armor : inv.getArmorContents()) {
|
||||
if (isDamagedMending(armor)) candidates.add(armor);
|
||||
}
|
||||
|
||||
for (ItemStack item : candidates) {
|
||||
if (remainingXp <= 0) {
|
||||
break;
|
||||
}
|
||||
if (item.getItemMeta() instanceof Damageable dmg && dmg.hasDamage()) {
|
||||
int damage = dmg.getDamage();
|
||||
int[] repair = calculateMendingRepair(damage, remainingXp);
|
||||
int repaired = repair[0];
|
||||
int xpUsed = repair[1];
|
||||
if (repaired > 0) {
|
||||
dmg.setDamage(damage - repaired);
|
||||
item.setItemMeta(dmg);
|
||||
remainingXp -= xpUsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return remainingXp;
|
||||
}
|
||||
|
||||
private static boolean isDamagedMending(ItemStack item) {
|
||||
if (item == null || item.getType() == Material.AIR || !item.hasItemMeta()) {
|
||||
return false;
|
||||
}
|
||||
if (!item.containsEnchantment(Enchantment.MENDING)) {
|
||||
return false;
|
||||
}
|
||||
if (item.getItemMeta() instanceof Damageable dmg) {
|
||||
return dmg.hasDamage() && dmg.getDamage() > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates repair amount and used XP for a given damage and available XP.
|
||||
* Returns an int array: [durabilityRepaired, xpUsed].
|
||||
*/
|
||||
public static int[] calculateMendingRepair(int damage, int availableXp) {
|
||||
if (damage <= 0 || availableXp <= 0) {
|
||||
return new int[]{0, 0};
|
||||
}
|
||||
int durabilityToRepair = Math.min(damage, availableXp * 2);
|
||||
int xpUsed = (durabilityToRepair + 1) / 2;
|
||||
return new int[]{durabilityToRepair, xpUsed};
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidates spawned items of the same type in a 4-block radius into unified stacks.
|
||||
*/
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onItemSpawn(ItemSpawnEvent event) {
|
||||
Item item = event.getEntity();
|
||||
ItemStack stack = item.getItemStack();
|
||||
if (stack.getAmount() >= stack.getMaxStackSize() || item.getWorld() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (Entity e : item.getWorld().getNearbyEntities(item.getLocation(), 4.0, 4.0, 4.0)) {
|
||||
if (e instanceof Item other && other != item && other.isValid()) {
|
||||
ItemStack otherStack = other.getItemStack();
|
||||
if (otherStack.isSimilar(stack)) {
|
||||
int toTransfer = calculateTransfer(stack.getAmount(), stack.getMaxStackSize(), otherStack.getAmount());
|
||||
if (toTransfer > 0) {
|
||||
stack.setAmount(stack.getAmount() + toTransfer);
|
||||
otherStack.setAmount(otherStack.getAmount() - toTransfer);
|
||||
if (otherStack.getAmount() <= 0) {
|
||||
other.remove();
|
||||
} else {
|
||||
other.setItemStack(otherStack);
|
||||
}
|
||||
item.setItemStack(stack);
|
||||
if (stack.getAmount() >= stack.getMaxStackSize()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Pure transfer amount calculator for stack consolidation. */
|
||||
public static int calculateTransfer(int targetAmount, int targetMax, int sourceAmount) {
|
||||
if (targetAmount < 0 || targetMax <= 0 || sourceAmount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
int canAdd = targetMax - targetAmount;
|
||||
if (canAdd <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(canAdd, sourceAmount);
|
||||
}
|
||||
}
|
||||
@@ -344,3 +344,43 @@ mortes:
|
||||
- "BONE | Osso da Sorte | Um ossinho pra você, campeão."
|
||||
- "COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora."
|
||||
- "ROTTEN_FLESH | Carne Podre | É o que tinha sobrado na despensa."
|
||||
|
||||
# --- Âncoras de Chunk (Chunk Loaders) ----------------------------------------
|
||||
chunkloader:
|
||||
# Limite máximo de âncoras ativas por jogador por padrão.
|
||||
# Pode ser alterado por jogador com /chunkloader limite <jogador> <qtd>.
|
||||
limite-padrao: 1
|
||||
|
||||
# Exibe os marcadores de âncoras ativas no BlueMap.
|
||||
bluemap: true
|
||||
|
||||
# Configurações de simulação de entidades na chunk carregada:
|
||||
simulacao:
|
||||
# Se true, mantém ativas gaiolas de monstros (spawners de blaze, esqueletos, etc.)
|
||||
# mesmo sem jogadores por perto.
|
||||
gaiolas-ativas: true
|
||||
|
||||
# Se true, simula o surgimento natural de monstros em plataformas escuras,
|
||||
# slime chunks e fortalezas do nether. Se false, apenas entidades existentes funcionam.
|
||||
spawn-monstros: true
|
||||
|
||||
# Teto máximo de monstros na chunk antes de pausar o spawn natural (evita acúmulo de lag).
|
||||
limite-monstros-chunk: 20
|
||||
|
||||
# Se true, mantém animais, aldeões, ferro-golems e colheitas sendo processados na chunk.
|
||||
tick-entidades-passivas: true
|
||||
|
||||
# Sistema de combustível e tempo de expiração:
|
||||
combustivel:
|
||||
# Se true, âncoras requerem abastecimento com itens e têm tempo de expiração.
|
||||
# Se false, todas as âncoras colocadas são permanentes por padrão.
|
||||
requer-tempo: true
|
||||
|
||||
# Tempo inicial padrão dado ao criar e colocar uma nova âncora (em horas).
|
||||
tempo-inicial-horas: 24
|
||||
|
||||
# --- Salva-Void (Resgate de Morte no Vazio) -----------------------------------
|
||||
salvavoid:
|
||||
# Raio máximo de busca em blocos a partir de onde o jogador caiu para achar
|
||||
# terreno seguro e colocar o baú com os itens resgatados.
|
||||
raio-busca: 32
|
||||
|
||||
@@ -122,6 +122,12 @@ class ChunkLoaderTest {
|
||||
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);
|
||||
@@ -197,6 +203,25 @@ class ChunkLoaderTest {
|
||||
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();
|
||||
|
||||
@@ -25,10 +25,14 @@ class TitlesTest {
|
||||
void matchesEarnedTitleByKeyAndByName() {
|
||||
Achievement pedreiro = Achievement.byKey("pedreiro");
|
||||
Achievement veterano = Achievement.byKey("veterano");
|
||||
List<Achievement> earned = List.of(pedreiro, veterano);
|
||||
Achievement cacador = Achievement.byKey("cacador");
|
||||
List<Achievement> earned = List.of(pedreiro, veterano, cacador);
|
||||
assertSame(pedreiro, CanalhandiaCommand.matchEarned("pedreiro", earned));
|
||||
assertSame(veterano, CanalhandiaCommand.matchEarned("Veterano", earned)); // display name, ci
|
||||
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
|
||||
|
||||
@@ -73,4 +73,12 @@ class VoidProtectionTest {
|
||||
assertFalse(VoidProtection.isSafeGround(null));
|
||||
assertFalse(VoidProtection.isReplaceable(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void calculatesRequiredSlotsAndChestFit() {
|
||||
assertEquals(0, VoidProtection.calculateRequiredSlots(null));
|
||||
assertEquals(0, VoidProtection.calculateRequiredSlots(List.of()));
|
||||
assertTrue(VoidProtection.canFitInSingleChest(List.of()));
|
||||
assertTrue(VoidProtection.canFitInDoubleChest(List.of()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class XpAndItemMergeTest {
|
||||
|
||||
@Test
|
||||
void handlesZeroOrNegativeAmountsGracefully() {
|
||||
assertEquals(0, XpAndItemMergeListener.calculateTransfer(0, 0, 0));
|
||||
assertEquals(0, XpAndItemMergeListener.calculateTransfer(-1, 16, 5));
|
||||
assertEquals(0, XpAndItemMergeListener.calculateTransfer(16, 16, 5));
|
||||
assertEquals(0, XpAndItemMergeListener.calculateTransfer(10, 16, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void calculatesTransfersUpToMaxCapacity() {
|
||||
// Target has 10, max is 16, source has 10 -> can transfer 6
|
||||
assertEquals(6, XpAndItemMergeListener.calculateTransfer(10, 16, 10));
|
||||
|
||||
// Target has 5, max is 64, source has 20 -> can transfer all 20
|
||||
assertEquals(20, XpAndItemMergeListener.calculateTransfer(5, 64, 20));
|
||||
|
||||
// Target has 63, max is 64, source has 10 -> can transfer 1
|
||||
assertEquals(1, XpAndItemMergeListener.calculateTransfer(63, 64, 10));
|
||||
}
|
||||
|
||||
@Test
|
||||
void calculatesMendingRepairCorrectly() {
|
||||
// Zero damage or zero XP -> 0 repair
|
||||
int[] r1 = XpAndItemMergeListener.calculateMendingRepair(0, 50);
|
||||
assertEquals(0, r1[0]);
|
||||
assertEquals(0, r1[1]);
|
||||
|
||||
int[] r2 = XpAndItemMergeListener.calculateMendingRepair(50, 0);
|
||||
assertEquals(0, r2[0]);
|
||||
assertEquals(0, r2[1]);
|
||||
|
||||
// 10 damage, 100 XP -> repairs 10 durability, uses 5 XP
|
||||
int[] r3 = XpAndItemMergeListener.calculateMendingRepair(10, 100);
|
||||
assertEquals(10, r3[0]);
|
||||
assertEquals(5, r3[1]);
|
||||
|
||||
// 5 damage, 10 XP -> repairs 5 durability, uses 3 XP
|
||||
int[] r4 = XpAndItemMergeListener.calculateMendingRepair(5, 10);
|
||||
assertEquals(5, r4[0]);
|
||||
assertEquals(3, r4[1]);
|
||||
|
||||
// 10 damage, 2 XP -> repairs 4 durability (2 * 2), uses 2 XP
|
||||
int[] r5 = XpAndItemMergeListener.calculateMendingRepair(10, 2);
|
||||
assertEquals(4, r5[0]);
|
||||
assertEquals(2, r5[1]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user