Fix PR 3 review findings [skip ci]
This commit is contained in:
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.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import java.net.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
@@ -72,6 +73,18 @@ public final class Bridge {
|
||||
if (current != null) current.activity.action(player.getUUID(), player.getName().getString(), action, amount);
|
||||
}
|
||||
|
||||
/** Fail closed while a claim lookup is pending or the panel is unavailable. */
|
||||
public static boolean canModify(ServerPlayer player, BlockPos pos) {
|
||||
Integration current = integration;
|
||||
if (current == null) return false;
|
||||
if (!current.settings().guildsEnabled() || !current.settings().landClaimingEnabled()) return true;
|
||||
String dimension = player.serverLevel().dimension().location().toString();
|
||||
ChunkCheckResult check = current.checkChunk(dimension, pos.getX() >> 4, pos.getZ() >> 4, player.getUUID());
|
||||
if (check.allowed()) return true;
|
||||
player.displayClientMessage(Component.literal("This chunk is protected by a guild or the claim check is unavailable."), true);
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void command(ServerPlayer player, String command) {
|
||||
String name = command.strip().split("\\s+", 2)[0];
|
||||
Integration current = integration;
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerPlayerGameMode;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Shadow;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
@Mixin(ServerPlayerGameMode.class)
|
||||
public abstract class BlockBreakMixin {
|
||||
@Shadow protected net.minecraft.server.level.ServerPlayer player;
|
||||
|
||||
@Inject(method = "destroyBlock", at = @At("HEAD"), cancellable = true)
|
||||
private void scopenet$protectBreak(BlockPos pos, CallbackInfoReturnable<Boolean> cir) {
|
||||
if (!Bridge.canModify(player, pos)) cir.setReturnValue(false);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.level.ServerPlayerGameMode;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.phys.BlockHitResult;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
@Mixin(ServerPlayerGameMode.class)
|
||||
public abstract class BlockInteractMixin {
|
||||
@Inject(method = "useItemOn", at = @At("HEAD"), cancellable = true)
|
||||
private void scopenet$protectInteraction(ServerPlayer player, Level level, ItemStack stack,
|
||||
InteractionHand hand, BlockHitResult hit, CallbackInfoReturnable<InteractionResult> cir) {
|
||||
if (!Bridge.canModify(player, hit.getBlockPos())
|
||||
|| !Bridge.canModify(player, hit.getBlockPos().relative(hit.getDirection()))) {
|
||||
cir.setReturnValue(InteractionResult.FAIL);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -4,6 +4,7 @@ import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.item.BlockItem;
|
||||
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
@@ -11,11 +12,20 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
@Mixin(BlockItem.class)
|
||||
public abstract class BlockItemMixin {
|
||||
@Inject(method = "place(Lnet/minecraft/world/item/context/BlockPlaceContext;)Lnet/minecraft/world/InteractionResult;", at = @At("HEAD"), cancellable = true)
|
||||
private void scopenet$protectPlace(BlockPlaceContext context, CallbackInfoReturnable<InteractionResult> cir) {
|
||||
if (context.getPlayer() instanceof ServerPlayer player && !Bridge.canModify(player, context.getClickedPos())) {
|
||||
cir.setReturnValue(InteractionResult.FAIL);
|
||||
}
|
||||
}
|
||||
|
||||
@Inject(method = "place(Lnet/minecraft/world/item/context/BlockPlaceContext;)Lnet/minecraft/world/InteractionResult;", at = @At("RETURN"))
|
||||
private void scopenet$placed(BlockPlaceContext context, CallbackInfoReturnable<InteractionResult> cir) {
|
||||
if (cir.getReturnValue().consumesAction() && context.getPlayer() instanceof ServerPlayer player) {
|
||||
Bridge.stat(player, "blocks_placed", 1);
|
||||
Bridge.action(player, "block_placed:" + context.getClickedPos().toShortString(), 1);
|
||||
String material = BuiltInRegistries.BLOCK.getKey(context.getLevel().getBlockState(context.getClickedPos()).getBlock())
|
||||
.getPath().toUpperCase(java.util.Locale.ROOT);
|
||||
Bridge.action(player, "block_placed:" + material + "@" + player.serverLevel().dimension().location(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package net.scopenet.minecraft.mixin;
|
||||
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.stats.*;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
@@ -13,8 +16,16 @@ public abstract class PlayerMixin {
|
||||
private void scopenet$stat(Stat<?> stat, int amount, CallbackInfo ci) {
|
||||
Bridge.action((ServerPlayer) (Object) this, stat.getName(), amount);
|
||||
String key = null;
|
||||
if (stat.getType() == Stats.BLOCK_MINED) key = "blocks_broken";
|
||||
else if (stat.getType() == Stats.CUSTOM) {
|
||||
if (stat.getType() == Stats.BLOCK_MINED) {
|
||||
key = "blocks_broken";
|
||||
String material = BuiltInRegistries.BLOCK.getKey((Block) stat.getValue()).getPath().toUpperCase(java.util.Locale.ROOT);
|
||||
Bridge.action((ServerPlayer) (Object) this, "block_broken:" + material + "@"
|
||||
+ ((ServerPlayer) (Object) this).serverLevel().dimension().location(), amount);
|
||||
} else if (stat.getType() == Stats.ENTITY_KILLED) {
|
||||
String entity = BuiltInRegistries.ENTITY_TYPE.getKey((EntityType<?>) stat.getValue()).getPath().toUpperCase(java.util.Locale.ROOT);
|
||||
Bridge.action((ServerPlayer) (Object) this, "mob_kills:" + entity, amount);
|
||||
}
|
||||
if (stat.getType() == Stats.CUSTOM) {
|
||||
key = switch (stat.getValue().toString()) {
|
||||
case "minecraft:deaths" -> "deaths";
|
||||
case "minecraft:player_kills" -> "player_kills";
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"package": "net.scopenet.minecraft.mixin",
|
||||
"compatibilityLevel": "JAVA_17",
|
||||
"refmap": "scopenet.refmap.json",
|
||||
"mixins": ["DedicatedMixin", "ServerMixin", "LoginMixin", "PlayerMixin", "BlockItemMixin", "ChatMixin", "CommandMixin", "AdvancementMixin"],
|
||||
"mixins": ["DedicatedMixin", "ServerMixin", "LoginMixin", "PlayerMixin", "BlockItemMixin", "BlockBreakMixin", "BlockInteractMixin", "ChatMixin", "CommandMixin", "AdvancementMixin"],
|
||||
"injectors": { "defaultRequire": 1 }
|
||||
}
|
||||
@@ -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()); }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user