12 Commits

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 17:54:32 +00:00
pragent test dfb84047ec chore: retrigger pragent review 2026-08-20 16:38:18 +00:00
pragent test 8821e6d2c0 chore: retrigger pragent review (no-op) 2026-08-20 16:36:28 +00:00
pragent-bot da4a1777a7 chore: add .pr-review.json for pragent 2026-08-20 16:14:05 +00:00
11 changed files with 585 additions and 30 deletions
+53
View File
@@ -0,0 +1,53 @@
## What
<!-- 1-2 sentences. What does this PR change? -->
## Why
<!-- Link the issue, design doc, or chat thread. Without a "why" the reviewer
has to guess whether the change is wanted at all. -->
Refs: #<!-- issue or N/A -->
## How to test
```bash
# Commands the reviewer can run to reproduce / verify:
docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \
maven:3.9-eclipse-temurin-25 mvn -B test
```
<!-- Manual test steps (if any): load a save, join a server, trigger a
death in the void, etc. State what you observed. -->
<!-- Server console output if relevant (translatable components render
in English there — that's normal). -->
## Risk
<!-- Which surface is touched? chat-only / item-loss / chunk-load / permissions.
Is a rollback plan needed? Does it need a config.yml default? -->
- [ ] chat message flow (immutable after send)
- [ ] pt-BR singular-form rule (`5.966 blocos de Pedra`, not `5.966 Pedras`)
- [ ] Bedrock equivalent (`comando:` typed fallback, `texto:` ASCII label)
- [ ] stats path (`<world>/players/stats`, not `<world>/stats`)
- [ ] reaction late-window (`reacao-validade-minutos`)
- [ ] Bukkit main thread (no async chunk loads in event handlers)
- [ ] new `comando:` added to `plugin.yml`
- [ ] new permission declared in `plugin.yml`
## Checklist
- [ ] `mvn -B test` passes locally (JDK 25)
- [ ] new behaviour has a test (`src/test/java/...`)
- [ ] no hand edits to generated sources (none in this repo)
- [ ] PR title is conventional-commit style
- [ ] branch is up to date with `main`
## AI review
Label this PR **`AI-REVIEW`** to trigger `pragent`. Add **`AI-USAGE`** for a
token/cost report on the review. The bot reads `.pr-review.json` from `main`
+ a static `architecture.md` from Nexus raw-hosted — so house rules (chat
immutability, singular-form, Bedrock fallback, JDK 25) are baked in.
+93
View File
@@ -0,0 +1,93 @@
# Contributing — Canalhandia (Canalhandia Minecraft plugin)
Canalhandia is a Minecraft Paper plugin that adds PT-BR chat games, item
protection, and other in-game commands for the Canalhandia server. This repo
holds the plugin source. The companion server config lives at
`gitea_admin/canalhandia-config`.
## Before you open a PR
1. **Fill in the PR template** (`.gitea/pull_request_template.md`). The
*Risk* checklist is the part pragent scores you on — check the boxes that
apply, delete the ones that don't.
2. **Update the static context** if your change introduces new house rules.
See `## Adding house rules` below.
3. **Run the gates locally** (see *Test commands*). CI is a single
`mvn -B test` against JDK 25 — there's no other infra in this repo.
## Test commands
```bash
# Run the test suite (JDK 25 required). Same command CI uses:
mvn -B test
# Compile only (faster):
mvn -B -DskipTests package
# Integration: copy the resulting jar into a test Paper server's plugins/
# directory, start the server, exercise the affected commands. The chat
# and stats paths are NOT covered by unit tests — they need a live server.
```
### What counts as "tested"
For pragent (and reviewers) to consider a change tested, the PR must show:
- [ ] `mvn -B test` output from your machine pasted in the PR, **OR** a CI
run URL.
- [ ] A new behaviour has a JUnit test in `src/test/java/...` that exercises
the new code path. New `comando:` blocks need at least one test that
sends a fake event and asserts the message format.
- [ ] If the change touches the chat immutability rule, the singular-form
rule, or the Bedrock fallback, a manual test on a Paper server is
required **and** a one-line note pasted in the PR.
- [ ] If the change touches `<world>/players/stats`, you must show the
file path on a running server (`ls <world>/players/stats/*.json`)
and confirm the stats file is updated, not the wrong path.
### Out of scope for tests
- Performance / load tests (none in this repo).
- E2E against the live server (manual; verify in `canalhandia-config` repo).
- UI / browser tests (no web surface).
## Commit messages
Conventional commits. Examples:
- `feat: add per-player cobblestone counter command`
- `fix(void): keep inventory when dying in lava void`
- `docs: update README build instructions`
- `chore: bump pom parent to 1.4.2`
## Branch names
`<type>/<short-kebab-description>` matching commit type. Example:
`fix/void-keep-inventory`.
## PR review
- The bot (`pragent`) reviews every PR labelled `AI-REVIEW`. It reads
`.pr-review.json` from `main` + the static `architecture.md` from Nexus
raw-hosted. Adding a new house rule? Update both.
- Add the `AI-USAGE` label if you want the bot to also post a token /
cost-equivalent report on the review.
## Adding house rules
If your PR changes how translatable components behave, how chat edits work,
how stats are read, or how the Bedrock fallback is rendered, you must:
1. Update the relevant section in the static `architecture.md` (upload a new
copy to Nexus raw-hosted at `canalhandia/architecture.md`).
2. Bump `.pr-review.json:instructions` with a one-paragraph summary.
3. Add a finding-checklist item to `.gitea/pull_request_template.md` *Risk*
section so PR authors know to confirm the rule.
Bot does not detect house-rule drift automatically — these three updates
together are the maintainer contract.
## Rollback
A single PR that bumps the plugin jar in `canalhandia-config` rolls back.
Plugins are jar-swapped, no migrations.
@@ -161,6 +161,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
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 ChunkLoaderListener(this), this);
getServer().getPluginManager().registerEvents(new XpAndItemMergeListener(this), this);
rescheduleTimer(); rescheduleTimer();
rescheduleMilestones(); rescheduleMilestones();
@@ -1530,8 +1530,10 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
} }
return true; return true;
} }
String arg = String.join(" ", args); String arg = String.join(" ", args).trim();
if (arg.equalsIgnoreCase("limpar") || arg.equalsIgnoreCase("nenhum")) { if (arg.equalsIgnoreCase("limpar") || arg.equalsIgnoreCase("nenhum")
|| arg.equalsIgnoreCase("remover") || arg.equalsIgnoreCase("remove")
|| arg.equalsIgnoreCase("clear")) {
plugin.titles().clear(player.getUniqueId()); plugin.titles().clear(player.getUniqueId());
Msg.ok(player, Lang.tr("canalhandia.cmd.titulo.removido")); Msg.ok(player, Lang.tr("canalhandia.cmd.titulo.removido"));
return true; return true;
@@ -1546,20 +1548,37 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
return true; return true;
} }
/** Matches typed text to an earned achievement by key or (case-insensitive) title. */ /** Matches typed text to an earned achievement by key, title, or accent-normalized text. */
static Achievement matchEarned(String text, List<Achievement> earned) { static Achievement matchEarned(String text, List<Achievement> earned) {
Achievement byKey = Achievement.byKey(text); if (text == null || text.isBlank() || earned == null || earned.isEmpty()) {
return null;
}
String clean = text.trim();
Achievement byKey = Achievement.byKey(clean);
if (byKey != null && earned.contains(byKey)) { if (byKey != null && earned.contains(byKey)) {
return byKey; return byKey;
} }
for (Achievement achievement : earned) { for (Achievement achievement : earned) {
if (achievement.title().equalsIgnoreCase(text.trim())) { if (achievement.title().equalsIgnoreCase(clean)) {
return achievement;
}
}
String normalizedClean = normalizeText(clean);
for (Achievement achievement : earned) {
if (normalizeText(achievement.title()).equalsIgnoreCase(normalizedClean)
|| normalizeText(achievement.key()).equalsIgnoreCase(normalizedClean)) {
return achievement; return achievement;
} }
} }
return null; return null;
} }
private static String normalizeText(String input) {
if (input == null) return "";
return java.text.Normalizer.normalize(input, java.text.Normalizer.Form.NFD)
.replaceAll("\\p{M}", "");
}
private static String titlesList(List<Achievement> earned) { private static String titlesList(List<Achievement> earned) {
List<String> names = new ArrayList<>(); List<String> names = new ArrayList<>();
for (Achievement achievement : earned) { for (Achievement achievement : earned) {
@@ -36,7 +36,7 @@ final class ChunkLoaders {
private static final String PERM_LIMIT_PREFIX = "canalhandia.chunkloader.limite."; private static final String PERM_LIMIT_PREFIX = "canalhandia.chunkloader.limite.";
private final Plugin plugin; private final Canalhandia plugin;
private final File file; private final File file;
private final List<ChunkLoader> loaders = new ArrayList<>(); private final List<ChunkLoader> loaders = new ArrayList<>();
private final AtomicLong nextId = new AtomicLong(1); private final AtomicLong nextId = new AtomicLong(1);
@@ -48,7 +48,7 @@ final class ChunkLoaders {
return t; return t;
}); });
ChunkLoaders(Plugin plugin, File file) { ChunkLoaders(Canalhandia plugin, File file) {
this.plugin = plugin; this.plugin = plugin;
this.file = file; this.file = file;
load(); load();
@@ -97,11 +97,21 @@ final class ChunkLoaders {
* Registers a new chunk loader and activates the ticket in the world. * 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) { 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) { 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) { ChunkLoader add(String ownerUuid, String ownerName, String world, int x, int y, int z, String name, long expiresAt) {
@@ -417,28 +427,34 @@ final class ChunkLoaders {
} }
Chunk chunk = w.getChunkAt(loader.chunkX(), loader.chunkZ()); Chunk chunk = w.getChunkAt(loader.chunkX(), loader.chunkZ());
// 1. Keep mob spawners (dungeon / blaze / skeleton cages) active boolean spawnersActive = plugin.settings() == null || plugin.settings().chunkLoaderSpawnersActive();
for (BlockState state : chunk.getTileEntities()) { boolean monsterSpawning = plugin.settings() == null || plugin.settings().chunkLoaderMonsterSpawning();
if (state instanceof CreatureSpawner spawner) { int monsterCap = plugin.settings() != null ? plugin.settings().chunkLoaderMonsterCap() : 20;
if (spawner.getRequiredPlayerRange() < 1024) {
spawner.setRequiredPlayerRange(2048); // 1. Keep mob spawners (dungeon / blaze / skeleton cages) active if enabled
spawner.update(true, false); 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) // 2. Simulate natural mob spawning (dark platforms / slime / nether farms) if enabled
long mobCount = 0; if (monsterSpawning) {
for (Entity entity : chunk.getEntities()) { long mobCount = 0;
if (entity instanceof Mob) { for (Entity entity : chunk.getEntities()) {
mobCount++; 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) { } catch (Exception ignored) {
} }
} }
@@ -449,7 +465,7 @@ final class ChunkLoaders {
int baseZ = loader.chunkZ() << 4; int baseZ = loader.chunkZ() << 4;
ThreadLocalRandom rnd = ThreadLocalRandom.current(); 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 rx = baseX + rnd.nextInt(16);
int rz = baseZ + 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))); int topY = Math.max(w.getMinHeight() + 2, Math.min(w.getMaxHeight() - 2, loader.y() + rnd.nextInt(-24, 25)));
@@ -458,15 +474,30 @@ final class ChunkLoaders {
Block space = w.getBlockAt(rx, topY, rz); Block space = w.getBlockAt(rx, topY, rz);
Block spaceAbove = w.getBlockAt(rx, topY + 1, 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; 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; continue;
} }
int light = space.getLightLevel(); 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) { if (toSpawn == null) {
continue; continue;
} }
@@ -481,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) { static EntityType pickEntityType(World w, Block space, int light) {
boolean isSlime = false; boolean isSlime = false;
try { try {
@@ -597,6 +597,54 @@ final class Settings {
set("chunkloader.bluemap", 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 ---------------------------------------------------------- // --- salvavoid ----------------------------------------------------------
int voidProtectionRadius() { int voidProtectionRadius() {
@@ -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);
}
}
+40
View File
@@ -344,3 +344,43 @@ mortes:
- "BONE | Osso da Sorte | Um ossinho pra você, campeão." - "BONE | Osso da Sorte | Um ossinho pra você, campeão."
- "COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora." - "COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora."
- "ROTTEN_FLESH | Carne Podre | É o que tinha sobrado na despensa." - "ROTTEN_FLESH | Carne Podre | É o que tinha sobrado na despensa."
# --- Âncoras de Chunk (Chunk Loaders) ----------------------------------------
chunkloader:
# Limite máximo de âncoras ativas por jogador por padrão.
# Pode ser alterado por jogador com /chunkloader limite <jogador> <qtd>.
limite-padrao: 1
# Exibe os marcadores de âncoras ativas no BlueMap.
bluemap: true
# Configurações de simulação de entidades na chunk carregada:
simulacao:
# Se true, mantém ativas gaiolas de monstros (spawners de blaze, esqueletos, etc.)
# mesmo sem jogadores por perto.
gaiolas-ativas: true
# Se true, simula o surgimento natural de monstros em plataformas escuras,
# slime chunks e fortalezas do nether. Se false, apenas entidades existentes funcionam.
spawn-monstros: true
# Teto máximo de monstros na chunk antes de pausar o spawn natural (evita acúmulo de lag).
limite-monstros-chunk: 20
# Se true, mantém animais, aldeões, ferro-golems e colheitas sendo processados na chunk.
tick-entidades-passivas: true
# Sistema de combustível e tempo de expiração:
combustivel:
# Se true, âncoras requerem abastecimento com itens e têm tempo de expiração.
# Se false, todas as âncoras colocadas são permanentes por padrão.
requer-tempo: true
# Tempo inicial padrão dado ao criar e colocar uma nova âncora (em horas).
tempo-inicial-horas: 24
# --- Salva-Void (Resgate de Morte no Vazio) -----------------------------------
salvavoid:
# Raio máximo de busca em blocos a partir de onde o jogador caiu para achar
# terreno seguro e colocar o baú com os itens resgatados.
raio-busca: 32
@@ -203,6 +203,25 @@ class ChunkLoaderTest {
assertNull(ChunkLoaders.pickEntityType(org.bukkit.World.Environment.NORMAL, false, 64, 5, 10)); 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 @Test
void simulationSafelyNoopsWithNullPlugin() { void simulationSafelyNoopsWithNullPlugin() {
loaders.startSimulation(); loaders.startSimulation();
@@ -25,10 +25,14 @@ class TitlesTest {
void matchesEarnedTitleByKeyAndByName() { void matchesEarnedTitleByKeyAndByName() {
Achievement pedreiro = Achievement.byKey("pedreiro"); Achievement pedreiro = Achievement.byKey("pedreiro");
Achievement veterano = Achievement.byKey("veterano"); Achievement veterano = Achievement.byKey("veterano");
List<Achievement> earned = List.of(pedreiro, veterano); Achievement cacador = Achievement.byKey("cacador");
List<Achievement> earned = List.of(pedreiro, veterano, cacador);
assertSame(pedreiro, CanalhandiaCommand.matchEarned("pedreiro", earned)); assertSame(pedreiro, CanalhandiaCommand.matchEarned("pedreiro", earned));
assertSame(veterano, CanalhandiaCommand.matchEarned("Veterano", earned)); // display name, ci assertSame(veterano, CanalhandiaCommand.matchEarned("Veterano", earned)); // display name, ci
assertSame(pedreiro, CanalhandiaCommand.matchEarned("Pedreiro", earned)); assertSame(pedreiro, CanalhandiaCommand.matchEarned("Pedreiro", earned));
assertSame(cacador, CanalhandiaCommand.matchEarned("cacador", earned));
assertSame(cacador, CanalhandiaCommand.matchEarned("Caçador", earned));
assertSame(cacador, CanalhandiaCommand.matchEarned("Cacador", earned));
} }
@Test @Test
@@ -0,0 +1,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]);
}
}