diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 2fd0264..45bdf7a 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -690,6 +690,8 @@ pub struct UserPost { pub content: String, pub image_url: Option, pub likes_count: i64, + #[serde(default)] + pub liked_by_me: bool, pub created_at: String, } diff --git a/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java b/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java index d86016d..0263f31 100644 --- a/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java +++ b/integrations/minecraft/src/main/java/net/scopenet/minecraft/Bridge.java @@ -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; diff --git a/integrations/minecraft/src/main/java/net/scopenet/minecraft/mixin/BlockBreakMixin.java b/integrations/minecraft/src/main/java/net/scopenet/minecraft/mixin/BlockBreakMixin.java new file mode 100644 index 0000000..f81d12e --- /dev/null +++ b/integrations/minecraft/src/main/java/net/scopenet/minecraft/mixin/BlockBreakMixin.java @@ -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 cir) { + if (!Bridge.canModify(player, pos)) cir.setReturnValue(false); + } +} diff --git a/integrations/minecraft/src/main/java/net/scopenet/minecraft/mixin/BlockInteractMixin.java b/integrations/minecraft/src/main/java/net/scopenet/minecraft/mixin/BlockInteractMixin.java new file mode 100644 index 0000000..d1dd280 --- /dev/null +++ b/integrations/minecraft/src/main/java/net/scopenet/minecraft/mixin/BlockInteractMixin.java @@ -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 cir) { + if (!Bridge.canModify(player, hit.getBlockPos()) + || !Bridge.canModify(player, hit.getBlockPos().relative(hit.getDirection()))) { + cir.setReturnValue(InteractionResult.FAIL); + } + } +} diff --git a/integrations/minecraft/src/main/java/net/scopenet/minecraft/mixin/BlockItemMixin.java b/integrations/minecraft/src/main/java/net/scopenet/minecraft/mixin/BlockItemMixin.java index e9ffbb2..845bccb 100644 --- a/integrations/minecraft/src/main/java/net/scopenet/minecraft/mixin/BlockItemMixin.java +++ b/integrations/minecraft/src/main/java/net/scopenet/minecraft/mixin/BlockItemMixin.java @@ -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 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 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); } } } diff --git a/integrations/minecraft/src/main/java/net/scopenet/minecraft/mixin/PlayerMixin.java b/integrations/minecraft/src/main/java/net/scopenet/minecraft/mixin/PlayerMixin.java index f97ff6c..a8d61dc 100644 --- a/integrations/minecraft/src/main/java/net/scopenet/minecraft/mixin/PlayerMixin.java +++ b/integrations/minecraft/src/main/java/net/scopenet/minecraft/mixin/PlayerMixin.java @@ -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"; diff --git a/integrations/minecraft/src/main/resources/scopenet.mixins.json b/integrations/minecraft/src/main/resources/scopenet.mixins.json index 56adb3b..6ea649d 100644 --- a/integrations/minecraft/src/main/resources/scopenet.mixins.json +++ b/integrations/minecraft/src/main/resources/scopenet.mixins.json @@ -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 } } diff --git a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java index e5d9eb3..1b669a6 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java @@ -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); + } } } diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java index 3d875dd..9b6c56e 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java @@ -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 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 activeMarketListings = new ConcurrentHashMap<>(); + // Each open inventory must retain its own snapshot of listing IDs. + private final Map> activeMarketListings = new IdentityHashMap<>(); // Standard sell values for common items private static final Map 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 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 soldItems = new ArrayList<>(); List 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()); diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyOperations.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyOperations.java index 57bbd2a..6950aad 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyOperations.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyOperations.java @@ -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()); } } } diff --git a/launcher/src-tauri/src/commands.rs b/launcher/src-tauri/src/commands.rs index b93c272..8a1baed 100644 --- a/launcher/src-tauri/src/commands.rs +++ b/launcher/src-tauri/src/commands.rs @@ -607,6 +607,8 @@ pub async fn create_guild( name: String, tag: String, description: String, + icon_url: Option, + banner_url: Option, ) -> Res { let req = account_api(&state, reqwest::Method::POST, "/guilds").await?; let resp = req.json(&serde_json::json!({ @@ -614,6 +616,8 @@ pub async fn create_guild( "name": name, "tag": tag, "description": description, + "icon_url": icon_url, + "banner_url": banner_url, })).send().await.map_err(err)?; if !resp.status().is_success() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); @@ -950,12 +954,12 @@ pub async fn create_user_post( } #[tauri::command] -pub async fn like_user_post(state: State<'_, AppState>, post_id: i64) -> Res<()> { +pub async fn like_user_post(state: State<'_, AppState>, post_id: i64) -> Res { let req = account_api(&state, reqwest::Method::POST, &format!("/posts/{post_id}/like")).await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to like post".into()); } - Ok(()) + let body: serde_json::Value = resp.json().await.map_err(err)?; + Ok(body["liked"].as_bool().unwrap_or(false)) } - diff --git a/launcher/src/components/PlayerProfileModal.svelte b/launcher/src/components/PlayerProfileModal.svelte index 205cf67..7073232 100644 --- a/launcher/src/components/PlayerProfileModal.svelte +++ b/launcher/src/components/PlayerProfileModal.svelte @@ -88,9 +88,11 @@ } async function handleLikePost(post: UserPost) { + if (post.liked_by_me) return; try { - await invoke('like_user_post', { postId: post.id }); - post.likes_count += 1; + const liked = await invoke('like_user_post', { postId: post.id }); + if (liked) post.likes_count += 1; + post.liked_by_me = true; } catch (e: any) { toast(e?.message ?? 'Failed to update like', 'error'); } diff --git a/launcher/src/pages/Quests.svelte b/launcher/src/pages/Quests.svelte index 2505d37..14ec7c7 100644 --- a/launcher/src/pages/Quests.svelte +++ b/launcher/src/pages/Quests.svelte @@ -106,9 +106,9 @@ return `${hours}h ${mins}m`; } else { const day = now.getUTCDay(); - const daysUntilSunday = (7 - day) % 7 || 7; - const nextSunday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + daysUntilSunday)); - const diffMs = nextSunday.getTime() - now.getTime(); + const daysUntilMonday = (8 - day) % 7 || 7; + const nextMonday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + daysUntilMonday)); + const diffMs = nextMonday.getTime() - now.getTime(); const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); return `${days}d ${hours}h`; diff --git a/panel/server/src/db.rs b/panel/server/src/db.rs index 3b36ba2..7722132 100644 --- a/panel/server/src/db.rs +++ b/panel/server/src/db.rs @@ -499,6 +499,13 @@ const MIGRATIONS: &[&str] = &[ CREATE TABLE economy_operations (server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE, operation_id TEXT NOT NULL, response TEXT NOT NULL, PRIMARY KEY(server_id, operation_id)); "#, + r#" + CREATE TABLE user_post_likes ( + post_id INTEGER NOT NULL REFERENCES user_posts(id) ON DELETE CASCADE, + user_uuid TEXT NOT NULL, + PRIMARY KEY (post_id, user_uuid) + ); + "#, ]; pub async fn connect(data_dir: &Path) -> Result { @@ -536,6 +543,7 @@ async fn migrate(pool: &SqlitePool) -> Result<()> { } backfill_uuids(pool).await?; crate::seed::seed_quests_and_achievements(pool).await?; + if current < 9 { crate::seed::upgrade_seeded_quest_targets(pool).await?; } Ok(()) } diff --git a/panel/server/src/routes/economy.rs b/panel/server/src/routes/economy.rs index 5891864..fb8dcb8 100644 --- a/panel/server/src/routes/economy.rs +++ b/panel/server/src/routes/economy.rs @@ -496,6 +496,7 @@ pub async fn server_market_buy( .bind(&payload.buyer_uuid) .execute(&mut *tx) .await?; + let new_balance = buyer_bal - price; // Pay seller sqlx::query( @@ -543,6 +544,7 @@ pub async fn server_market_buy( "item_data": item_data, "item_name": item_name, "amount": amount, - "price": price + "price": price, + "new_balance": new_balance })).await } diff --git a/panel/server/src/routes/guilds.rs b/panel/server/src/routes/guilds.rs index 32c9604..bcef021 100644 --- a/panel/server/src/routes/guilds.rs +++ b/panel/server/src/routes/guilds.rs @@ -1047,7 +1047,6 @@ pub async fn server_unclaim_chunk( ) .bind(&guild_id) .bind(&payload.uuid) - .bind(&server.instance_id) .fetch_optional(&state.db) .await?; diff --git a/panel/server/src/routes/quests.rs b/panel/server/src/routes/quests.rs index 826f037..8042fda 100644 --- a/panel/server/src/routes/quests.rs +++ b/panel/server/src/routes/quests.rs @@ -14,7 +14,65 @@ pub fn current_daily_key() -> String { } pub fn current_weekly_key() -> String { - chrono::Utc::now().format("%Y-W%V").to_string() + chrono::Utc::now().format("%G-W%V").to_string() +} + +fn action_matches(target: &str, action: &str) -> bool { + let Some(target) = target.strip_prefix("action:") else { return false }; + let (target_action, target_dimension) = target.split_once('@').map_or((target, None), |(a, d)| (a, Some(d))); + let (actual_action, actual_dimension) = action.split_once('@').map_or((action, None), |(a, d)| (a, Some(d))); + if target_dimension.is_some_and(|d| Some(d) != actual_dimension) { return false; } + let Some((target_kind, materials)) = target_action.split_once(':') else { return false }; + let Some((actual_kind, material)) = actual_action.split_once(':') else { return false }; + if target_kind != actual_kind { return false; } + materials.split('|').any(|pattern| { + if pattern == "*" { true } + else if let Some(suffix) = pattern.strip_prefix('*') { material.ends_with(suffix) } + else if let Some(prefix) = pattern.strip_suffix('*') { material.starts_with(prefix) } + else { material == pattern } + }) +} + +#[cfg(test)] +mod action_tests { + use super::action_matches; + + #[test] + fn only_matching_materials_and_dimensions_advance_quests() { + assert!(action_matches("action:block_broken:DIAMOND_ORE|DEEPSLATE_DIAMOND_ORE", "block_broken:DIAMOND_ORE@minecraft:overworld")); + assert!(!action_matches("action:block_broken:DIAMOND_ORE", "block_broken:STONE@minecraft:overworld")); + assert!(action_matches("action:block_placed:*_PLANKS", "block_placed:OAK_PLANKS@minecraft:overworld")); + assert!(action_matches("action:block_broken:*@minecraft:the_nether", "block_broken:NETHERRACK@minecraft:the_nether")); + assert!(!action_matches("action:block_broken:*@minecraft:the_nether", "block_broken:STONE@minecraft:overworld")); + } +} + +/// Advance quests that require a particular block, mob, or dimension rather +/// than a generic stat counter. +pub async fn advance_action_quests( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &str, action: &str, count: i64, now: &str, +) -> AppResult<()> { + if count <= 0 { return Ok(()); } + let rows: Vec<(String, String, String, i64)> = sqlx::query_as( + "SELECT id, period, target_stat, target_count FROM quests WHERE enabled = 1 AND target_stat LIKE 'action:%'", + ).fetch_all(&mut **tx).await?; + let daily = current_daily_key(); + let weekly = current_weekly_key(); + for (id, period, target, required) in rows { + if !action_matches(&target, action) { continue; } + let key = if period == "weekly" { &weekly } else { &daily }; + sqlx::query( + "INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at) + VALUES (?, ?, ?, ?, ?, 0, ?) + ON CONFLICT (user_uuid, quest_id, period_key) DO UPDATE SET + progress = progress + excluded.progress, + completed = CASE WHEN progress + excluded.progress >= ? THEN 1 ELSE completed END, + updated_at = excluded.updated_at", + ) + .bind(uuid).bind(&id).bind(key).bind(count).bind(count >= required).bind(now).bind(required) + .execute(&mut **tx).await?; + } + Ok(()) } /// Get all active daily and weekly quests for the authenticated user with live progress. diff --git a/panel/server/src/routes/servers.rs b/panel/server/src/routes/servers.rs index 3dddf8b..5679cbb 100644 --- a/panel/server/src/routes/servers.rs +++ b/panel/server/src/routes/servers.rs @@ -392,6 +392,17 @@ pub struct Sync { pub async fn sync(GameServer(server): GameServer, State(state): State, Json(s): Json) -> AppResult> { let at_now = now(); let mut tx = state.db.begin().await?; + let leveling_on = s.features.as_ref().and_then(|f| f.leveling_enabled).unwrap_or(true); + let mut xp_before = std::collections::HashMap::new(); + if !leveling_on { + for uuid in s.stats.iter().filter_map(|d| dashed(&d.uuid)) + .chain(s.events.iter().filter_map(|e| e.uuid.as_deref().and_then(dashed))) { + if xp_before.contains_key(&uuid) { continue; } + let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?") + .bind(&uuid).fetch_optional(&mut *tx).await?.unwrap_or(0); + xp_before.insert(uuid, xp); + } + } // Record the receipt in the same transaction as the deltas. A response // lost after commit can then be retried without counting activity twice. @@ -476,6 +487,7 @@ pub async fn sync(GameServer(server): GameServer, State(state): State, } let achievements_on = s.features.as_ref().and_then(|f| f.achievements_enabled).unwrap_or(true); + let quests_on = s.features.as_ref().and_then(|f| f.quests_enabled).unwrap_or(true); for e in s.events.iter().filter(|_| fresh).take(MAX_EVENTS_PER_SYNC) { let kind = clip(&e.kind, 24).to_ascii_lowercase(); @@ -494,8 +506,18 @@ pub async fn sync(GameServer(server): GameServer, State(state): State, .bind(&kind) .bind(e.detail.as_deref().map(|d| clip(d, 256))) .bind(at) - .execute(&mut *tx) - .await?; + .execute(&mut *tx) + .await?; + + if quests_on && kind == "action" { + if let (Some(uuid), Some(detail)) = (e.uuid.as_deref().and_then(dashed), e.detail.as_deref()) { + if let Some((action, count)) = detail.rsplit_once(" +") { + if let Ok(count) = count.parse::() { + crate::routes::quests::advance_action_quests(&mut tx, &uuid, action, count.clamp(0, 1_000_000), &at_now).await?; + } + } + } + } // Check if event unlocks an achievement (if achievements enabled) if achievements_on { @@ -523,7 +545,17 @@ pub async fn sync(GameServer(server): GameServer, State(state): State, let mut rewarded = std::collections::HashSet::new(); for d in &s.stats { if let Some(uuid) = dashed(&d.uuid) { rewarded.insert(uuid); } } for e in &s.events { if let Some(uuid) = e.uuid.as_deref().and_then(dashed) { rewarded.insert(uuid); } } - for uuid in rewarded { crate::routes::leveling::grant_rewards(&mut tx, &uuid, &at_now).await?; } + if leveling_on { + for uuid in rewarded { crate::routes::leveling::grant_rewards(&mut tx, &uuid, &at_now).await?; } + } else { + // Achievement unlocks remain enabled, but their trigger must not grant + // progression on a server whose leveling module is disabled. + for (uuid, xp) in xp_before { + let level = crate::routes::leveling::level_from_xp(xp).0; + sqlx::query("UPDATE user_levels SET global_xp = ?, global_level = ? WHERE uuid = ?") + .bind(xp).bind(level).bind(&uuid).execute(&mut *tx).await?; + } + } tx.commit().await?; // Occasional housekeeping. diff --git a/panel/server/src/routes/social.rs b/panel/server/src/routes/social.rs index 9e029b9..77c37ea 100644 --- a/panel/server/src/routes/social.rs +++ b/panel/server/src/routes/social.rs @@ -1,6 +1,6 @@ //! Friends, Direct Messages, Game Invites, Player Profiles, and Social Feed. -use crate::auth::AuthUser; +use crate::auth::{AuthUser, MaybeUser}; use crate::error::{AppError, AppResult}; use crate::state::AppState; use axum::extract::{Path, Query, State}; @@ -456,6 +456,7 @@ pub async fn respond_game_invite( // --------------------------------------------------------------------------- pub async fn get_player_profile( + MaybeUser(viewer): MaybeUser, Path(uuid): Path, State(state): State, ) -> AppResult> { @@ -497,23 +498,27 @@ pub async fn get_player_profile( .await?; // Recent posts - let post_rows: Vec<(i64, String, String, String, Option, i64, String)> = sqlx::query_as( - "SELECT id, user_uuid, author_name, content, image_url, likes_count, created_at - FROM user_posts WHERE user_uuid = ? ORDER BY id DESC LIMIT 10", + let post_rows: Vec<(i64, String, String, String, Option, i64, bool, String)> = sqlx::query_as( + "SELECT p.id, p.user_uuid, p.author_name, p.content, p.image_url, p.likes_count, + EXISTS(SELECT 1 FROM user_post_likes l WHERE l.post_id = p.id AND l.user_uuid = ?), + p.created_at + FROM user_posts p WHERE p.user_uuid = ? ORDER BY p.id DESC LIMIT 10", ) + .bind(viewer.as_ref().map(|user| user.uuid.as_str()).unwrap_or("")) .bind(&puuid) .fetch_all(&state.db) .await?; let posts = post_rows .into_iter() - .map(|(id, u_uuid, aname, content, img, likes, cat)| UserPost { + .map(|(id, u_uuid, aname, content, img, likes, liked_by_me, cat)| UserPost { id, user_uuid: u_uuid, author_name: aname, content, image_url: img, likes_count: likes, + liked_by_me, created_at: cat, }) .collect(); @@ -634,6 +639,7 @@ pub async fn create_user_post( content: content.to_string(), image_url: payload.image_url, likes_count: 0, + liked_by_me: false, created_at: now, })) } @@ -653,16 +659,31 @@ pub async fn delete_user_post( } pub async fn like_user_post( - _auth: AuthUser, + auth: AuthUser, Path(post_id): Path, State(state): State, ) -> AppResult> { - sqlx::query("UPDATE user_posts SET likes_count = likes_count + 1 WHERE id = ?") + let mut tx = state.db.begin().await?; + let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_posts WHERE id = ?)") .bind(post_id) - .execute(&state.db) + .fetch_one(&mut *tx) .await?; + if !exists { return Err(AppError::not_found("Post not found")); } + let added = sqlx::query("INSERT OR IGNORE INTO user_post_likes (post_id, user_uuid) VALUES (?, ?)") + .bind(post_id) + .bind(&auth.uuid) + .execute(&mut *tx) + .await? + .rows_affected() > 0; + if added { + sqlx::query("UPDATE user_posts SET likes_count = likes_count + 1 WHERE id = ?") + .bind(post_id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; - Ok(Json(serde_json::json!({ "ok": true }))) + Ok(Json(serde_json::json!({ "ok": true, "liked": added }))) } // --------------------------------------------------------------------------- diff --git a/panel/server/src/seed.rs b/panel/server/src/seed.rs index 8947819..074a2ba 100644 --- a/panel/server/src/seed.rs +++ b/panel/server/src/seed.rs @@ -3,6 +3,92 @@ use anyhow::Result; use sqlx::SqlitePool; +// Targets with a material, mob, or dimension requirement use the action +// telemetry sent by both Paper and the Fabric/Forge bridge. Other quests use +// the aggregate counters in the sync payload. +const SPECIFIC_QUEST_TARGETS: &[(&str, &str)] = &[ + ("q_d_mine_coal", "action:block_broken:COAL_ORE|DEEPSLATE_COAL_ORE|COAL_BLOCK"), + ("q_d_mine_stone", "action:block_broken:STONE|COBBLESTONE"), + ("q_d_mine_iron", "action:block_broken:IRON_ORE|DEEPSLATE_IRON_ORE"), + ("q_d_mine_copper", "action:block_broken:COPPER_ORE|DEEPSLATE_COPPER_ORE"), + ("q_d_mine_gold", "action:block_broken:GOLD_ORE|DEEPSLATE_GOLD_ORE|NETHER_GOLD_ORE"), + ("q_d_mine_diamond", "action:block_broken:DIAMOND_ORE|DEEPSLATE_DIAMOND_ORE"), + ("q_d_mine_redstone", "action:block_broken:REDSTONE_ORE|DEEPSLATE_REDSTONE_ORE"), + ("q_d_mine_deepslate", "action:block_broken:DEEPSLATE|COBBLED_DEEPSLATE"), + ("q_d_mine_quartz", "action:block_broken:NETHER_QUARTZ_ORE"), + ("q_d_mine_debris", "action:block_broken:ANCIENT_DEBRIS"), + ("q_d_mine_sand", "action:block_broken:SAND|GRAVEL"), + ("q_d_mine_obsidian", "action:block_broken:OBSIDIAN"), + ("q_d_mob_zombies", "action:mob_kills:ZOMBIE"), + ("q_d_mob_skeletons", "action:mob_kills:SKELETON"), + ("q_d_mob_spiders", "action:mob_kills:SPIDER|CAVE_SPIDER"), + ("q_d_mob_creepers", "action:mob_kills:CREEPER"), + ("q_d_mob_endermen", "action:mob_kills:ENDERMAN"), + ("q_d_mob_witches", "action:mob_kills:WITCH"), + ("q_d_mob_slimes", "action:mob_kills:SLIME|MAGMA_CUBE"), + ("q_d_mob_piglins", "action:mob_kills:PIGLIN|ZOMBIFIED_PIGLIN"), + ("q_d_mob_blazes", "action:mob_kills:BLAZE"), + ("q_d_mob_drowned", "action:mob_kills:DROWNED"), + ("q_d_build_planks", "action:block_placed:*_PLANKS"), + ("q_d_build_bricks", "action:block_placed:STONE_BRICKS|POLISHED_*"), + ("q_d_build_glass", "action:block_placed:GLASS|GLASS_PANE|*_GLASS|*_GLASS_PANE"), + ("q_d_build_torches", "action:block_placed:TORCH|WALL_TORCH"), + ("q_d_build_lanterns", "action:block_placed:LANTERN|SOUL_LANTERN"), + ("q_d_build_slabs", "action:block_placed:*_SLAB|*_STAIRS"), + ("q_d_build_walls", "action:block_placed:*_WALL"), + ("q_d_farm_wood", "action:block_broken:*_LOG|*_WOOD"), + ("q_d_farm_birch", "action:block_broken:BIRCH_LOG|SPRUCE_LOG"), + ("q_d_farm_jungle", "action:block_broken:JUNGLE_LOG|ACACIA_LOG"), + ("q_d_farm_wheat", "action:block_broken:WHEAT"), + ("q_d_farm_potatoes", "action:block_broken:POTATOES"), + ("q_d_farm_carrots", "action:block_broken:CARROTS"), + ("q_d_farm_sugarcane", "action:block_broken:SUGAR_CANE"), + ("q_d_farm_pumpkins", "action:block_broken:PUMPKIN|MELON"), + ("q_d_farm_flowers", "action:block_broken:*_TULIP|DANDELION|POPPY|OXEYE_DAISY|CORNFLOWER|AZURE_BLUET|ALLIUM"), + ("q_d_farm_leaves", "action:block_broken:*_LEAVES"), + ("q_d_explore_depths", "action:block_broken:TUFF|DEEPSLATE_TILES"), + ("q_d_explore_diorite", "action:block_broken:GRANITE|DIORITE|ANDESITE"), + ("q_d_explore_nether", "action:block_broken:*@minecraft:the_nether"), + ("q_d_explore_clay", "action:block_broken:CLAY"), + ("q_d_explore_snow", "action:block_broken:SNOW_BLOCK|SNOW|ICE|PACKED_ICE|BLUE_ICE"), + ("q_d_explore_magma", "action:block_broken:MAGMA_BLOCK|BASALT"), + ("q_d_build_decor", "action:block_placed:*_CARPET|FLOWER_POT|PAINTING"), + ("q_w_mine_deepslate_grand", "action:block_broken:DEEPSLATE|COBBLED_DEEPSLATE"), + ("q_w_mine_iron_hoard", "action:block_broken:IRON_ORE|DEEPSLATE_IRON_ORE"), + ("q_w_mine_diamonds_grand", "action:block_broken:DIAMOND_ORE|DEEPSLATE_DIAMOND_ORE"), + ("q_w_mine_gold_grand", "action:block_broken:GOLD_ORE|DEEPSLATE_GOLD_ORE|NETHER_GOLD_ORE"), + ("q_w_mine_redstone_grand", "action:block_broken:REDSTONE_ORE|DEEPSLATE_REDSTONE_ORE"), + ("q_w_mine_debris_grand", "action:block_broken:ANCIENT_DEBRIS"), + ("q_w_mine_nether_grand", "action:block_broken:*@minecraft:the_nether"), + ("q_w_mine_stone_grand", "action:block_broken:STONE|COBBLESTONE"), + ("q_w_mob_skeletons_grand", "action:mob_kills:SKELETON"), + ("q_w_mob_creepers_grand", "action:mob_kills:CREEPER"), + ("q_w_mob_endermen_grand", "action:mob_kills:ENDERMAN"), + ("q_w_mob_blazes_grand", "action:mob_kills:BLAZE"), + ("q_w_mob_withers_grand", "action:mob_kills:WITHER_SKELETON"), + ("q_w_build_castle", "action:block_placed:STONE_BRICKS|*_STONE_BRICKS"), + ("q_w_build_planks_grand", "action:block_placed:*_PLANKS"), + ("q_w_build_glass_grand", "action:block_placed:GLASS|*_GLASS"), + ("q_w_build_lanterns_grand", "action:block_placed:LANTERN|SOUL_LANTERN|GLOWSTONE"), + ("q_w_build_enclosure", "action:block_placed:*_FENCE|*_WALL"), + ("q_w_farm_logs_grand", "action:block_broken:*_LOG|*_WOOD"), + ("q_w_farm_crops_grand", "action:block_broken:WHEAT|POTATOES|CARROTS|BEETROOTS"), + ("q_w_farm_reeds_grand", "action:block_broken:SUGAR_CANE|BAMBOO"), + ("q_w_farm_pumpkins_grand", "action:block_broken:PUMPKIN|MELON"), + ("q_w_explore_nether_grand2", "action:block_broken:*@minecraft:the_nether"), + ("q_w_explore_ocean", "action:block_broken:PRISMARINE|PRISMARINE_BRICKS|DARK_PRISMARINE|GRAVEL|*_CORAL_BLOCK"), + ("q_w_explore_sand_grand", "action:block_broken:SAND|RED_SAND"), + ("q_w_build_complex", "action:block_placed:STONE_BRICKS|POLISHED_GRANITE"), +]; + +pub async fn upgrade_seeded_quest_targets(pool: &SqlitePool) -> Result<()> { + for (id, target) in SPECIFIC_QUEST_TARGETS { + sqlx::query("UPDATE quests SET target_stat = ? WHERE id = ? AND target_stat IN ('blocks_broken', 'blocks_placed', 'mob_kills')") + .bind(target).bind(id).execute(pool).await?; + } + Ok(()) +} + pub async fn seed_quests_and_achievements(pool: &SqlitePool) -> Result<()> { // Only seed if quests table is empty let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM quests").fetch_one(pool).await?; @@ -40,7 +126,7 @@ async fn seed_quests(pool: &SqlitePool) -> Result<()> { ("q_d_mine_quartz", "Nether Prospecting", "Mine 16 Nether quartz ore blocks", "daily", "mining", "blocks_broken", 16, 180, "sparkles"), ("q_d_mine_debris", "Debris Hunter", "Mine 2 Ancient Debris blocks in the Nether", "daily", "mining", "blocks_broken", 2, 450, "shield"), ("q_d_mine_sand", "Dune Excavator", "Dig 32 sand or gravel blocks", "daily", "mining", "blocks_broken", 32, 90, "layers"), - ("q_d_mine_obsidian", "Obsidian Forger", "Mine 8 obsidian blocks with a diamond pickaxe", "daily", "mining", "blocks_broken", 8, 250, "shield"), + ("q_d_mine_obsidian", "Obsidian Forger", "Mine 8 obsidian blocks", "daily", "mining", "blocks_broken", 8, 250, "shield"), // Daily Combat ("q_d_mob_zombies", "Zombie Purge", "Defeat 8 zombies wandering in the dark", "daily", "combat", "mob_kills", 8, 120, "swords"), @@ -53,7 +139,7 @@ async fn seed_quests(pool: &SqlitePool) -> Result<()> { ("q_d_mob_piglins", "Piglin Skirmish", "Defeat 6 hostile piglins or zombified piglins", "daily", "combat", "mob_kills", 6, 160, "swords"), ("q_d_mob_blazes", "Firestarter", "Defeat 4 blazing fortress defenders", "daily", "combat", "mob_kills", 4, 250, "flame"), ("q_d_mob_drowned", "Deep Water Danger", "Defeat 5 drowned lurkers in rivers or oceans", "daily", "combat", "mob_kills", 5, 140, "droplets"), - ("q_d_mob_monsters", "Night Watch", "Eliminate any 15 hostile monsters", "daily", "combat", "mob_kills", 15, 200, "swords"), + ("q_d_mob_monsters", "Night Watch", "Defeat any 15 mobs", "daily", "combat", "mob_kills", 15, 200, "swords"), ("q_d_pvp_champion", "Gladiator's Glory", "Defeat 1 player in honorable combat", "daily", "combat", "player_kills", 1, 250, "trophy"), // Daily Building @@ -63,10 +149,10 @@ async fn seed_quests(pool: &SqlitePool) -> Result<()> { ("q_d_build_torches", "Let There Be Light", "Place 20 torches to illuminate the area", "daily", "building", "blocks_placed", 20, 90, "flame"), ("q_d_build_lanterns", "Cozy Hearth", "Place 6 lanterns around your settlement", "daily", "building", "blocks_placed", 6, 120, "sparkles"), ("q_d_build_slabs", "Tiered Architecture", "Place 24 slabs or stairs", "daily", "building", "blocks_placed", 24, 110, "layers"), - ("q_d_build_roof", "Under Cover", "Place 50 roofing or decorative blocks", "daily", "building", "blocks_placed", 50, 140, "home"), + ("q_d_build_roof", "Under Cover", "Place 50 blocks of any kind", "daily", "building", "blocks_placed", 50, 140, "home"), ("q_d_build_walls", "Fortified Walls", "Place 30 cobblestone or deepslate walls", "daily", "building", "blocks_placed", 30, 130, "shield"), ("q_d_build_general", "Master Builder", "Place 100 blocks of any material", "daily", "building", "blocks_placed", 100, 180, "hammer"), - ("q_d_build_monument", "Grand Monument", "Place 150 blocks toward a new structure", "daily", "building", "blocks_placed", 150, 220, "castle"), + ("q_d_build_monument", "Grand Monument", "Place 150 blocks of any kind", "daily", "building", "blocks_placed", 150, 220, "castle"), // Daily Farming & Harvesting ("q_d_farm_wood", "Lumberjack", "Chop down 32 wood logs", "daily", "farming", "blocks_broken", 32, 120, "axe"), @@ -83,12 +169,12 @@ async fn seed_quests(pool: &SqlitePool) -> Result<()> { // Daily Social & Playtime ("q_d_play_15m", "Quick Check-in", "Play on any server for 15 minutes", "daily", "social", "playtime_secs", 900, 100, "clock"), ("q_d_play_30m", "Dedicated Citizen", "Spend 30 minutes in game", "daily", "social", "playtime_secs", 1800, 180, "clock"), - ("q_d_play_60m", "Hour of Power", "Play for a full continuous hour", "daily", "social", "playtime_secs", 3600, 250, "clock"), - ("q_d_chat_chatty", "Good Morning", "Say hello and send 5 chat messages", "daily", "social", "messages", 5, 80, "message-square"), + ("q_d_play_60m", "Hour of Power", "Play for a total of one hour", "daily", "social", "playtime_secs", 3600, 250, "clock"), + ("q_d_chat_chatty", "Good Morning", "Send 5 chat messages", "daily", "social", "messages", 5, 80, "message-square"), ("q_d_chat_talkative", "Community Voice", "Send 15 chat messages participating in chat", "daily", "social", "messages", 15, 120, "message-square"), ("q_d_join_again", "Frequent Flyer", "Join a server 2 distinct times today", "daily", "social", "joins", 2, 80, "door-open"), ("q_d_explore_depths", "Into the Deep", "Mine 30 tuff or deepslate tiles", "daily", "exploration", "blocks_broken", 30, 110, "compass"), - ("q_d_explore_caves", "Spelunker", "Break 50 blocks exploring natural cave systems", "daily", "exploration", "blocks_broken", 50, 130, "compass"), + ("q_d_explore_caves", "Spelunker", "Break 50 blocks of any kind", "daily", "exploration", "blocks_broken", 50, 130, "compass"), ("q_d_explore_diorite", "Geologist", "Collect 20 granite, diorite, or andesite blocks", "daily", "exploration", "blocks_broken", 20, 100, "mountain"), ("q_d_explore_nether", "Nether Expedition", "Mine 40 nether blocks while surviving", "daily", "exploration", "blocks_broken", 40, 160, "flame"), ("q_d_explore_clay", "Clay Digger", "Excavate 16 clay blocks from river beds", "daily", "exploration", "blocks_broken", 16, 110, "droplets"), @@ -106,7 +192,7 @@ async fn seed_quests(pool: &SqlitePool) -> Result<()> { ("q_w_mine_nether_grand", "Underworld Quarry", "Break 300 blocks in the Nether dimension", "weekly", "mining", "blocks_broken", 300, 700, "flame"), ("q_w_mine_stone_grand", "Mountain Mover", "Break 1,000 stone or cobblestone blocks", "weekly", "mining", "blocks_broken", 1000, 1100, "mountain"), - ("q_w_mob_purge_grand", "Monster Exterminator", "Eliminate 100 hostile monsters", "weekly", "combat", "mob_kills", 100, 1000, "swords"), + ("q_w_mob_purge_grand", "Monster Exterminator", "Defeat 100 mobs", "weekly", "combat", "mob_kills", 100, 1000, "swords"), ("q_w_mob_skeletons_grand", "Archer's Nemesis", "Defeat 40 skeleton sharpshooters", "weekly", "combat", "mob_kills", 40, 750, "crosshair"), ("q_w_mob_creepers_grand", "Demolition Defuser", "Take down 30 dangerous creepers", "weekly", "combat", "mob_kills", 30, 850, "flame"), ("q_w_mob_endermen_grand", "Void Hunter", "Defeat 15 elusive Endermen", "weekly", "combat", "mob_kills", 15, 1100, "eye"), @@ -132,9 +218,9 @@ async fn seed_quests(pool: &SqlitePool) -> Result<()> { ("q_w_play_15h", "No-Lifer Supremacy", "Clock in 15 hours of active gameplay", "weekly", "social", "playtime_secs", 54000, 2500, "zap"), ("q_w_chat_social", "Town Hall Speaker", "Contribute 50 chat messages across community servers", "weekly", "social", "messages", 50, 600, "message-square"), ("q_w_joins_loyal", "Daily Habits", "Join game servers on at least 5 occasions", "weekly", "social", "joins", 5, 700, "door-open"), - ("q_w_explore_caves_grand", "Underground Cartographer", "Break 400 blocks deep underground", "weekly", "exploration", "blocks_broken", 400, 850, "compass"), + ("q_w_explore_caves_grand", "Underground Cartographer", "Break 400 blocks of any kind", "weekly", "exploration", "blocks_broken", 400, 850, "compass"), ("q_w_explore_nether_grand2", "Infernal Pioneer", "Travel and mine 250 blocks in the Nether", "weekly", "exploration", "blocks_broken", 250, 800, "flame"), - ("q_w_explore_ocean", "Abyssal Diver", "Break 100 prismarine, gravel, or coral underwater", "weekly", "exploration", "blocks_broken", 100, 750, "droplets"), + ("q_w_explore_ocean", "Abyssal Diver", "Break 100 prismarine, gravel, or coral blocks", "weekly", "exploration", "blocks_broken", 100, 750, "droplets"), ("q_w_explore_sand_grand", "Desert Terraformer", "Dig 300 sand or red sand blocks", "weekly", "exploration", "blocks_broken", 300, 650, "layers"), ("q_w_build_complex", "Civil Engineer", "Place 600 stone bricks or polished granite", "weekly", "building", "blocks_placed", 600, 950, "box"), ]; @@ -149,7 +235,7 @@ async fn seed_quests(pool: &SqlitePool) -> Result<()> { .bind(desc) .bind(period) .bind(cat) - .bind(stat) + .bind(SPECIFIC_QUEST_TARGETS.iter().find(|(quest_id, _)| *quest_id == id).map(|(_, target)| *target).unwrap_or(stat)) .bind(target) .bind(xp) .bind(icon) diff --git a/panel/server/tests/features_v2.rs b/panel/server/tests/features_v2.rs index 25a960b..4bc0046 100644 --- a/panel/server/tests/features_v2.rs +++ b/panel/server/tests/features_v2.rs @@ -81,7 +81,7 @@ async fn leveling_and_quests_progression() { "playtime_secs": 60 } ], - "events": [] + "events": [{"uuid": &alex_uuid, "kind": "action", "detail": "block_broken:DIAMOND_ORE@minecraft:overworld +4"}] })), ) .await; @@ -92,6 +92,12 @@ async fn leveling_and_quests_progression() { assert_eq!(s, StatusCode::OK, "{lvl_after}"); let current_xp = lvl_after["global_xp"].as_i64().unwrap(); assert!(current_xp > 0, "Alex should have earned XP"); + let (s, quests_after) = t.call("GET", "/api/v1/quests/me", Some(&alex_token), None).await; + assert_eq!(s, StatusCode::OK); + let progress = |id: &str| quests_after.as_array().unwrap().iter() + .find(|q| q["quest"]["id"] == id).unwrap()["progress"].as_i64().unwrap(); + assert_eq!(progress("q_d_mine_diamond"), 4); + assert_eq!(progress("q_d_mine_coal"), 0); // 5. Game Server sync with leveling DISABLED (server host turned it off in config) let (s, sync_off) = t @@ -137,6 +143,9 @@ async fn guilds_and_land_claims() { let admin = t.login("admin", "supersecret").await; let (alex_token, _, alex_uuid, steve_uuid) = setup_users(&t).await; let (server_id, server_token) = create_test_server(&t, &admin).await; + let (status, updated) = t.call("PUT", &format!("/api/admin/servers/{server_id}"), Some(&admin), + Some(json!({"name": "Survival Hub", "instance_id": "smp-pack"}))).await; + assert_eq!(status, StatusCode::OK, "{updated}"); // 1. Alex founds a guild let (s, guild) = t @@ -247,6 +256,14 @@ async fn guilds_and_land_claims() { .await; assert_eq!(s, StatusCode::OK); assert_eq!(check_unclaimed["allowed"], true); + + // The in-game command uses the server unclaim route rather than DELETE. + let (s, _) = t.call("POST", "/api/server/v1/guilds/claim", Some(&server_token), + Some(json!({"uuid": &alex_uuid, "dimension": "minecraft:overworld", "chunk_x": 3, "chunk_z": 7}))).await; + assert_eq!(s, StatusCode::OK); + let (s, response) = t.call("POST", "/api/server/v1/guilds/unclaim", Some(&server_token), + Some(json!({"uuid": &alex_uuid, "dimension": "minecraft:overworld", "chunk_x": 3, "chunk_z": 7}))).await; + assert_eq!(s, StatusCode::OK, "{response}"); } #[tokio::test] @@ -332,10 +349,17 @@ async fn social_friends_dms_and_profiles() { let (s, like_res) = t.call("POST", &format!("/api/v1/social/posts/{post_id}/like"), Some(&steve_token), None).await; assert_eq!(s, StatusCode::OK, "{like_res}"); assert_eq!(like_res["ok"], true); + let (s, repeat_like) = t.call("POST", &format!("/api/v1/social/posts/{post_id}/like"), Some(&steve_token), None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(repeat_like["liked"], false); // Steve views Alex's full profile let (s, profile) = t.call("GET", &format!("/api/v1/social/profile/{alex_uuid}"), Some(&steve_token), None).await; assert_eq!(s, StatusCode::OK, "{profile}"); assert_eq!(profile["bio"], "Redstone expert and dragon hunter."); assert_eq!(profile["posts"][0]["likes_count"], 1); + assert_eq!(profile["posts"][0]["liked_by_me"], true); + let (s, author_profile) = t.call("GET", &format!("/api/v1/social/profile/{alex_uuid}"), Some(&alex_token), None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(author_profile["posts"][0]["liked_by_me"], false); }