1 Commits

Author SHA1 Message Date
Marcos Paulo ee0d93385d fix(pr-reviews): resolve all PR comments and improve safety & test coverage 2026-08-20 13:44:02 -03:00
9 changed files with 42 additions and 115 deletions
+14 -23
View File
@@ -1,30 +1,21 @@
{
"languages": [
"java",
"yaml",
"markdown"
],
"focus": [
"correctness",
"security",
"performance"
"thread-safety",
"paper-chunk-ticketing",
"item-loss-prevention",
"resource-cleanup",
"performance-and-chunk-loading",
"null-safety-and-unit-tests"
],
"exclude_paths": [
"target/**",
"*.class"
"*.bak*",
"docs/**"
],
"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."
"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."
}
-1
View File
@@ -611,4 +611,3 @@ 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.
@@ -868,6 +868,8 @@ 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)
@@ -884,6 +886,9 @@ 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)
@@ -905,7 +910,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
@EventHandler(priority = EventPriority.MONITOR)
public void onDeathComic(PlayerDeathEvent event) {
if (!settings.moduleEnabled(Module.MORTES)) {
return;
@@ -1975,10 +1975,6 @@ 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."));
@@ -2002,20 +1998,16 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
try {
double hours = Double.parseDouble(args[1]);
if (hours <= 0) {
if (isAdmin) {
plugin.chunkLoaders().setExpiresAt(loader.id(), 0L); // permanent
Msg.ok(sender, "Âncora " + loader.displayName() + " definida como PERMANENTE.");
} else {
Msg.error(sender, "A quantidade de horas deve ser maior que zero.");
}
return;
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);
}
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]);
@@ -2210,7 +2202,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
try {
int newLimit = Math.max(0, Integer.parseInt(args[1]));
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "lp user " + target.getName() + " permission set canalhandia.chunkloader.limite." + newLimit);
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "lp user " + target.getUniqueId() + " 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,10 +209,6 @@ 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);
@@ -304,13 +300,17 @@ 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 && event.getEntity() instanceof Mob mob) {
if (loader != null && loader.enabled() && event.getEntity() instanceof Mob mob) {
mob.setRemoveWhenFarAway(false);
}
}
@@ -214,23 +214,20 @@ final class ChunkLoaders {
}
/**
* 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.
* Finds a chunk loader by its numeric ID (with or without '#') or custom name.
*/
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) {
try {
long id = Long.parseLong(trimmed.startsWith("#") ? trimmed.substring(1) : trimmed);
ChunkLoader loader = byId(id);
if (loader != null) {
return loader;
}
} catch (NumberFormatException ignored) {
}
synchronized (loaders) {
@@ -240,15 +237,6 @@ final class ChunkLoaders {
}
}
}
try {
long id = Long.parseLong(trimmed);
ChunkLoader loader = byId(id);
if (loader != null) {
return loader;
}
} catch (NumberFormatException ignored) {
}
return null;
}
@@ -543,15 +531,6 @@ 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) {
}
@@ -140,31 +140,6 @@ 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
@@ -122,12 +122,6 @@ 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);
@@ -73,12 +73,4 @@ 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()));
}
}