Fix PR 3 review findings [skip ci]

This commit is contained in:
SCOPEDD committed 2026-09-29 23:21:14 -04:00
1 parent 590178e41a
commit 454620f21e
21 files changed
+522 -160

No files matched your search

@@ -4,6 +4,7 @@ import net.scopenet.integration.*;
import net.scopenet.paper.commands.*;
import org.bukkit.*;
import org.bukkit.entity.Player;
import org.bukkit.block.Block;
import org.bukkit.event.*;
import org.bukkit.event.block.*;
import org.bukkit.event.entity.*;
@@ -11,6 +12,7 @@ import org.bukkit.event.player.*;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.concurrent.TimeUnit;
import java.util.UUID;
public final class ScopenetPlugin extends JavaPlugin implements Listener {
private Integration integration;
@@ -152,6 +154,27 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
}
}
private boolean claimsEnabled() {
return integration != null && integration.settings().guildsEnabled() && integration.settings().landClaimingEnabled();
}
private boolean protectedFrom(Player player, Block block) {
if (!claimsEnabled()) return false;
Chunk chunk = block.getChunk();
ChunkCheckResult check = integration.checkChunk(dimension(block.getWorld()), chunk.getX(), chunk.getZ(), player.getUniqueId());
if (check.allowed()) return false;
player.sendMessage(ChatColor.RED + "This chunk is protected by a guild or the claim check is unavailable.");
return true;
}
private boolean claimed(Block block) {
if (!claimsEnabled()) return false;
Chunk chunk = block.getChunk();
// A non-member identity protects environmental changes, including
// while the panel is unavailable and the lookup is pending.
return integration.checkChunk(dimension(block.getWorld()), chunk.getX(), chunk.getZ(), new UUID(0, 0)).claimed();
}
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
public void broken(BlockBreakEvent event) {
if (integration != null && integration.settings().guildsEnabled() && integration.settings().landClaimingEnabled()) {
@@ -164,6 +187,8 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
}
}
add(event.getPlayer(), "blocks_broken");
if (integration != null) integration.activity.action(event.getPlayer().getUniqueId(), event.getPlayer().getName(),
"block_broken:" + event.getBlock().getType().name() + "@" + dimension(event.getBlock().getWorld()), 1);
}
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
@@ -178,6 +203,64 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
}
}
add(event.getPlayer(), "blocks_placed");
if (integration != null) integration.activity.action(event.getPlayer().getUniqueId(), event.getPlayer().getName(),
"block_placed:" + event.getBlock().getType().name() + "@" + dimension(event.getBlock().getWorld()), 1);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void interact(PlayerInteractEvent event) {
if (event.getClickedBlock() != null && event.getAction() == Action.RIGHT_CLICK_BLOCK
&& protectedFrom(event.getPlayer(), event.getClickedBlock())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void bucketEmpty(PlayerBucketEmptyEvent event) {
if (protectedFrom(event.getPlayer(), event.getBlockClicked().getRelative(event.getBlockFace()))) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void bucketFill(PlayerBucketFillEvent event) {
if (protectedFrom(event.getPlayer(), event.getBlockClicked())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void entityExplode(EntityExplodeEvent event) {
event.blockList().removeIf(this::claimed);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void blockExplode(BlockExplodeEvent event) {
event.blockList().removeIf(this::claimed);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void pistonExtend(BlockPistonExtendEvent event) {
if (event.getBlocks().stream().anyMatch(block -> claimed(block) || claimed(block.getRelative(event.getDirection())))) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void pistonRetract(BlockPistonRetractEvent event) {
if (event.getBlocks().stream().anyMatch(block -> claimed(block) || claimed(block.getRelative(event.getDirection())))) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void fluid(BlockFromToEvent event) {
if (claimed(event.getToBlock())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void fire(BlockIgniteEvent event) {
if (claimed(event.getBlock())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void burn(BlockBurnEvent event) {
if (claimed(event.getBlock())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void entityChangeBlock(EntityChangeBlockEvent event) {
if (claimed(event.getBlock())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -231,6 +314,10 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
event instanceof PlayerDeathEvent death ? death.getDeathMessage() : null);
}
Player killer = event.getEntity().getKiller();
if (killer != null) add(killer, event.getEntity() instanceof Player ? "player_kills" : "mob_kills");
if (killer != null) {
add(killer, event.getEntity() instanceof Player ? "player_kills" : "mob_kills");
if (integration != null && !(event.getEntity() instanceof Player)) integration.activity.action(
killer.getUniqueId(), killer.getName(), "mob_kills:" + event.getEntityType().name(), 1);
}
}
}
@@ -19,13 +19,13 @@ import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public final class EconomyHandler implements CommandExecutor, Listener {
private final ScopenetPlugin plugin;
private final Integration integration;
private final EconomyOperations operations;
private static final String GUI_SHOP_TITLE = ChatColor.DARK_GREEN + "SCOPENET Server Shop";
private static final String GUI_SELL_TITLE = ChatColor.GOLD + "Sell Chest (Place Items Here)";
@@ -35,10 +35,8 @@ public final class EconomyHandler implements CommandExecutor, Listener {
// Trade sessions: maps player UUID to active trade session
private final Map<UUID, TradeSession> activeTrades = new ConcurrentHashMap<>();
// Marketplace: slot index → listing id (populated when the GUI is opened)
// Note: this is per-opening; multiple concurrent GUIs share the same map which is
// acceptable because listings are keyed by slot and players cannot open two markets.
private final Map<Integer, Long> activeMarketListings = new ConcurrentHashMap<>();
// Each open inventory must retain its own snapshot of listing IDs.
private final Map<Inventory, Map<Integer, Long>> activeMarketListings = new IdentityHashMap<>();
// Standard sell values for common items
private static final Map<Material, Double> ITEM_SELL_VALUES = new HashMap<>();
@@ -73,6 +71,13 @@ public final class EconomyHandler implements CommandExecutor, Listener {
public EconomyHandler(ScopenetPlugin plugin, Integration integration) {
this.plugin = plugin;
this.integration = integration;
this.operations = new EconomyOperations(plugin, integration);
}
private void giveOrDrop(Player player, ItemStack item) {
for (ItemStack remaining : player.getInventory().addItem(item).values()) {
player.getWorld().dropItemNaturally(player.getLocation(), remaining);
}
}
@Override
@@ -222,27 +227,16 @@ public final class EconomyHandler implements CommandExecutor, Listener {
player.sendMessage(ChatColor.RED + "This item (" + hand.getType().name() + ") cannot be sold to the server shop.");
return;
}
int amount = hand.getAmount();
ItemStack sold = hand.clone();
int amount = sold.getAmount();
double total = unitPrice * amount;
player.sendMessage(ChatColor.GRAY + "Processing sale...");
// FIX #5: Credit FIRST, only remove item after the server confirms payment.
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
// FIX #4: atomic addBalance – no read-modify-write race condition.
double newBal = integration.client().addBalance(
player.getUniqueId(), player.getName(), total, "Sell hand: " + hand.getType().name());
Bukkit.getScheduler().runTask(plugin, () -> {
player.getInventory().setItemInMainHand(null);
player.sendMessage(ChatColor.GREEN + "Sold " + amount + "x " + hand.getType().name() + " for "
+ ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", total) + ChatColor.GREEN
+ "! Balance: " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", newBal));
});
} catch (Exception e) {
// Item was NOT removed – nothing to restore.
player.sendMessage(ChatColor.RED + "Failed to credit sell balance: " + e.getMessage());
}
});
JsonObject payload = new JsonObject();
payload.addProperty("uuid", player.getUniqueId().toString());
payload.addProperty("username", player.getName());
payload.addProperty("delta", total);
payload.addProperty("description", "Sell hand: " + sold.getType().name());
player.getInventory().setItemInMainHand(null);
if (!operations.enqueue(player, "economy/adjust", payload, List.of(sold), null)) giveOrDrop(player, sold);
return;
}
@@ -268,27 +262,25 @@ public final class EconomyHandler implements CommandExecutor, Listener {
return;
}
int amount = hand.getAmount();
String itemId = hand.getType().name();
String itemName = hand.hasItemMeta() && hand.getItemMeta().hasDisplayName()
? hand.getItemMeta().getDisplayName() : hand.getType().name().replace('_', ' ');
ItemStack listed = hand.clone();
int amount = listed.getAmount();
String itemId = listed.getType().name();
String itemName = listed.hasItemMeta() && listed.getItemMeta().hasDisplayName()
? listed.getItemMeta().getDisplayName() : listed.getType().name().replace('_', ' ');
JsonObject payload = new JsonObject();
payload.addProperty("seller_uuid", player.getUniqueId().toString());
payload.addProperty("seller_name", player.getName());
payload.addProperty("item_id", itemId);
payload.addProperty("item_name", itemName);
payload.addProperty("amount", amount);
payload.addProperty("price", price);
payload.addProperty("item_data", EconomyOperations.encode(listed));
player.sendMessage(ChatColor.GRAY + "Listing item on marketplace...");
// FIX #5: Only remove item after the API accepted the listing.
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
integration.client().marketList(player.getUniqueId(), player.getName(), itemId, itemName, amount, price);
Bukkit.getScheduler().runTask(plugin, () -> {
player.getInventory().setItemInMainHand(null);
player.sendMessage(ChatColor.GREEN + "Successfully listed " + ChatColor.YELLOW + amount + "x " + itemName
+ ChatColor.GREEN + " on the market for " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", price) + "!");
});
} catch (Exception e) {
player.sendMessage(ChatColor.RED + "Market listing failed: " + e.getMessage());
// Item was never removed; no restoration needed.
}
});
// Escrow the exact stack before persisting a retryable operation.
player.getInventory().setItemInMainHand(null);
if (!operations.enqueue(player, "economy/market/list", payload, List.of(listed), null)) {
giveOrDrop(player, listed);
}
return;
}
@@ -307,9 +299,11 @@ public final class EconomyHandler implements CommandExecutor, Listener {
try {
JsonArray listings = integration.client().getMarketListings();
Bukkit.getScheduler().runTask(plugin, () -> {
if (player.getOpenInventory().getTopInventory() != inv) return;
// Clear loading placeholders
for (int i = 0; i < 45; i++) inv.setItem(i, null);
activeMarketListings.clear();
Map<Integer, Long> inventoryListings = new HashMap<>();
activeMarketListings.put(inv, inventoryListings);
if (listings.size() == 0) {
inv.setItem(22, GuiHelper.createItem(Material.BARRIER, "&cNo listings", "&7Be the first to sell something!"));
return;
@@ -326,7 +320,7 @@ public final class EconomyHandler implements CommandExecutor, Listener {
double listPrice = listing.has("price") ? listing.get("price").getAsDouble() : 0;
Material mat;
try { mat = Material.valueOf(listItemId); } catch (Exception ex) { mat = Material.PAPER; }
activeMarketListings.put(slot, id);
inventoryListings.put(slot, id);
inv.setItem(slot, GuiHelper.createItem(mat, listAmt,
"&f" + listItemName + " &7(x" + listAmt + ")",
"&7Seller: &f" + sellerName,
@@ -414,59 +408,25 @@ public final class EconomyHandler implements CommandExecutor, Listener {
}
if (price <= 0) return;
final double finalPrice = price;
final ItemStack toBuy = clicked.clone();
player.sendMessage(ChatColor.GRAY + "Processing purchase...");
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
// FIX #4: Atomic debit – the panel validates the balance and deducts atomically.
double newBal = integration.client().addBalance(
player.getUniqueId(), player.getName(), -finalPrice, "Shop purchase: " + toBuy.getType().name());
Bukkit.getScheduler().runTask(plugin, () -> {
ItemStack give = new ItemStack(toBuy.getType(), toBuy.getAmount());
player.getInventory().addItem(give);
player.sendMessage(ChatColor.GREEN + "Purchased " + give.getAmount() + "x " + give.getType().name()
+ " for " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", finalPrice) + ChatColor.GREEN
+ "! Balance: " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", newBal));
});
} catch (Exception e) {
player.sendMessage(ChatColor.RED + "Shop transaction failed: " + e.getMessage());
}
});
ItemStack purchase = new ItemStack(clicked.getType(), clicked.getAmount());
JsonObject payload = new JsonObject();
payload.addProperty("uuid", player.getUniqueId().toString());
payload.addProperty("username", player.getName());
payload.addProperty("delta", -price);
payload.addProperty("description", "Shop purchase: " + purchase.getType().name());
operations.enqueue(player, "economy/adjust", payload, Collections.emptyList(), purchase);
} else if (title.equals(GUI_MARKET_TITLE)) {
// FIX #2: Always cancel market clicks – purchase only via the API.
event.setCancelled(true);
int slot = event.getRawSlot();
Long listingId = activeMarketListings.get(slot);
Long listingId = activeMarketListings.getOrDefault(event.getInventory(), Collections.emptyMap()).get(slot);
if (listingId == null || listingId < 0) return;
player.sendMessage(ChatColor.GRAY + "Processing market purchase...");
final long fListingId = listingId;
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
JsonObject result = integration.client().marketBuy(fListingId, player.getUniqueId(), player.getName());
String itemId = result.has("item_id") ? result.get("item_id").getAsString() : null;
int amt = result.has("amount") ? result.get("amount").getAsInt() : 1;
double newBal = result.has("new_balance") ? result.get("new_balance").getAsDouble() : 0;
if (itemId != null) {
Bukkit.getScheduler().runTask(plugin, () -> {
try {
Material mat = Material.valueOf(itemId);
player.getInventory().addItem(new ItemStack(mat, amt));
player.sendMessage(ChatColor.GREEN + "Purchased " + amt + "x " + mat.name()
+ " from the market! Balance: " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", newBal));
} catch (Exception ex) {
player.sendMessage(ChatColor.GREEN + "Purchase successful! Balance: " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", newBal));
}
});
} else {
player.sendMessage(ChatColor.GREEN + "Market purchase successful!");
}
} catch (Exception e) {
player.sendMessage(ChatColor.RED + "Market purchase failed: " + e.getMessage());
}
});
JsonObject payload = new JsonObject();
payload.addProperty("listing_id", listingId);
payload.addProperty("buyer_uuid", player.getUniqueId().toString());
payload.addProperty("buyer_name", player.getName());
operations.enqueue(player, "economy/market/buy", payload, Collections.emptyList(), null);
} else if (title.startsWith(GUI_TRADE_PREFIX)) {
TradeSession session = activeTrades.get(player.getUniqueId());
if (session != null) {
@@ -480,8 +440,12 @@ public final class EconomyHandler implements CommandExecutor, Listener {
if (!(event.getPlayer() instanceof Player player)) return;
String title = event.getView().getTitle();
if (title.equals(GUI_MARKET_TITLE)) {
activeMarketListings.remove(event.getInventory());
}
if (title.equals(GUI_SELL_TITLE)) {
// FIX #4/#5: Collect items, credit atomically, only confirm sale after success.
// Move sellable items to durable escrow before crediting the balance.
Inventory inv = event.getInventory();
List<ItemStack> soldItems = new ArrayList<>();
List<ItemStack> unsellable = new ArrayList<>();
@@ -499,33 +463,20 @@ public final class EconomyHandler implements CommandExecutor, Listener {
unsellable.add(item.clone());
}
}
inv.clear();
// Return unsellable items immediately
for (ItemStack it : unsellable) {
player.getInventory().addItem(it);
}
for (ItemStack it : unsellable) giveOrDrop(player, it);
if (totalEarned > 0) {
final double earned = totalEarned;
final int itemsCount = countSold;
// FIX #4: addBalance is atomic – no read-modify-write race.
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
double newBal = integration.client().addBalance(
player.getUniqueId(), player.getName(), earned, "Sell chest: " + itemsCount + " items");
player.sendMessage(ChatColor.GREEN + "Sold " + itemsCount + " items for "
+ ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", earned) + ChatColor.GREEN + "!"
+ " Balance: " + ChatColor.YELLOW + "$" + String.format(Locale.US, "%,.2f", newBal));
} catch (Exception e) {
// FIX #5: Credit failed – return items to player.
player.sendMessage(ChatColor.RED + "Failed to credit earnings, items returned: " + e.getMessage());
Bukkit.getScheduler().runTask(plugin, () -> {
for (ItemStack it : soldItems) {
player.getInventory().addItem(it);
}
});
}
});
JsonObject payload = new JsonObject();
payload.addProperty("uuid", player.getUniqueId().toString());
payload.addProperty("username", player.getName());
payload.addProperty("delta", totalEarned);
payload.addProperty("description", "Sell chest: " + countSold + " items");
if (!operations.enqueue(player, "economy/adjust", payload, soldItems, null)) {
for (ItemStack item : soldItems) giveOrDrop(player, item);
}
}
} else if (title.startsWith(GUI_TRADE_PREFIX)) {
TradeSession session = activeTrades.get(player.getUniqueId());
@@ -121,8 +121,14 @@ final class EconomyOperations {
for (ItemStack item : items) give(player, item);
jobs.remove(id);
save();
player.sendMessage(job.has("error") ? ChatColor.RED + "Transaction rejected; held items returned: " + job.get("error").getAsString()
: ChatColor.GREEN + "Transaction completed.");
if (job.has("error")) {
player.sendMessage(ChatColor.RED + "Transaction rejected; held items returned: " + job.get("error").getAsString());
} else {
JsonObject result = job.getAsJsonObject("result");
JsonElement balanceValue = result.has("new_balance") ? result.get("new_balance") : result.get("balance");
String balance = balanceValue != null ? " Balance: $" + String.format(Locale.US, "%,.2f", balanceValue.getAsDouble()) : "";
player.sendMessage(ChatColor.GREEN + "Transaction completed." + balance);
}
} catch (Exception e) { plugin.getLogger().severe("Economy delivery requires attention for " + id + ": " + e.getMessage()); }
}
}