diff --git a/gradle.properties b/gradle.properties index b6ca6d0930..506ecdcb45 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -pluginVersion=1.219.0 +pluginVersion=1.220.0 diff --git a/nms/java21/src/main/java/io/th0rgal/oraxen/nms/handler/java21/NMSHandler.java b/nms/java21/src/main/java/io/th0rgal/oraxen/nms/handler/java21/NMSHandler.java index c3e31662d5..1274c0aa51 100644 --- a/nms/java21/src/main/java/io/th0rgal/oraxen/nms/handler/java21/NMSHandler.java +++ b/nms/java21/src/main/java/io/th0rgal/oraxen/nms/handler/java21/NMSHandler.java @@ -33,7 +33,6 @@ import net.minecraft.resources.ResourceKey; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityType; -import net.minecraft.world.entity.PositionMoveRotation; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.sounds.SoundEvent; @@ -49,6 +48,7 @@ import net.minecraft.world.item.JukeboxSong; import net.minecraft.world.item.component.Consumable; import net.minecraft.world.item.component.CustomData; +import net.minecraft.world.item.component.DeathProtection; import net.minecraft.world.item.consume_effects.*; import net.minecraft.world.item.context.BlockPlaceContext; import net.minecraft.world.item.context.DirectionalPlaceContext; @@ -71,10 +71,9 @@ import org.bukkit.craftbukkit.entity.CraftPlayer; import org.bukkit.craftbukkit.inventory.CraftItemStack; import org.bukkit.entity.Player; +import org.bukkit.event.Listener; import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.components.FoodComponent; -import org.bukkit.event.Listener; import org.jetbrains.annotations.NotNull; import javax.annotation.Nullable; @@ -83,11 +82,16 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.*; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; public class NMSHandler implements io.th0rgal.oraxen.nms.NMSHandler { private final Listener packDispatchListener; + private final Map> pendingBlockChanges = new ConcurrentHashMap<>(); + + private record PendingBlockChange(int sequence, int x, int y, int z, boolean placement) { + } public NMSHandler() { // Paper exposed the configuration/reconfiguration events used by the pre-join @@ -115,11 +119,63 @@ public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) tags.put(Registries.BLOCK, payload); msg = new ClientboundUpdateTagsPacket(tags); } + if (msg instanceof ClientboundBlockChangedAckPacket packet) { + final Deque pending = pendingBlockChanges.get(ctx.channel()); + if (pending != null) + pending.removeIf(change -> change.sequence() <= packet.sequence()); + } ctx.write(msg, promise); } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + // Bukkit block events do not expose the packet sequence. Retain the + // position and operation so the matching prediction can be settled as + // soon as its authoritative block states have been sent. + final Deque pending = + pendingBlockChanges.computeIfAbsent(ctx.channel(), ignored -> new ConcurrentLinkedDeque<>()); + if (msg instanceof ServerboundUseItemOnPacket packet) { + final BlockPos pos = packet.getHitResult().getBlockPos(); + pending.addLast(new PendingBlockChange(packet.getSequence(), pos.getX(), pos.getY(), pos.getZ(), true)); + } else if (msg instanceof ServerboundPlayerActionPacket packet + && packet.getAction() == ServerboundPlayerActionPacket.Action.START_DESTROY_BLOCK) { + final BlockPos pos = packet.getPos(); + pending.addLast(new PendingBlockChange(packet.getSequence(), pos.getX(), pos.getY(), pos.getZ(), false)); + } + while (pending.size() > 16) pending.pollFirst(); + super.channelRead(ctx, msg); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + pendingBlockChanges.remove(ctx.channel()); + super.channelInactive(ctx); + } }))); } + @Override + public void acknowledgeBlockChanges(Player player, Location packetBlock, boolean placement) { + final ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle(); + final Connection connection = serverPlayer.connection.connection; + final Deque pending = pendingBlockChanges.get(connection.channel); + if (pending == null) return; + + PendingBlockChange matched = null; + for (final PendingBlockChange change : pending) { + if (change.placement() == placement && change.x() == packetBlock.getBlockX() + && change.y() == packetBlock.getBlockY() && change.z() == packetBlock.getBlockZ()) { + matched = change; + break; + } + } + if (matched == null) return; + + final int sequence = matched.sequence(); + pending.removeIf(change -> change.sequence() <= sequence); + serverPlayer.connection.send(new ClientboundBlockChangedAckPacket(sequence)); + } + @Override public Listener packDispatchListener() { return packDispatchListener; @@ -216,8 +272,8 @@ public BlockData correctBlockStates(Player player, EquipmentSlot slot, ItemStack InteractionResult result = blockItem.place(placeContext); if (result == InteractionResult.FAIL) return null; - if (placeContext instanceof DirectionalPlaceContext && player.getGameMode() != org.bukkit.GameMode.CREATIVE) - itemStack.setAmount(itemStack.getAmount() - 1); + if (player.getGameMode() != org.bukkit.GameMode.CREATIVE) + itemStack.setAmount(nmsStack.getCount()); World world = player.getWorld(); BlockPos placedPos = placeContext.getClickedPos(); @@ -249,11 +305,6 @@ public BlockHitResult getPlayerPOVHitResult(Level world, net.minecraft.world.ent return world.clip(new ClipContext(vec3, vec32, ClipContext.Block.OUTLINE, fluidHandling, player)); } - @Override - public void customBlockDefaultTools(Player player) { - - } - private TagNetworkSerialization.NetworkPayload createPayload() { Constructor constructor = Arrays .stream(TagNetworkSerialization.NetworkPayload.class.getDeclaredConstructors()).findFirst() @@ -288,11 +339,6 @@ private Map createTagRegistryMap() { }).collect(HashMap::new, Map::putAll, Map::putAll); } - @Override - public boolean getSupported() { - return true; - } - /** * Sets a component on an item using the DataComponents registry * @@ -449,24 +495,6 @@ private void handleListValue(net.minecraft.nbt.CompoundTag nbt, String key, List } } - @SuppressWarnings("UnstableApiUsage") - @Override - public void foodComponent(ItemBuilder item, ConfigurationSection foodSection) { - FoodComponent foodComponent = new ItemStack(item.getType()).getItemMeta().getFood(); - - // Ensure nutrition is non-negative - int nutrition = Math.max(foodSection.getInt("nutrition"), 0); - foodComponent.setNutrition(nutrition); - - // Ensure saturation is non-negative - float saturation = Math.max((float) foodSection.getDouble("saturation", 0.0), 0f); - foodComponent.setSaturation(saturation); - - foodComponent.setCanAlwaysEat(foodSection.getBoolean("can_always_eat", false)); - - item.setFoodComponent(foodComponent); - } - @SuppressWarnings("UnstableApiUsage") @Override public void consumableComponent(ItemBuilder item, ConfigurationSection section) { @@ -522,6 +550,105 @@ public void consumableComponent(ItemBuilder item, ConfigurationSection section) item.setConsumableComponent(consumable.build()); } + @Override + public void deathProtectionComponent(ItemBuilder item, ConfigurationSection section) { + List effects = parseDeathProtectionEffects(section.getMapList("death_effects")); + item.setDeathProtectionComponent(new DeathProtection(effects)); + } + + private List parseDeathProtectionEffects(List> effectSections) { + List effects = new ArrayList<>(); + + for (Map effectSection : effectSections) { + String type = Optional.ofNullable(effectSection.get("type")) + .map(Object::toString) + .orElse(""); + + switch (type.toLowerCase(Locale.ROOT)) { + case "apply_effects" -> addDeathProtectionStatusEffects(effects, effectSection); + case "remove_effects" -> addDeathProtectionRemoveEffects(effects, effectSection); + case "clear_all_effects" -> effects.add(new ClearAllStatusEffectsConsumeEffect()); + case "teleport_randomly" -> { + float diameter = parseFloatValue(effectSection.get("diameter"), 16f, + "death_protection.teleport_randomly.diameter"); + effects.add(new TeleportRandomlyConsumeEffect(diameter)); + } + case "play_sound" -> { + String soundId = Optional.ofNullable(effectSection.get("sound")) + .map(Object::toString) + .orElse(null); + if (soundId != null) { + SoundEvent soundEvent = getSoundEventFromId(soundId); + if (soundEvent != null) + effects.add(new PlaySoundConsumeEffect(Holder.direct(soundEvent))); + } + } + default -> Logs.logWarning("Invalid death_protection ConsumeEffect-Type " + type); + } + } + + return effects; + } + + private void addDeathProtectionStatusEffects(List effects, Map effectSection) { + if (!(effectSection.get("effects") instanceof Map configuredEffects)) + return; + + float probability = Math.max(0f, Math.min(1f, + parseFloatValue(effectSection.get("probability"), 1f, "death_protection.probability"))); + List statusEffects = new ArrayList<>(); + + for (Map.Entry entry : configuredEffects.entrySet()) { + String effectId = entry.getKey().toString(); + if (!(entry.getValue() instanceof Map rawMap)) { + Logs.logWarning("Invalid death_protection effect data for " + effectId + ": expected map"); + continue; + } + + Map effectData = new HashMap<>(); + for (Map.Entry effectEntry : rawMap.entrySet()) + effectData.put(String.valueOf(effectEntry.getKey()), effectEntry.getValue()); + + getMobEffectOptional(effectId) + .map(BuiltInRegistries.MOB_EFFECT::wrapAsHolder) + .ifPresentOrElse(effect -> { + int duration = Math.max(parseIntegerValue(effectData.get("duration"), 1, "duration", effectId), 0) * 20; + int amplifier = Math.max(parseIntegerValue(effectData.get("amplifier"), 0, "amplifier", effectId), 0); + boolean ambient = Optional.ofNullable(effectData.get("ambient")) + .map(value -> Boolean.parseBoolean(value.toString())) + .orElse(false); + boolean particles = Optional.ofNullable(effectData.get("show_particles")) + .map(value -> Boolean.parseBoolean(value.toString())) + .orElse(true); + boolean icon = Optional.ofNullable(effectData.get("show_icon")) + .map(value -> Boolean.parseBoolean(value.toString())) + .orElse(true); + + statusEffects.add(new MobEffectInstance( + effect, duration, amplifier, ambient, particles, icon)); + }, () -> Logs.logWarning("Invalid potion effect in death_protection: " + effectId)); + } + + if (!statusEffects.isEmpty()) + effects.add(new ApplyStatusEffectsConsumeEffect(statusEffects, probability)); + } + + private void addDeathProtectionRemoveEffects(List effects, Map effectSection) { + if (!(effectSection.get("effects") instanceof List effectIds)) + return; + + List> mobEffects = effectIds.stream() + .map(Object::toString) + .map(this::getMobEffectOptional) + .filter(Optional::isPresent) + .map(Optional::get) + .map(BuiltInRegistries.MOB_EFFECT::wrapAsHolder) + .toList(); + + if (!mobEffects.isEmpty()) + effects.add(new RemoveStatusEffectsConsumeEffect(HolderSet.direct(mobEffects))); + } + private void handleApplyEffects(Consumable.Builder consumable, Map effectSection) { if (!(effectSection.get("effects") instanceof Map effects)) return; @@ -711,6 +838,20 @@ public Object consumableComponent(final ItemStack itemStack) { return null; } + @Override + @Nullable + public Object deathProtectionComponent(final ItemStack itemStack) { + if (itemStack == null) + return null; + try { + net.minecraft.world.item.ItemStack nmsItem = CraftItemStack.asNMSCopy(itemStack); + return nmsItem.get(DataComponents.DEATH_PROTECTION); + } catch (Exception e) { + Logs.debug(e); + } + return null; + } + @Override public ItemStack consumableComponent(final ItemStack itemStack, @Nullable Object consumable) { if (consumable == null) @@ -725,6 +866,20 @@ public ItemStack consumableComponent(final ItemStack itemStack, @Nullable Object return itemStack; } + @Override + public ItemStack deathProtectionComponent(final ItemStack itemStack, @Nullable Object deathProtection) { + if (!(deathProtection instanceof DeathProtection component)) + return itemStack; + try { + net.minecraft.world.item.ItemStack nmsItem = CraftItemStack.asNMSCopy(itemStack); + nmsItem.set(DataComponents.DEATH_PROTECTION, component); + return asBukkitCopy(nmsItem); + } catch (Exception e) { + Logs.debug(e); + } + return itemStack; + } + @Override public boolean supportsJukeboxPlaying() { return true; @@ -743,23 +898,8 @@ public void playJukeBoxSong(Location location, ItemStack itemStack) { new BlockPos(location.getBlockX(), location.getBlockY(), location.getBlockZ()), id); } - @Override - public void stopJukeBox(Location location) { - if (location == null || location.getWorld() == null) return; - ServerLevel level = ((CraftWorld) location.getWorld()).getHandle().getLevel(); - level.levelEvent(null, LevelEvent.SOUND_STOP_JUKEBOX_SONG, - new BlockPos(location.getBlockX(), location.getBlockY(), location.getBlockZ()), 0); - } - // ============ Backpack Cosmetic Packet Methods ============ - private static final AtomicInteger ENTITY_ID_COUNTER = new AtomicInteger(Integer.MAX_VALUE / 2); - - @Override - public int getNextEntityId() { - return ENTITY_ID_COUNTER.decrementAndGet(); - } - private static EntityType getEntityType(String entityId) { try { Object location = ResourceLocationHelper.parse(entityId); @@ -837,27 +977,6 @@ public void spawnBackpackArmorStand(Player viewer, int entityId, Location locati } } - @Override - public void sendEntityTeleport(Player viewer, int entityId, Location location) { - ServerPlayer serverPlayer = ((CraftPlayer) viewer).getHandle(); - Connection connection = serverPlayer.connection.connection; - - // Create position/rotation data - PositionMoveRotation positionData = new PositionMoveRotation( - new Vec3(location.getX(), location.getY(), location.getZ()), - Vec3.ZERO, // delta movement - location.getYaw(), - location.getPitch() - ); - - ClientboundEntityPositionSyncPacket teleportPacket = new ClientboundEntityPositionSyncPacket( - entityId, - positionData, - false // on ground - ); - connection.send(teleportPacket); - } - @Override public void sendEntityHeadRotation(Player viewer, int entityId, float yaw) { ServerPlayer serverPlayer = ((CraftPlayer) viewer).getHandle(); diff --git a/nms/java25/src/main/java/io/th0rgal/oraxen/nms/handler/java25/NMSHandler.java b/nms/java25/src/main/java/io/th0rgal/oraxen/nms/handler/java25/NMSHandler.java index 6bc4804d11..f7e8682caf 100644 --- a/nms/java25/src/main/java/io/th0rgal/oraxen/nms/handler/java25/NMSHandler.java +++ b/nms/java25/src/main/java/io/th0rgal/oraxen/nms/handler/java25/NMSHandler.java @@ -33,7 +33,6 @@ import net.minecraft.resources.ResourceKey; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityType; -import net.minecraft.world.entity.PositionMoveRotation; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.sounds.SoundEvent; @@ -49,6 +48,7 @@ import net.minecraft.world.item.JukeboxSong; import net.minecraft.world.item.component.Consumable; import net.minecraft.world.item.component.CustomData; +import net.minecraft.world.item.component.DeathProtection; import net.minecraft.world.item.consume_effects.*; import net.minecraft.world.item.context.BlockPlaceContext; import net.minecraft.world.item.context.DirectionalPlaceContext; @@ -71,10 +71,9 @@ import org.bukkit.craftbukkit.entity.CraftPlayer; import org.bukkit.craftbukkit.inventory.CraftItemStack; import org.bukkit.entity.Player; +import org.bukkit.event.Listener; import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.components.FoodComponent; -import org.bukkit.event.Listener; import org.jetbrains.annotations.NotNull; import javax.annotation.Nullable; @@ -83,11 +82,16 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.*; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; public class NMSHandler implements io.th0rgal.oraxen.nms.NMSHandler { private final Listener packDispatchListener; + private final Map> pendingBlockChanges = new ConcurrentHashMap<>(); + + private record PendingBlockChange(int sequence, int x, int y, int z, boolean placement) { + } public NMSHandler() { // Paper exposed the configuration/reconfiguration events used by the pre-join @@ -115,11 +119,63 @@ public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) tags.put(Registries.BLOCK, payload); msg = new ClientboundUpdateTagsPacket(tags); } + if (msg instanceof ClientboundBlockChangedAckPacket packet) { + final Deque pending = pendingBlockChanges.get(ctx.channel()); + if (pending != null) + pending.removeIf(change -> change.sequence() <= packet.sequence()); + } ctx.write(msg, promise); } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + // Bukkit block events do not expose the packet sequence. Retain the + // position and operation so the matching prediction can be settled as + // soon as its authoritative block states have been sent. + final Deque pending = + pendingBlockChanges.computeIfAbsent(ctx.channel(), ignored -> new ConcurrentLinkedDeque<>()); + if (msg instanceof ServerboundUseItemOnPacket packet) { + final BlockPos pos = packet.getHitResult().getBlockPos(); + pending.addLast(new PendingBlockChange(packet.getSequence(), pos.getX(), pos.getY(), pos.getZ(), true)); + } else if (msg instanceof ServerboundPlayerActionPacket packet + && packet.getAction() == ServerboundPlayerActionPacket.Action.START_DESTROY_BLOCK) { + final BlockPos pos = packet.getPos(); + pending.addLast(new PendingBlockChange(packet.getSequence(), pos.getX(), pos.getY(), pos.getZ(), false)); + } + while (pending.size() > 16) pending.pollFirst(); + super.channelRead(ctx, msg); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + pendingBlockChanges.remove(ctx.channel()); + super.channelInactive(ctx); + } }))); } + @Override + public void acknowledgeBlockChanges(Player player, Location packetBlock, boolean placement) { + final ServerPlayer serverPlayer = ((CraftPlayer) player).getHandle(); + final Connection connection = serverPlayer.connection.connection; + final Deque pending = pendingBlockChanges.get(connection.channel); + if (pending == null) return; + + PendingBlockChange matched = null; + for (final PendingBlockChange change : pending) { + if (change.placement() == placement && change.x() == packetBlock.getBlockX() + && change.y() == packetBlock.getBlockY() && change.z() == packetBlock.getBlockZ()) { + matched = change; + break; + } + } + if (matched == null) return; + + final int sequence = matched.sequence(); + pending.removeIf(change -> change.sequence() <= sequence); + serverPlayer.connection.send(new ClientboundBlockChangedAckPacket(sequence)); + } + @Override public Listener packDispatchListener() { return packDispatchListener; @@ -194,8 +250,8 @@ public BlockData correctBlockStates(Player player, EquipmentSlot slot, ItemStack InteractionResult result = blockItem.place(placeContext); if (result == InteractionResult.FAIL) return null; - if (placeContext instanceof DirectionalPlaceContext && player.getGameMode() != org.bukkit.GameMode.CREATIVE) - itemStack.setAmount(itemStack.getAmount() - 1); + if (player.getGameMode() != org.bukkit.GameMode.CREATIVE) + itemStack.setAmount(nmsStack.getCount()); World world = player.getWorld(); BlockPos placedPos = placeContext.getClickedPos(); @@ -227,11 +283,6 @@ public BlockHitResult getPlayerPOVHitResult(Level world, net.minecraft.world.ent return world.clip(new ClipContext(vec3, vec32, ClipContext.Block.OUTLINE, fluidHandling, player)); } - @Override - public void customBlockDefaultTools(Player player) { - - } - private TagNetworkSerialization.NetworkPayload createPayload() { Constructor constructor = Arrays .stream(TagNetworkSerialization.NetworkPayload.class.getDeclaredConstructors()).findFirst() @@ -265,11 +316,6 @@ private Map createTagRegistryMap() { }).collect(HashMap::new, Map::putAll, Map::putAll); } - @Override - public boolean getSupported() { - return true; - } - /** * Sets a component on an item using the DataComponents registry * @@ -426,24 +472,6 @@ private void handleListValue(net.minecraft.nbt.CompoundTag nbt, String key, List } } - @SuppressWarnings("UnstableApiUsage") - @Override - public void foodComponent(ItemBuilder item, ConfigurationSection foodSection) { - FoodComponent foodComponent = new ItemStack(item.getType()).getItemMeta().getFood(); - - // Ensure nutrition is non-negative - int nutrition = Math.max(foodSection.getInt("nutrition"), 0); - foodComponent.setNutrition(nutrition); - - // Ensure saturation is non-negative - float saturation = Math.max((float) foodSection.getDouble("saturation", 0.0), 0f); - foodComponent.setSaturation(saturation); - - foodComponent.setCanAlwaysEat(foodSection.getBoolean("can_always_eat", false)); - - item.setFoodComponent(foodComponent); - } - @SuppressWarnings("UnstableApiUsage") @Override public void consumableComponent(ItemBuilder item, ConfigurationSection section) { @@ -499,6 +527,105 @@ public void consumableComponent(ItemBuilder item, ConfigurationSection section) item.setConsumableComponent(consumable.build()); } + @Override + public void deathProtectionComponent(ItemBuilder item, ConfigurationSection section) { + List effects = parseDeathProtectionEffects(section.getMapList("death_effects")); + item.setDeathProtectionComponent(new DeathProtection(effects)); + } + + private List parseDeathProtectionEffects(List> effectSections) { + List effects = new ArrayList<>(); + + for (Map effectSection : effectSections) { + String type = Optional.ofNullable(effectSection.get("type")) + .map(Object::toString) + .orElse(""); + + switch (type.toLowerCase(Locale.ROOT)) { + case "apply_effects" -> addDeathProtectionStatusEffects(effects, effectSection); + case "remove_effects" -> addDeathProtectionRemoveEffects(effects, effectSection); + case "clear_all_effects" -> effects.add(new ClearAllStatusEffectsConsumeEffect()); + case "teleport_randomly" -> { + float diameter = parseFloatValue(effectSection.get("diameter"), 16f, + "death_protection.teleport_randomly.diameter"); + effects.add(new TeleportRandomlyConsumeEffect(diameter)); + } + case "play_sound" -> { + String soundId = Optional.ofNullable(effectSection.get("sound")) + .map(Object::toString) + .orElse(null); + if (soundId != null) { + SoundEvent soundEvent = getSoundEventFromId(soundId); + if (soundEvent != null) + effects.add(new PlaySoundConsumeEffect(Holder.direct(soundEvent))); + } + } + default -> Logs.logWarning("Invalid death_protection ConsumeEffect-Type " + type); + } + } + + return effects; + } + + private void addDeathProtectionStatusEffects(List effects, Map effectSection) { + if (!(effectSection.get("effects") instanceof Map configuredEffects)) + return; + + float probability = Math.max(0f, Math.min(1f, + parseFloatValue(effectSection.get("probability"), 1f, "death_protection.probability"))); + List statusEffects = new ArrayList<>(); + + for (Map.Entry entry : configuredEffects.entrySet()) { + String effectId = entry.getKey().toString(); + if (!(entry.getValue() instanceof Map rawMap)) { + Logs.logWarning("Invalid death_protection effect data for " + effectId + ": expected map"); + continue; + } + + Map effectData = new HashMap<>(); + for (Map.Entry effectEntry : rawMap.entrySet()) + effectData.put(String.valueOf(effectEntry.getKey()), effectEntry.getValue()); + + getMobEffectOptional(effectId) + .map(BuiltInRegistries.MOB_EFFECT::wrapAsHolder) + .ifPresentOrElse(effect -> { + int duration = Math.max(parseIntegerValue(effectData.get("duration"), 1, "duration", effectId), 0) * 20; + int amplifier = Math.max(parseIntegerValue(effectData.get("amplifier"), 0, "amplifier", effectId), 0); + boolean ambient = Optional.ofNullable(effectData.get("ambient")) + .map(value -> Boolean.parseBoolean(value.toString())) + .orElse(false); + boolean particles = Optional.ofNullable(effectData.get("show_particles")) + .map(value -> Boolean.parseBoolean(value.toString())) + .orElse(true); + boolean icon = Optional.ofNullable(effectData.get("show_icon")) + .map(value -> Boolean.parseBoolean(value.toString())) + .orElse(true); + + statusEffects.add(new MobEffectInstance( + effect, duration, amplifier, ambient, particles, icon)); + }, () -> Logs.logWarning("Invalid potion effect in death_protection: " + effectId)); + } + + if (!statusEffects.isEmpty()) + effects.add(new ApplyStatusEffectsConsumeEffect(statusEffects, probability)); + } + + private void addDeathProtectionRemoveEffects(List effects, Map effectSection) { + if (!(effectSection.get("effects") instanceof List effectIds)) + return; + + List> mobEffects = effectIds.stream() + .map(Object::toString) + .map(this::getMobEffectOptional) + .filter(Optional::isPresent) + .map(Optional::get) + .map(BuiltInRegistries.MOB_EFFECT::wrapAsHolder) + .toList(); + + if (!mobEffects.isEmpty()) + effects.add(new RemoveStatusEffectsConsumeEffect(HolderSet.direct(mobEffects))); + } + private void handleApplyEffects(Consumable.Builder consumable, Map effectSection) { if (!(effectSection.get("effects") instanceof Map effects)) return; @@ -688,6 +815,20 @@ public Object consumableComponent(final ItemStack itemStack) { return null; } + @Override + @Nullable + public Object deathProtectionComponent(final ItemStack itemStack) { + if (itemStack == null) + return null; + try { + net.minecraft.world.item.ItemStack nmsItem = CraftItemStack.asNMSCopy(itemStack); + return nmsItem.get(DataComponents.DEATH_PROTECTION); + } catch (Exception e) { + Logs.debug(e); + } + return null; + } + @Override public ItemStack consumableComponent(final ItemStack itemStack, @Nullable Object consumable) { if (consumable == null) @@ -702,6 +843,20 @@ public ItemStack consumableComponent(final ItemStack itemStack, @Nullable Object return itemStack; } + @Override + public ItemStack deathProtectionComponent(final ItemStack itemStack, @Nullable Object deathProtection) { + if (!(deathProtection instanceof DeathProtection component)) + return itemStack; + try { + net.minecraft.world.item.ItemStack nmsItem = CraftItemStack.asNMSCopy(itemStack); + nmsItem.set(DataComponents.DEATH_PROTECTION, component); + return asBukkitCopy(nmsItem); + } catch (Exception e) { + Logs.debug(e); + } + return itemStack; + } + @Override public boolean supportsJukeboxPlaying() { return true; @@ -720,23 +875,8 @@ public void playJukeBoxSong(Location location, ItemStack itemStack) { new BlockPos(location.getBlockX(), location.getBlockY(), location.getBlockZ()), id); } - @Override - public void stopJukeBox(Location location) { - if (location == null || location.getWorld() == null) return; - ServerLevel level = ((CraftWorld) location.getWorld()).getHandle().getLevel(); - level.levelEvent(null, LevelEvent.SOUND_STOP_JUKEBOX_SONG, - new BlockPos(location.getBlockX(), location.getBlockY(), location.getBlockZ()), 0); - } - // ============ Backpack Cosmetic Packet Methods ============ - private static final AtomicInteger ENTITY_ID_COUNTER = new AtomicInteger(Integer.MAX_VALUE / 2); - - @Override - public int getNextEntityId() { - return ENTITY_ID_COUNTER.decrementAndGet(); - } - private static EntityType getEntityType(String entityId) { try { Object location = ResourceLocationHelper.parse(entityId); @@ -814,27 +954,6 @@ public void spawnBackpackArmorStand(Player viewer, int entityId, Location locati } } - @Override - public void sendEntityTeleport(Player viewer, int entityId, Location location) { - ServerPlayer serverPlayer = ((CraftPlayer) viewer).getHandle(); - Connection connection = serverPlayer.connection.connection; - - // Create position/rotation data - PositionMoveRotation positionData = new PositionMoveRotation( - new Vec3(location.getX(), location.getY(), location.getZ()), - Vec3.ZERO, // delta movement - location.getYaw(), - location.getPitch() - ); - - ClientboundEntityPositionSyncPacket teleportPacket = new ClientboundEntityPositionSyncPacket( - entityId, - positionData, - false // on ground - ); - connection.send(teleportPacket); - } - @Override public void sendEntityHeadRotation(Player viewer, int entityId, float yaw) { ServerPlayer serverPlayer = ((CraftPlayer) viewer).getHandle(); diff --git a/src/main/java/io/th0rgal/oraxen/OraxenPlugin.java b/src/main/java/io/th0rgal/oraxen/OraxenPlugin.java index bdc4eaeebe..5f975da463 100644 --- a/src/main/java/io/th0rgal/oraxen/OraxenPlugin.java +++ b/src/main/java/io/th0rgal/oraxen/OraxenPlugin.java @@ -19,6 +19,7 @@ import io.th0rgal.oraxen.hud.HudManager; import io.th0rgal.oraxen.items.ItemUpdater; import io.th0rgal.oraxen.mechanics.MechanicsManager; +import io.th0rgal.oraxen.mechanics.provided.gameplay.CustomBlockPickItemListener; import io.th0rgal.oraxen.mechanics.provided.gameplay.furniture.FurnitureFactory; import io.th0rgal.oraxen.nms.NMSHandlers; import io.th0rgal.oraxen.pack.dispatch.PackLoadingManager; @@ -134,6 +135,9 @@ public void onEnable() { if (CustomBlockMiningListener.isSupported()) { Bukkit.getPluginManager().registerEvents(new CustomBlockMiningListener(), this); } + if (VersionUtil.atOrAbove("1.21.5")) { + Bukkit.getPluginManager().registerEvents(new CustomBlockPickItemListener(), this); + } NMSHandlers.setup(); // Auto-update Paper config for block updates (noteblock, tripwire, chorus) diff --git a/src/main/java/io/th0rgal/oraxen/commands/RecipesCommand.java b/src/main/java/io/th0rgal/oraxen/commands/RecipesCommand.java index eab54d0d2f..f3369e9ffa 100644 --- a/src/main/java/io/th0rgal/oraxen/commands/RecipesCommand.java +++ b/src/main/java/io/th0rgal/oraxen/commands/RecipesCommand.java @@ -58,6 +58,9 @@ private OraxenCommand getBuilderCommand() { .withSubcommand(getCampfireBuilderCommand()) .withSubcommand(getSmokingBuilderCommand()) .withSubcommand(getStonecuttingBuilderCommand()) + .withSubcommand(getSmithingBuilderCommand()) + .withSubcommand(getAnvilBuilderCommand()) + .withSubcommand(getGrindstoneBuilderCommand()) .executes((sender, args) -> { if (sender instanceof Player player) { final RecipeBuilder recipe = RecipeBuilder.get(player.getUniqueId()); @@ -182,6 +185,50 @@ private OraxenCommand getStonecuttingBuilderCommand() { }); } + private OraxenCommand getSmithingBuilderCommand() { + return new OraxenCommand("smithing") + .withPermission("oraxen.command.recipes.builder") + .executes((sender, args) -> { + if (sender instanceof Player player) { + final RecipeBuilder recipe = RecipeBuilder.get(player.getUniqueId()); + (recipe != null ? recipe : new SmithingBuilder(player)).open(); + } else + Message.NOT_PLAYER.send(sender); + }); + } + + private OraxenCommand getAnvilBuilderCommand() { + return new OraxenCommand("anvil") + .withPermission("oraxen.command.recipes.builder") + .withArguments(new IntegerArgument("experience_cost")) + .executes((sender, args) -> { + if (sender instanceof Player player) { + RecipeBuilder recipe = RecipeBuilder.get(player.getUniqueId()); + recipe = recipe != null ? recipe : new AnvilBuilder(player); + if (recipe instanceof AnvilBuilder anvil) + anvil.setExperienceCost((Integer) args.get("experience_cost")); + recipe.open(); + } else + Message.NOT_PLAYER.send(sender); + }); + } + + private OraxenCommand getGrindstoneBuilderCommand() { + return new OraxenCommand("grindstone") + .withPermission("oraxen.command.recipes.builder") + .withArguments(new IntegerArgument("experience")) + .executes((sender, args) -> { + if (sender instanceof Player player) { + RecipeBuilder recipe = RecipeBuilder.get(player.getUniqueId()); + recipe = recipe != null ? recipe : new GrindstoneBuilder(player); + if (recipe instanceof GrindstoneBuilder grindstone) + grindstone.setExperience((Integer) args.get("experience")); + recipe.open(); + } else + Message.NOT_PLAYER.send(sender); + }); + } + private OraxenCommand getSaveCommand() { return new OraxenCommand("save") .withPermission("oraxen.command.recipes.builder") diff --git a/src/main/java/io/th0rgal/oraxen/configs/ConfigsManager.java b/src/main/java/io/th0rgal/oraxen/configs/ConfigsManager.java index 98b4d65a8a..b9a31ff465 100644 --- a/src/main/java/io/th0rgal/oraxen/configs/ConfigsManager.java +++ b/src/main/java/io/th0rgal/oraxen/configs/ConfigsManager.java @@ -794,7 +794,9 @@ public void assignAllUsedModelDatas() { ConfigurationSection itemSection = configuration.getConfigurationSection(key); if (itemSection == null) continue; - ConfigurationSection packSection = OraxenYaml.getConfigurationSection(itemSection, "Pack"); + ItemMigrator migrator = new ItemMigrator(itemSection); + fileChanged |= migrator.configUpdated(); + ConfigurationSection packSection = itemSection.getConfigurationSection("pack"); Material material = OraxenYaml.getMaterial(itemSection.getString("material", "")); if (packSection == null || material == null) continue; @@ -852,7 +854,7 @@ public void parseAllItemTemplates() { if (itemSection == null || !itemSection.isBoolean("template")) continue; ItemMigrator migrator = new ItemMigrator(itemSection); - ConfigurationSection mechanicsSection = OraxenYaml.getConfigurationSection(itemSection, "Mechanics"); + ConfigurationSection mechanicsSection = itemSection.getConfigurationSection("mechanics"); if (mechanicsSection != null) migrator.migrateLegacyBlockMechanics(mechanicsSection); configUpdated |= migrator.configUpdated(); blockConfigMigrated |= migrator.blockConfigMigrated(); diff --git a/src/main/java/io/th0rgal/oraxen/configs/ResourcesManager.java b/src/main/java/io/th0rgal/oraxen/configs/ResourcesManager.java index 88fe0a415f..beadfc6c16 100644 --- a/src/main/java/io/th0rgal/oraxen/configs/ResourcesManager.java +++ b/src/main/java/io/th0rgal/oraxen/configs/ResourcesManager.java @@ -85,18 +85,18 @@ private void extractVersionSpecificItemConfig(ZipEntry entry) { ConfigurationSection itemSection = itemYaml.getConfigurationSection(itemId); if (itemSection == null) continue; - ConfigurationSection mechanicSection = itemSection.getConfigurationSection("Mechanics"); + ConfigurationSection mechanicSection = itemSection.getConfigurationSection("mechanics"); if (mechanicSection == null) continue; - ConfigurationSection componentSection = itemSection.getConfigurationSection("Components"); - if (componentSection == null) componentSection = itemSection.createSection("Components"); + ConfigurationSection componentSection = itemSection.getConfigurationSection("components"); + if (componentSection == null) componentSection = itemSection.createSection("components"); Object durability = mechanicSection.get("durability.value"); mechanicSection.set("durability", null); componentSection.set("durability", durability); - if (mechanicSection.getKeys(false).isEmpty()) itemSection.set("Mechanics", null); - if (componentSection.getKeys(false).isEmpty()) itemSection.set("Components", null); + if (mechanicSection.getKeys(false).isEmpty()) itemSection.set("mechanics", null); + if (componentSection.getKeys(false).isEmpty()) itemSection.set("components", null); } File itemFile = plugin.getDataFolder().toPath().resolve(entry.getName()).toFile(); diff --git a/src/main/java/io/th0rgal/oraxen/configs/Settings.java b/src/main/java/io/th0rgal/oraxen/configs/Settings.java index e837d93f0a..6eaf0b7c71 100644 --- a/src/main/java/io/th0rgal/oraxen/configs/Settings.java +++ b/src/main/java/io/th0rgal/oraxen/configs/Settings.java @@ -75,6 +75,7 @@ public enum Settings { //Pack GENERATE("Pack.generation.generate"), + UNPROTECTED_PACK_LOCATION("Pack.generation.unprotected-location"), DISABLE_MCMETA_GENERATION("Pack.generation.disable_mcmeta_generation"), MULTI_VERSION_PACKS("Pack.generation.multi_version_packs"), EXCLUDED_FILE_EXTENSIONS("Pack.generation.excluded_file_extensions"), diff --git a/src/main/java/io/th0rgal/oraxen/items/ItemBuilder.java b/src/main/java/io/th0rgal/oraxen/items/ItemBuilder.java index bc18865d18..5914aab5cc 100644 --- a/src/main/java/io/th0rgal/oraxen/items/ItemBuilder.java +++ b/src/main/java/io/th0rgal/oraxen/items/ItemBuilder.java @@ -132,6 +132,8 @@ public class ItemBuilder { // 1.21.2+ properties @Nullable + private Object deathProtectionComponent; + @Nullable private EquippableComponent equippableComponent; @Nullable private Boolean isGlider; @@ -619,6 +621,20 @@ public ItemBuilder setConsumableComponent(@Nullable V consumableComponent) { return this; } + public boolean hasDeathProtectionComponent() { + return VersionUtil.atOrAbove("1.21.2") && deathProtectionComponent != null; + } + + @Nullable + public Object getDeathProtectionComponent() { + return deathProtectionComponent; + } + + public ItemBuilder setDeathProtectionComponent(@Nullable V deathProtectionComponent) { + this.deathProtectionComponent = deathProtectionComponent; + return this; + } + public boolean hasToolComponent() { return VersionUtil.atOrAbove("1.20.5") && toolComponent != null; } @@ -863,6 +879,7 @@ public ItemBuilder clone() { ItemBuilder clonedBuilder = new ItemBuilder(itemStack.clone()); clonedBuilder.genericComponents.putAll(genericComponents); clonedBuilder.paintingVariant = paintingVariant; + clonedBuilder.deathProtectionComponent = deathProtectionComponent; clonedBuilder.attributeEntries.clear(); clonedBuilder.attributeEntries.addAll(attributeEntries); if (legacyAttributeModifiers != null) { @@ -899,6 +916,7 @@ public synchronized ItemBuilder regen() { // Build into a local and publish once so concurrent readers never see // an intermediate stack. ItemStack built = applyConsumableComponent(itemStack); + built = applyDeathProtectionComponent(built); built = applyPaintingVariantComponent(built); built = applyGenericComponents(built); finalItemStack = built; @@ -1098,6 +1116,10 @@ private ItemStack applyConsumableComponent(ItemStack itemStack) { return NMSHandlers.getHandler().consumableComponent(itemStack, consumableComponent); } + private ItemStack applyDeathProtectionComponent(ItemStack itemStack) { + return NMSHandlers.getHandler().deathProtectionComponent(itemStack, deathProtectionComponent); + } + private ItemStack applyGenericComponents(ItemStack itemStack) { if (genericComponents.isEmpty()) return itemStack; return NMSHandlers.getHandler().applyGenericComponents(itemStack, genericComponents); @@ -1156,9 +1178,9 @@ public void save() { yamlConfiguration.set(itemId + ".ItemFlags", this.itemFlags.stream().map(ItemFlag::name).toList()); if (hasEquippableComponent()) { - yamlConfiguration.set(itemId + ".Components.equippable.slot", + yamlConfiguration.set(itemId + ".components.equippable.slot", this.equippableComponent.getSlot().name()); - yamlConfiguration.set(itemId + ".Components.equippable.model", + yamlConfiguration.set(itemId + ".components.equippable.model", this.equippableComponent.getModel().toString()); } try { diff --git a/src/main/java/io/th0rgal/oraxen/items/ItemComponents.java b/src/main/java/io/th0rgal/oraxen/items/ItemComponents.java index 1194327b06..3d3cb119a2 100644 --- a/src/main/java/io/th0rgal/oraxen/items/ItemComponents.java +++ b/src/main/java/io/th0rgal/oraxen/items/ItemComponents.java @@ -3,7 +3,6 @@ import io.th0rgal.oraxen.api.OraxenItems; import io.th0rgal.oraxen.compatibilities.provided.ecoitems.WrappedEcoItem; import io.th0rgal.oraxen.compatibilities.provided.mythiccrucible.WrappedCrucibleItem; -import io.th0rgal.oraxen.nms.NMSHandler; import io.th0rgal.oraxen.nms.NMSHandlers; import io.th0rgal.oraxen.utils.AdventureUtils; import io.th0rgal.oraxen.utils.OraxenYaml; @@ -20,6 +19,7 @@ import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.components.EquippableComponent; +import org.bukkit.inventory.meta.components.FoodComponent; import org.bukkit.inventory.meta.components.JukeboxPlayableComponent; import org.bukkit.inventory.meta.components.ToolComponent; import org.bukkit.inventory.meta.components.UseCooldownComponent; @@ -47,7 +47,7 @@ private void parseDataComponents(final ItemBuilder item, final ConfigurationSect else if (section.contains("displayname")) applyItemName(item, section, "displayname"); - final ConfigurationSection components = OraxenYaml.getConfigurationSection(section, "Components"); + final ConfigurationSection components = section.getConfigurationSection("components"); applyRemainingComponents(item, components); } @@ -104,9 +104,8 @@ private void handleLegacyComponents(final ItemBuilder item, final ConfigurationS if (OraxenYaml.contains(components, "enchantment_glint_override")) item.setEnchantmentGlintOverride(OraxenYaml.getBoolean(components, "enchantment_glint_override")); - final NMSHandler nmsHandler = NMSHandlers.getHandler(); Optional.ofNullable(OraxenYaml.getConfigurationSection(components, "food")) - .ifPresent(food -> nmsHandler.foodComponent(item, food)); + .ifPresent(food -> parseFoodComponent(item, food)); Optional.ofNullable(OraxenYaml.getConfigurationSection(components, "tool")) .ifPresent(toolSection -> parseToolComponent(item, toolSection)); @@ -149,6 +148,10 @@ private void handleLegacyComponents(final ItemBuilder item, final ConfigurationS if (!VersionUtil.atOrAbove("1.21.2")) return; + + Optional.ofNullable(OraxenYaml.getConfigurationSection(components, "death_protection")) + .ifPresent(deathProtection -> NMSHandlers.getHandler().deathProtectionComponent(item, deathProtection)); + Optional.ofNullable(OraxenYaml.getConfigurationSection(components, "equippable")) .ifPresent(equippable -> parseEquippableComponent(item, equippable)); @@ -179,7 +182,16 @@ private void handleLegacyComponents(final ItemBuilder item, final ConfigurationS .ifPresent(item::setItemModel); Optional.ofNullable(OraxenYaml.getConfigurationSection(components, "consumable")) - .ifPresent(consumableSection -> nmsHandler.consumableComponent(item, consumableSection)); + .ifPresent(consumableSection -> NMSHandlers.getHandler().consumableComponent(item, consumableSection)); + } + + @SuppressWarnings("UnstableApiUsage") + private void parseFoodComponent(final ItemBuilder item, final ConfigurationSection foodSection) { + final FoodComponent foodComponent = new ItemStack(item.getType()).getItemMeta().getFood(); + foodComponent.setNutrition(Math.max(foodSection.getInt("nutrition"), 0)); + foodComponent.setSaturation(Math.max((float) foodSection.getDouble("saturation", 0.0), 0f)); + foodComponent.setCanAlwaysEat(foodSection.getBoolean("can_always_eat", false)); + item.setFoodComponent(foodComponent); } private boolean isLegacyComponent(final String key) { @@ -193,6 +205,7 @@ private boolean isLegacyComponent(final String key) { normalizedKey.equals("tool") || normalizedKey.equals("painting_variant") || normalizedKey.equals("jukebox_playable") || + normalizedKey.equals("death_protection") || normalizedKey.equals("equippable") || normalizedKey.equals("use_cooldown") || normalizedKey.equals("use_remainder") || diff --git a/src/main/java/io/th0rgal/oraxen/items/ItemLoader.java b/src/main/java/io/th0rgal/oraxen/items/ItemLoader.java index 740b966f2d..5e4e36d12e 100644 --- a/src/main/java/io/th0rgal/oraxen/items/ItemLoader.java +++ b/src/main/java/io/th0rgal/oraxen/items/ItemLoader.java @@ -18,6 +18,7 @@ public final class ItemLoader { private final OraxenMeta oraxenMeta; private final ConfigurationSection section; private final Material type; + private final ItemMigrator migrator; private WrappedMMOItem mmoItem; private WrappedCrucibleItem crucibleItem; private WrappedEcoItem ecoItem; @@ -25,6 +26,7 @@ public final class ItemLoader { public ItemLoader(final ConfigurationSection section) { this.section = section; + migrator = new ItemMigrator(section); if (section.isString("template")) templateItem = ItemTemplate.getLoaderTemplate(section.getString("template")); @@ -51,15 +53,17 @@ else if (mmoSection != null) // Each item gets its own OraxenMeta: templates are merged in by value, never shared // by reference, so sibling items cannot overwrite each other's pack info. oraxenMeta = new OraxenMeta(); - final ConfigurationSection mergedPackSection = - OraxenYaml.getConfigurationSection(mergeWithTemplateSection(), "Pack"); + final ConfigurationSection mergedSection = mergeWithTemplateSection(); + final ConfigurationSection mergedPackSection = mergedSection != null + ? mergedSection.getConfigurationSection("pack") + : null; if (mergedPackSection != null) oraxenMeta.setPackInfos(mergedPackSection); // Only an explicitly configured custom_model_data on the item itself is registered. // Template children deliberately do not inherit the template's number, otherwise every // sibling would resolve to the same one; they get an automatically assigned id instead. - final ConfigurationSection packSection = OraxenYaml.getConfigurationSection(section, "Pack"); + final ConfigurationSection packSection = section.getConfigurationSection("pack"); if (packSection != null && packSection.isInt("custom_model_data")) MODEL_DATAS_BY_ID.put(section.getName(), new ModelData(type, oraxenMeta.getModelName(), packSection.getInt("custom_model_data"))); @@ -99,7 +103,7 @@ else if (usesEcoItems()) } private ItemValidator validator() { - return new ItemValidator(section, mergeWithTemplateSection(), type, oraxenMeta, MODEL_DATAS_BY_ID); + return new ItemValidator(section, mergeWithTemplateSection(), type, oraxenMeta, MODEL_DATAS_BY_ID, migrator); } private ConfigurationSection mergeWithTemplateSection() { diff --git a/src/main/java/io/th0rgal/oraxen/items/ItemMechanics.java b/src/main/java/io/th0rgal/oraxen/items/ItemMechanics.java index a79355a6d1..4105813fd3 100644 --- a/src/main/java/io/th0rgal/oraxen/items/ItemMechanics.java +++ b/src/main/java/io/th0rgal/oraxen/items/ItemMechanics.java @@ -25,7 +25,7 @@ public ItemMechanics(final ConfigurationSection section, final ItemMigrator migr } public void apply(final ItemBuilder item, final ConfigurationSection mergedSection) { - final ConfigurationSection mechanicsSection = OraxenYaml.getConfigurationSection(mergedSection, "Mechanics"); + final ConfigurationSection mechanicsSection = mergedSection.getConfigurationSection("mechanics"); if (mechanicsSection == null) return; @@ -36,8 +36,8 @@ public void apply(final ItemBuilder item, final ConfigurationSection mergedSecti final MechanicFactory factory = MechanicsManager.getMechanicFactory(mechanicID); if (factory == null) { if (LEGACY_BLOCK_MECHANIC_IDS.contains(mechanicID.toLowerCase(Locale.ROOT))) - Logs.logWarning("Item " + section.getName() + " uses legacy Mechanics." + mechanicID - + "; migrate it to Mechanics.block or this mechanic will be ignored."); + Logs.logWarning("Item " + section.getName() + " uses legacy mechanics." + mechanicID + + "; migrate it to mechanics.block or this mechanic will be ignored."); continue; } diff --git a/src/main/java/io/th0rgal/oraxen/items/ItemMigrator.java b/src/main/java/io/th0rgal/oraxen/items/ItemMigrator.java index bf2a26bbb1..36cbf3651e 100644 --- a/src/main/java/io/th0rgal/oraxen/items/ItemMigrator.java +++ b/src/main/java/io/th0rgal/oraxen/items/ItemMigrator.java @@ -5,6 +5,7 @@ import io.th0rgal.oraxen.utils.logs.Logs; import org.bukkit.configuration.ConfigurationSection; +import java.util.Locale; import java.util.Map; public final class ItemMigrator { @@ -22,6 +23,48 @@ public final class ItemMigrator { public ItemMigrator(final ConfigurationSection section) { this.section = section; + migrateUppercaseSections(); + } + + /** + * Migrates the item-level sections that historically used capitalized names + * to their lowercase canonical names. + */ + public void migrateUppercaseSections() { + if (section == null) + return; + + for (final String key : section.getKeys(false).toArray(String[]::new)) { + final String lowercaseKey = key.toLowerCase(Locale.ROOT); + if (key.equals(lowercaseKey)) + continue; + if (!switch (lowercaseKey) { + case "mechanics", "pack", "components" -> true; + default -> false; + }) + continue; + + final Object value = section.get(key); + final Object existingValue = section.get(lowercaseKey); + if (value instanceof ConfigurationSection sourceSection) { + final ConfigurationSection targetSection; + if (existingValue instanceof ConfigurationSection existingSection) { + targetSection = existingSection; + } else { + if (existingValue != null) + section.set(lowercaseKey, null); + targetSection = section.createSection(lowercaseKey); + } + OraxenYaml.copyConfigurationSection(sourceSection, targetSection); + OraxenYaml.invalidateKeyCache(targetSection); + } else if (existingValue == null) { + section.set(lowercaseKey, value); + } + + section.set(key, null); + OraxenYaml.invalidateKeyCache(section); + configUpdated = true; + } } public void recordLegacyNameMigration(final boolean migrated) { @@ -57,8 +100,8 @@ public void migrateLegacyBlockMechanics(final ConfigurationSection mechanicsSect configUpdated = true; blockConfigMigrated = true; if (OraxenPlugin.get() != null) - Logs.logWarning("Item " + section.getName() + " uses legacy Mechanics." + legacyMechanicID - + "; it has been migrated to Mechanics.block."); + Logs.logWarning("Item " + section.getName() + " uses legacy mechanics." + legacyMechanicID + + "; it has been migrated to mechanics.block."); return; } } diff --git a/src/main/java/io/th0rgal/oraxen/items/ItemProperties.java b/src/main/java/io/th0rgal/oraxen/items/ItemProperties.java index 700f0ddf8b..55910be5b6 100644 --- a/src/main/java/io/th0rgal/oraxen/items/ItemProperties.java +++ b/src/main/java/io/th0rgal/oraxen/items/ItemProperties.java @@ -92,7 +92,7 @@ private void parseMiscOptions(final ItemBuilder item, final ConfigurationSection private void applyArmorStandModelProperties(ConfigurationSection section) { oraxenMeta.setArmorStandHeadScale(null); - ConfigurationSection mechanicsSection = OraxenYaml.getConfigurationSection(section, "Mechanics"); + ConfigurationSection mechanicsSection = section.getConfigurationSection("mechanics"); if (mechanicsSection == null) return; ConfigurationSection furnitureSection = OraxenYaml.getConfigurationSection(mechanicsSection, "furniture"); if (furnitureSection == null) return; @@ -285,7 +285,7 @@ private Integer resolveCustomModelData() { migrator.markConfigUpdated(); if (!Settings.DISABLE_AUTOMATIC_MODEL_DATA.toBool()) { - Optional.ofNullable(OraxenYaml.getConfigurationSection(section, "Pack")) + Optional.ofNullable(section.getConfigurationSection("pack")) .ifPresent(packSection -> { packSection.set("custom_model_data", customModelData); OraxenYaml.invalidateKeyCache(packSection); diff --git a/src/main/java/io/th0rgal/oraxen/items/ItemUpdater.java b/src/main/java/io/th0rgal/oraxen/items/ItemUpdater.java index 14a24888a6..50a75048f8 100644 --- a/src/main/java/io/th0rgal/oraxen/items/ItemUpdater.java +++ b/src/main/java/io/th0rgal/oraxen/items/ItemUpdater.java @@ -527,6 +527,10 @@ public static ItemStack updateItem(ItemStack oldItem) { ItemStack newItem = nmsHandler.copyItemNBTTags(oldItem, newItemBuilder.build()); newItem.setAmount(oldItem.getAmount()); + Object deathProtectionComponent = VersionUtil.atOrAbove("1.21.2") + ? Optional.ofNullable(nmsHandler.deathProtectionComponent(newItem)) + .orElseGet(() -> nmsHandler.deathProtectionComponent(oldItem)) + : null; ItemUtils.editItemMeta(newItem, itemMeta -> { ItemMeta oldMeta = oldItem.getItemMeta(); @@ -681,7 +685,9 @@ public static ItemStack updateItem(ItemStack oldItem) { nmsHandler.consumableComponent(newItem, Optional.ofNullable(nmsHandler.consumableComponent(newItem)) .orElse(nmsHandler.consumableComponent(oldItem))); - return newItem; + return deathProtectionComponent != null + ? nmsHandler.deathProtectionComponent(newItem, deathProtectionComponent) + : newItem; } } diff --git a/src/main/java/io/th0rgal/oraxen/items/ItemValidator.java b/src/main/java/io/th0rgal/oraxen/items/ItemValidator.java index 61ca19f468..6beefe6022 100644 --- a/src/main/java/io/th0rgal/oraxen/items/ItemValidator.java +++ b/src/main/java/io/th0rgal/oraxen/items/ItemValidator.java @@ -16,13 +16,14 @@ public final class ItemValidator { private final ItemMigrator migrator; public ItemValidator(final ConfigurationSection section, final ConfigurationSection mergedSection, - final Material type, final OraxenMeta oraxenMeta, final Map modelDatasById) { + final Material type, final OraxenMeta oraxenMeta, final Map modelDatasById, + final ItemMigrator migrator) { this.section = section; this.mergedSection = mergedSection; this.type = type; this.oraxenMeta = oraxenMeta; this.modelDatasById = modelDatasById; - migrator = new ItemMigrator(section); + this.migrator = migrator; } public Result validate(final ItemBuilder item) { diff --git a/src/main/java/io/th0rgal/oraxen/mechanics/provided/cosmetic/backpack/BackpackCosmeticFactory.java b/src/main/java/io/th0rgal/oraxen/mechanics/provided/cosmetic/backpack/BackpackCosmeticFactory.java index 8c2c06d46d..20c578a0e6 100644 --- a/src/main/java/io/th0rgal/oraxen/mechanics/provided/cosmetic/backpack/BackpackCosmeticFactory.java +++ b/src/main/java/io/th0rgal/oraxen/mechanics/provided/cosmetic/backpack/BackpackCosmeticFactory.java @@ -18,6 +18,11 @@ public class BackpackCosmeticFactory extends MechanicFactory { private static BackpackCosmeticFactory instance; + private static final String armorStandEnabledKey = "armor_stand_enabled"; + private static final String armorStandRangeKey = "armor_stand_range"; + private final BackpackCosmeticListener listener; + private final boolean armorStandEnabled; + private final int armorStandRange; @ConfigProperty(type = PropertyType.STRING, description = "Equipment slot that triggers backpack display", defaultValue = "CHEST") public static final String PROP_SLOT = "slot"; @@ -49,22 +54,24 @@ public class BackpackCosmeticFactory extends MechanicFactory { public BackpackCosmeticFactory(ConfigurationSection section) { super(section); instance = this; + armorStandEnabled = section.getBoolean(armorStandEnabledKey, true); + armorStandRange = Math.max(1, section.getInt(armorStandRangeKey, 128)); - BackpackCosmeticListener listener = new BackpackCosmeticListener(this); + listener = new BackpackCosmeticListener(this); MechanicsManager.registerListeners(OraxenPlugin.get(), getMechanicID(), listener, listener.createMountListener(OraxenPlugin.get())); - // Register tasks with MechanicsManager for proper cleanup on reload BackpackCosmeticManager manager = BackpackCosmeticManager.getInstance(); - // Fast position update task (every tick = 50ms) for smooth backpack following SchedulerUtil.ScheduledTask positionTask = SchedulerUtil.runTaskTimer(1L, 1L, manager::updateAllBackpackPositions); MechanicsManager.registerTask(getMechanicID(), positionTask); - // Viewer refresh task (every 20 ticks = 1 second) for adding/removing viewers SchedulerUtil.ScheduledTask refreshTask = SchedulerUtil.runTaskTimer(20L, 20L, manager::refreshAllViewers); MechanicsManager.registerTask(getMechanicID(), refreshTask); + SchedulerUtil.ScheduledTask armorStandTask = SchedulerUtil.runTaskLater(1L, listener::startExistingArmorStandViewerTasks); + MechanicsManager.registerTask(getMechanicID(), armorStandTask); + if (Settings.DEBUG.toBool()) { io.th0rgal.oraxen.utils.logs.Logs.logSuccess("BackpackCosmeticFactory initialized"); } @@ -74,6 +81,19 @@ public static BackpackCosmeticFactory getInstance() { return instance; } + public boolean isArmorStandEnabled() { + return armorStandEnabled; + } + + public int getArmorStandRange() { + return armorStandRange; + } + + @Override + public void onUnregister() { + listener.cleanupArmorStandDisplays(); + } + @Override public Mechanic parse(ConfigurationSection section) { BackpackCosmeticMechanic mechanic = new BackpackCosmeticMechanic(this, section); diff --git a/src/main/java/io/th0rgal/oraxen/mechanics/provided/cosmetic/backpack/BackpackCosmeticListener.java b/src/main/java/io/th0rgal/oraxen/mechanics/provided/cosmetic/backpack/BackpackCosmeticListener.java index 4a5abe0c21..37c3b56775 100644 --- a/src/main/java/io/th0rgal/oraxen/mechanics/provided/cosmetic/backpack/BackpackCosmeticListener.java +++ b/src/main/java/io/th0rgal/oraxen/mechanics/provided/cosmetic/backpack/BackpackCosmeticListener.java @@ -2,15 +2,20 @@ import io.th0rgal.oraxen.api.OraxenItems; import io.th0rgal.oraxen.mechanics.Mechanic; +import io.th0rgal.oraxen.mechanics.MechanicsManager; import io.th0rgal.oraxen.utils.SchedulerUtil; import io.th0rgal.oraxen.utils.VersionUtil; import org.bukkit.Bukkit; import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.entity.ArmorStand; +import org.bukkit.entity.Entity; import org.bukkit.entity.Player; import org.bukkit.event.Event; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDeathEvent; import org.bukkit.event.entity.EntityPickupItemEvent; import org.bukkit.event.entity.EntityToggleGlideEvent; import org.bukkit.event.entity.PlayerDeathEvent; @@ -22,6 +27,7 @@ import org.bukkit.plugin.java.JavaPlugin; import java.lang.reflect.Method; +import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -37,6 +43,8 @@ public class BackpackCosmeticListener implements Listener { private final BackpackCosmeticManager manager; private final Set hiddenForMovement = ConcurrentHashMap.newKeySet(); private final Map hiddenMovementMechanics = new ConcurrentHashMap<>(); + private final Map armorStandDisplays = new ConcurrentHashMap<>(); + private final Map armorStandViewerTasks = new ConcurrentHashMap<>(); // Movement thresholds to reduce unnecessary updates // Without mount packets, we need more frequent updates for smooth following @@ -53,14 +61,29 @@ public BackpackCosmeticListener(BackpackCosmeticFactory factory) { public void onPlayerJoin(PlayerJoinEvent event) { Player player = event.getPlayer(); - // Check if player has backpack item equipped - SchedulerUtil.runForEntityLater(player, 5L, () -> checkAndUpdateBackpack(player)); + SchedulerUtil.runForEntityLater(player, 5L, () -> { + checkAndUpdateBackpack(player); + startArmorStandViewerTask(player); + refreshArmorStandDisplays(player); + }); } @EventHandler(priority = EventPriority.MONITOR) public void onPlayerQuit(PlayerQuitEvent event) { - clearMovementHidden(event.getPlayer().getUniqueId()); - manager.hideBackpack(event.getPlayer()); + Player player = event.getPlayer(); + UUID playerId = player.getUniqueId(); + clearMovementHidden(playerId); + manager.hideBackpack(player); + + SchedulerUtil.ScheduledTask task = armorStandViewerTasks.remove(playerId); + if (task != null) task.cancel(); + for (Map.Entry entry : armorStandDisplays.entrySet()) { + BackpackCosmeticManager.BackpackData data = entry.getValue(); + data.getViewers().remove(playerId); + if (data.getViewers().isEmpty()) { + armorStandDisplays.remove(entry.getKey(), data); + } + } } @EventHandler(priority = EventPriority.MONITOR) @@ -137,6 +160,23 @@ public void onEntityToggleGlide(EntityToggleGlideEvent event) { SchedulerUtil.runForEntityLater(player, 1L, () -> checkAndUpdateBackpack(player)); } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onArmorStandManipulate(PlayerArmorStandManipulateEvent event) { + ArmorStand stand = event.getRightClicked(); + Player player = event.getPlayer(); + if (!factory.isArmorStandEnabled() || event.getSlot() != EquipmentSlot.CHEST) return; + + SchedulerUtil.runForEntityLater(stand, 1L, () -> checkArmorStandDisplay(stand)); + SchedulerUtil.runForEntityLater(player, 2L, () -> refreshArmorStandDisplays(player)); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onArmorStandDeath(EntityDeathEvent event) { + if (event.getEntity() instanceof ArmorStand stand) { + removeArmorStandDisplay(stand.getUniqueId()); + } + } + /** * Creates the mount/dismount listener for backpack resyncs. * The mount events moved from org.spigotmc to org.bukkit.event.entity in 1.20.5; @@ -162,11 +202,13 @@ public class MountListener implements Listener { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onEntityMount(org.bukkit.event.entity.EntityMountEvent event) { handleMountChange(event.getEntity()); + handleArmorStandMountChange(event.getMount()); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onEntityDismount(org.bukkit.event.entity.EntityDismountEvent event) { handleMountChange(event.getEntity()); + handleArmorStandMountChange(event.getDismounted()); } } @@ -176,11 +218,22 @@ public void onEntityDismount(org.bukkit.event.entity.EntityDismountEvent event) private void registerLegacyMountEvent(JavaPlugin plugin, Listener listener, String eventClassName) { try { Class eventClass = Class.forName(eventClassName).asSubclass(Event.class); - Method getter = eventClass.getMethod("getEntity"); + Method entityGetter = eventClass.getMethod("getEntity"); + String vehicleGetterName = eventClassName.endsWith("EntityMountEvent") ? "getMount" : "getDismounted"; + Method vehicleGetter; + try { + vehicleGetter = eventClass.getMethod(vehicleGetterName); + } catch (NoSuchMethodException ignored) { + vehicleGetter = null; + } + Method finalVehicleGetter = vehicleGetter; Bukkit.getPluginManager().registerEvent(eventClass, listener, EventPriority.MONITOR, (l, event) -> { if (!eventClass.isInstance(event)) return; try { - if (getter.invoke(event) instanceof org.bukkit.entity.Entity entity) handleMountChange(entity); + if (entityGetter.invoke(event) instanceof Entity entity) handleMountChange(entity); + if (finalVehicleGetter != null && finalVehicleGetter.invoke(event) instanceof Entity vehicle) { + handleArmorStandMountChange(vehicle); + } } catch (ReflectiveOperationException ignored) { } }, plugin, true); @@ -189,15 +242,39 @@ private void registerLegacyMountEvent(JavaPlugin plugin, Listener listener, Stri } } - private void handleMountChange(org.bukkit.entity.Entity mounted) { + private void handleMountChange(Entity mounted) { if (!(mounted instanceof Player player)) return; if (!manager.hasBackpack(player)) return; scheduleBackpackMountResync(player); } - // Schedules two resyncs because mount/dismount packets can arrive out of order with the - // passenger-list updates the client uses; the second pass is a safety net for that race. + private void handleArmorStandMountChange(Entity vehicle) { + if (!factory.isArmorStandEnabled() || !(vehicle instanceof ArmorStand stand)) return; + + BackpackCosmeticManager.BackpackData data = armorStandDisplays.get(stand.getUniqueId()); + if (data == null) return; + + SchedulerUtil.runForEntity(stand, () -> { + if (armorStandDisplays.get(stand.getUniqueId()) != data || !stand.isValid()) return; + + int[] passengerIds = manager.getMergedPassengerIds(stand, data.getEntityId()); + int vehicleId = stand.getEntityId(); + float yaw = stand.getYaw(); + for (UUID viewerId : data.getViewers()) { + Player viewer = Bukkit.getPlayer(viewerId); + if (viewer == null) continue; + + SchedulerUtil.runForEntity(viewer, () -> { + if (viewer.isOnline() && data.getViewers().contains(viewerId) + && armorStandDisplays.get(stand.getUniqueId()) == data) { + manager.sendBackpackMount(viewer, data, vehicleId, yaw, passengerIds, false); + } + }); + } + }); + } + private void scheduleBackpackMountResync(Player player) { manager.requestResync(player); SchedulerUtil.runForEntityLater(player, 1L, () -> manager.resyncBackpackMount(player)); @@ -434,4 +511,131 @@ private boolean isArmorItem(ItemStack item) { typeName.endsWith("_BOOTS") || typeName.equals("ELYTRA"); } + + void startExistingArmorStandViewerTasks() { + if (!factory.isArmorStandEnabled()) return; + + for (Player player : Bukkit.getOnlinePlayers()) { + startArmorStandViewerTask(player); + } + } + + private void startArmorStandViewerTask(Player viewer) { + if (!factory.isArmorStandEnabled()) return; + + UUID viewerId = viewer.getUniqueId(); + if (armorStandViewerTasks.containsKey(viewerId)) return; + + SchedulerUtil.ScheduledTask task = SchedulerUtil.runForEntityTimer(viewer, 1L, 20L, + () -> refreshArmorStandDisplays(viewer), + () -> armorStandViewerTasks.remove(viewerId)); + if (task == null) return; + + SchedulerUtil.ScheduledTask previous = armorStandViewerTasks.putIfAbsent(viewerId, task); + if (previous == null) { + MechanicsManager.registerTask(factory.getMechanicID(), task); + } else { + task.cancel(); + } + } + + private void refreshArmorStandDisplays(Player viewer) { + if (!factory.isArmorStandEnabled() || !viewer.isOnline()) return; + + int armorStandRange = factory.getArmorStandRange(); + Set nearbyStandIds = new HashSet<>(); + for (Entity entity : viewer.getNearbyEntities( + armorStandRange, armorStandRange, armorStandRange)) { + if (entity instanceof ArmorStand stand) { + nearbyStandIds.add(stand.getUniqueId()); + refreshArmorStandForViewer(viewer, stand); + } + } + + for (Map.Entry entry : armorStandDisplays.entrySet()) { + if (!nearbyStandIds.contains(entry.getKey())) { + removeArmorStandViewer(viewer, entry.getKey(), entry.getValue()); + } + } + } + + private void refreshArmorStandForViewer(Player viewer, ArmorStand stand) { + SchedulerUtil.runForEntity(stand, () -> { + if (!stand.isValid()) { + removeArmorStandDisplay(stand.getUniqueId()); + return; + } + + ItemStack chestItem = stand.getEquipment() == null ? null : stand.getEquipment().getChestplate(); + BackpackCosmeticMechanic mechanic = getBackpackMechanic(chestItem); + if (mechanic == null) { + removeArmorStandDisplay(stand.getUniqueId()); + return; + } + + BackpackCosmeticManager.BackpackData data = ensureArmorStandDisplay(stand, mechanic, chestItem); + Location location = stand.getLocation().clone(); + int vehicleId = stand.getEntityId(); + float yaw = stand.getYaw(); + int[] passengerIds = manager.getMergedPassengerIds(stand, data.getEntityId()); + + SchedulerUtil.runForEntity(viewer, () -> { + if (armorStandDisplays.get(stand.getUniqueId()) != data) return; + manager.updateBackpackViewer(viewer, data, location, vehicleId, yaw, passengerIds); + if (data.getViewers().isEmpty()) armorStandDisplays.remove(stand.getUniqueId(), data); + }); + }, () -> removeArmorStandDisplay(stand.getUniqueId())); + } + + private BackpackCosmeticManager.BackpackData ensureArmorStandDisplay(ArmorStand stand, + BackpackCosmeticMechanic mechanic, + ItemStack displayItem) { + UUID standId = stand.getUniqueId(); + BackpackCosmeticManager.BackpackData data = armorStandDisplays.get(standId); + if (data != null && data.getMechanic() == mechanic && displayItem.isSimilar(data.getDisplayItem())) { + return data; + } + + removeArmorStandDisplay(standId); + data = manager.createBackpackData(mechanic, displayItem.clone()); + armorStandDisplays.put(standId, data); + return data; + } + + private void removeArmorStandViewer(Player viewer, UUID standId, BackpackCosmeticManager.BackpackData data) { + manager.removeBackpackViewer(viewer, data); + if (data.getViewers().isEmpty()) armorStandDisplays.remove(standId, data); + } + + private void checkArmorStandDisplay(ArmorStand stand) { + if (!stand.isValid()) { + removeArmorStandDisplay(stand.getUniqueId()); + return; + } + + ItemStack chestItem = stand.getEquipment() == null ? null : stand.getEquipment().getChestplate(); + BackpackCosmeticMechanic mechanic = getBackpackMechanic(chestItem); + if (mechanic == null) { + removeArmorStandDisplay(stand.getUniqueId()); + return; + } + + ensureArmorStandDisplay(stand, mechanic, chestItem); + } + + private void removeArmorStandDisplay(UUID standId) { + BackpackCosmeticManager.BackpackData data = armorStandDisplays.remove(standId); + if (data == null) return; + manager.scheduleBackpackDestroyForViewers(data); + } + + void cleanupArmorStandDisplays() { + for (SchedulerUtil.ScheduledTask task : armorStandViewerTasks.values()) { + task.cancel(); + } + armorStandViewerTasks.clear(); + + armorStandDisplays.values().forEach(manager::scheduleBackpackDestroyForViewers); + armorStandDisplays.clear(); + } } diff --git a/src/main/java/io/th0rgal/oraxen/mechanics/provided/cosmetic/backpack/BackpackCosmeticManager.java b/src/main/java/io/th0rgal/oraxen/mechanics/provided/cosmetic/backpack/BackpackCosmeticManager.java index 1fee41fdaf..529fb8aa1c 100644 --- a/src/main/java/io/th0rgal/oraxen/mechanics/provided/cosmetic/backpack/BackpackCosmeticManager.java +++ b/src/main/java/io/th0rgal/oraxen/mechanics/provided/cosmetic/backpack/BackpackCosmeticManager.java @@ -42,10 +42,7 @@ public void showBackpack(Player player, BackpackCosmeticMechanic mechanic, ItemS // Remove existing backpack first hideBackpack(player); - // Generate a unique entity ID for the armor stand - int entityId = NMSHandlers.getHandler().getNextEntityId(); - - BackpackData data = new BackpackData(entityId, mechanic, displayItem); + BackpackData data = createBackpackData(mechanic, displayItem); activeBackpacks.put(playerId, data); // Spawn the backpack for all nearby players @@ -294,7 +291,11 @@ private void sendBackpackMountPacket(Player viewer, Player owner, int[] passenge NMSHandlers.getHandler().sendMountPacket(viewer, owner.getEntityId(), passengerIds); } - private int[] getMergedPassengerIds(Player owner, int backpackEntityId) { + BackpackData createBackpackData(BackpackCosmeticMechanic mechanic, ItemStack displayItem) { + return new BackpackData(Bukkit.getUnsafe().nextEntityId(), mechanic, displayItem); + } + + int[] getMergedPassengerIds(Entity owner, int backpackEntityId) { Set passengerIds = new LinkedHashSet<>(); for (Entity passenger : owner.getPassengers()) { passengerIds.add(passenger.getEntityId()); @@ -304,6 +305,42 @@ private int[] getMergedPassengerIds(Player owner, int backpackEntityId) { return passengerIds.stream().mapToInt(Integer::intValue).toArray(); } + void updateBackpackViewer(Player viewer, BackpackData data, Location location, + int vehicleId, float yaw, int[] passengerIds) { + if (!viewer.isOnline() || !isWithinViewDistance(viewer, location, data.getMechanic().getViewDistance())) { + removeBackpackViewer(viewer, data); + return; + } + + boolean spawned = data.getViewers().add(viewer.getUniqueId()); + if (spawned) { + NMSHandlers.getHandler().spawnBackpackArmorStand( + viewer, data.getEntityId(), location, data.getDisplayItem(), data.getMechanic().isSmallArmorStand()); + } + + sendBackpackMount(viewer, data, vehicleId, yaw, passengerIds, spawned); + } + + void sendBackpackMount(Player viewer, BackpackData data, int vehicleId, + float yaw, int[] passengerIds, boolean resync) { + NMSHandlers.getHandler().sendMountPacket(viewer, vehicleId, passengerIds); + NMSHandlers.getHandler().sendEntityHeadRotation(viewer, data.getEntityId(), yaw); + + if (resync) { + SchedulerUtil.runForEntityLater(viewer, 1L, () -> { + if (viewer.isOnline() && data.getViewers().contains(viewer.getUniqueId())) { + NMSHandlers.getHandler().sendMountPacket(viewer, vehicleId, passengerIds); + } + }); + } + } + + void removeBackpackViewer(Player viewer, BackpackData data) { + if (data.getViewers().remove(viewer.getUniqueId())) { + NMSHandlers.getHandler().sendEntityDestroy(viewer, data.getEntityId()); + } + } + private void destroyBackpackForViewers(BackpackData data) { for (UUID viewerId : data.getViewers()) { Player viewer = Bukkit.getPlayer(viewerId); @@ -314,9 +351,24 @@ private void destroyBackpackForViewers(BackpackData data) { data.getViewers().clear(); } + void scheduleBackpackDestroyForViewers(BackpackData data) { + for (UUID viewerId : data.getViewers()) { + Player viewer = Bukkit.getPlayer(viewerId); + if (viewer != null && viewer.isOnline()) { + SchedulerUtil.runForEntity(viewer, + () -> NMSHandlers.getHandler().sendEntityDestroy(viewer, data.getEntityId())); + } + } + data.getViewers().clear(); + } + private boolean isWithinViewDistance(Player viewer, Player target, int viewDistance) { + return isWithinViewDistance(viewer, target.getLocation(), viewDistance); + } + + private boolean isWithinViewDistance(Player viewer, Location target, int viewDistance) { if (!viewer.getWorld().equals(target.getWorld())) return false; - return viewer.getLocation().distanceSquared(target.getLocation()) <= viewDistance * viewDistance; + return viewer.getLocation().distanceSquared(target) <= (double) viewDistance * viewDistance; } /** diff --git a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/CustomBlockPickItemListener.java b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/CustomBlockPickItemListener.java new file mode 100644 index 0000000000..8b508e9ba5 --- /dev/null +++ b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/CustomBlockPickItemListener.java @@ -0,0 +1,67 @@ +package io.th0rgal.oraxen.mechanics.provided.gameplay; + +import io.papermc.paper.event.player.PlayerPickBlockEvent; +import io.th0rgal.oraxen.api.OraxenBlocks; +import io.th0rgal.oraxen.api.OraxenItems; +import io.th0rgal.oraxen.items.ItemBuilder; +import io.th0rgal.oraxen.mechanics.provided.gameplay.chorusblock.ChorusBlockMechanic; +import io.th0rgal.oraxen.mechanics.provided.gameplay.noteblock.NoteBlockMechanic; +import io.th0rgal.oraxen.mechanics.provided.gameplay.shaped.ShapedBlockMechanic; +import io.th0rgal.oraxen.mechanics.provided.gameplay.stringblock.StringBlockMechanic; +import io.th0rgal.oraxen.utils.inventories.PickItemUtils; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.inventory.ItemStack; + +public class CustomBlockPickItemListener implements Listener { + + @EventHandler(ignoreCancelled = true) + public void onPickBlock(PlayerPickBlockEvent event) { + ItemBuilder itemBuilder = getPickedItem(event.getBlock()); + if (itemBuilder == null) return; + + ItemStack item = itemBuilder.build(); + if (item == null || item.getType().isAir()) return; + + event.setCancelled(true); + PickItemUtils.pickItem(event.getPlayer(), item); + } + + private ItemBuilder getPickedItem(Block block) { + String itemId = switch (block.getType()) { + case NOTE_BLOCK -> getNoteBlockItemId(block); + case TRIPWIRE -> getStringBlockItemId(block); + case CHORUS_PLANT -> getChorusBlockItemId(block); + default -> getShapedBlockItemId(block); + }; + return itemId == null ? null : OraxenItems.getItemById(itemId); + } + + private String getNoteBlockItemId(Block block) { + NoteBlockMechanic mechanic = OraxenBlocks.getNoteBlockMechanic(block); + if (mechanic == null) return null; + if (mechanic.isDirectional() && !mechanic.getDirectional().isParentBlock()) + return mechanic.getDirectional().getParentBlock(); + return mechanic.getItemID(); + } + + private String getStringBlockItemId(Block block) { + StringBlockMechanic mechanic = OraxenBlocks.getStringMechanic(block); + if (mechanic != null) return mechanic.getItemID(); + + StringBlockMechanic mechanicBelow = OraxenBlocks.getStringMechanic(block.getRelative(BlockFace.DOWN)); + return mechanicBelow != null && mechanicBelow.isTall() ? mechanicBelow.getItemID() : null; + } + + private String getChorusBlockItemId(Block block) { + ChorusBlockMechanic mechanic = OraxenBlocks.getChorusMechanic(block); + return mechanic == null ? null : mechanic.getItemID(); + } + + private String getShapedBlockItemId(Block block) { + ShapedBlockMechanic mechanic = OraxenBlocks.getShapedMechanic(block); + return mechanic == null ? null : mechanic.getItemID(); + } +} diff --git a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/chorusblock/ChorusBlockMechanic.java b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/chorusblock/ChorusBlockMechanic.java index d276203197..5d038d10df 100644 --- a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/chorusblock/ChorusBlockMechanic.java +++ b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/chorusblock/ChorusBlockMechanic.java @@ -9,6 +9,7 @@ import io.th0rgal.oraxen.mechanics.provided.gameplay.limitedplacing.LimitedPlacing; import io.th0rgal.oraxen.mechanics.provided.gameplay.storage.StorageMechanic; import io.th0rgal.oraxen.utils.actions.ClickAction; +import io.th0rgal.oraxen.utils.OraxenYaml; import io.th0rgal.oraxen.utils.blocksounds.BlockSounds; import io.th0rgal.oraxen.utils.drops.Drop; import org.bukkit.Material; @@ -89,7 +90,7 @@ public ChorusBlockMechanic(MechanicFactory mechanicFactory, ConfigurationSection } public String getModel(ConfigurationSection section) { - return model != null ? model : section.getString("Pack.model"); + return model != null ? model : OraxenYaml.getString(section, "pack.model"); } public int getCustomVariation() { diff --git a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/BlockLocation.java b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/BlockLocation.java index 19d66038ee..ed214ad9b7 100644 --- a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/BlockLocation.java +++ b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/BlockLocation.java @@ -5,6 +5,7 @@ import org.bukkit.Utility; import org.bukkit.World; import org.bukkit.configuration.serialization.ConfigurationSerializable; +import org.bukkit.configuration.serialization.ConfigurationSerialization; import org.bukkit.persistence.PersistentDataType; import org.jetbrains.annotations.NotNull; @@ -12,7 +13,12 @@ import java.util.Map; public class BlockLocation implements ConfigurationSerializable { - public static PersistentDataType dataType = new ConfigurationSerializableDataType<>(BlockLocation.class); + + static { + ConfigurationSerialization.registerClass(BlockLocation.class); + } + + public static final PersistentDataType dataType = new ConfigurationSerializableDataType<>(BlockLocation.class); private int x; private int y; diff --git a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/FurnitureFactory.java b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/FurnitureFactory.java index 526e84189f..1e68da974d 100644 --- a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/FurnitureFactory.java +++ b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/FurnitureFactory.java @@ -14,6 +14,7 @@ import io.th0rgal.oraxen.mechanics.provided.gameplay.furniture.text.FurnitureTextPacketBridge; import io.th0rgal.oraxen.mechanics.provided.gameplay.furniture.text.FurnitureTextRegistry; import io.th0rgal.oraxen.utils.SchedulerUtil; +import io.th0rgal.oraxen.utils.VersionUtil; import io.th0rgal.oraxen.utils.blocksounds.BlockSounds; import org.bukkit.Bukkit; import org.bukkit.configuration.ConfigurationSection; @@ -49,6 +50,8 @@ public FurnitureFactory(ConfigurationSection section) { new JukeboxListener(), new FurnitureTextLoadListener() ); + if (VersionUtil.atOrAbove("1.21.5")) + MechanicsManager.registerListeners(OraxenPlugin.get(), getMechanicID(), new FurniturePickItemListener()); evolvingFurnitures = false; instance = this; FurniturePacketDispatcher.init(); diff --git a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/FurniturePickItemListener.java b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/FurniturePickItemListener.java new file mode 100644 index 0000000000..bb4b3a087b --- /dev/null +++ b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/FurniturePickItemListener.java @@ -0,0 +1,34 @@ +package io.th0rgal.oraxen.mechanics.provided.gameplay.furniture; + +import io.papermc.paper.event.player.PlayerPickBlockEvent; +import io.papermc.paper.event.player.PlayerPickEntityEvent; +import io.papermc.paper.event.player.PlayerPickItemEvent; +import io.th0rgal.oraxen.api.OraxenFurniture; +import io.th0rgal.oraxen.api.OraxenItems; +import io.th0rgal.oraxen.items.ItemBuilder; +import io.th0rgal.oraxen.utils.inventories.PickItemUtils; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; + +public class FurniturePickItemListener implements Listener { + + @EventHandler(ignoreCancelled = true) + public void onPickBlock(PlayerPickBlockEvent event) { + handlePick(event, OraxenFurniture.getFurnitureMechanic(event.getBlock())); + } + + @EventHandler(ignoreCancelled = true) + public void onPickEntity(PlayerPickEntityEvent event) { + handlePick(event, OraxenFurniture.getFurnitureMechanic(event.getEntity())); + } + + private void handlePick(PlayerPickItemEvent event, FurnitureMechanic mechanic) { + if (mechanic == null) return; + + event.setCancelled(true); + ItemBuilder itemBuilder = OraxenItems.getItemById(mechanic.getItemID()); + if (itemBuilder == null) return; + + PickItemUtils.pickItem(event.getPlayer(), itemBuilder.build()); + } +} diff --git a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/text/FurnitureTextEntry.java b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/text/FurnitureTextEntry.java index fdc99975d6..f767f7f6d8 100644 --- a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/text/FurnitureTextEntry.java +++ b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/text/FurnitureTextEntry.java @@ -1,6 +1,6 @@ package io.th0rgal.oraxen.mechanics.provided.gameplay.furniture.text; -import io.th0rgal.oraxen.nms.NMSHandlers; +import org.bukkit.Bukkit; import org.bukkit.Location; import java.util.Arrays; @@ -8,7 +8,6 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; /** * Runtime registration entry for a placed furniture base that has one or more @@ -16,8 +15,6 @@ */ public final class FurnitureTextEntry { - private static final AtomicInteger FALLBACK_VIRTUAL_ID = new AtomicInteger(Integer.MAX_VALUE / 2); - private final UUID baseUuid; private final int baseEntityId; private volatile Location baseLocation; @@ -95,8 +92,6 @@ private static int refreshInterval(FurnitureTextDefinition definition) { } private static int nextVirtualEntityId() { - int entityId = NMSHandlers.getHandler().getNextEntityId(); - if (entityId != -1) return entityId; - return FALLBACK_VIRTUAL_ID.decrementAndGet(); + return Bukkit.getUnsafe().nextEntityId(); } } diff --git a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/text/FurnitureTextPacketListener.java b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/text/FurnitureTextPacketListener.java index ccbd93d9cc..4e115c9b5c 100644 --- a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/text/FurnitureTextPacketListener.java +++ b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/text/FurnitureTextPacketListener.java @@ -13,7 +13,6 @@ import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerDestroyEntities; import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerEntityMetadata; import com.github.retrooper.packetevents.wrapper.play.server.WrapperPlayServerSpawnEntity; -import io.th0rgal.oraxen.nms.NMSHandlers; import io.th0rgal.oraxen.utils.VersionUtil; import io.th0rgal.oraxen.utils.SchedulerUtil; import net.kyori.adventure.text.Component; @@ -97,11 +96,6 @@ void sendTextMetadata(FurnitureTextEntry entry, Player viewer, boolean ignoreRan for (int i = 0; i < entry.size(); i++) { FurnitureTextDefinition def = entry.getDefinitions().get(i); Component text = def.renderComponent(viewer); - if (NMSHandlers.getHandler().sendTextDisplayMetadata(viewer, entry.virtualEntityId(i), text, - def.getScale(), billboardByte(def), def.getViewRange(), def.getLineWidth(), - def.getBackgroundArgb(), def.getTextOpacity(), textFlags(def))) { - continue; - } PacketEvents.getAPI().getPlayerManager().sendPacket(viewer, new WrapperPlayServerEntityMetadata(entry.virtualEntityId(i), buildMetadata(def, viewer, text))); } @@ -134,13 +128,6 @@ private void sendTextEntry(FurnitureTextEntry entry, Player viewer, User user, V ); Component text = def.renderComponent(viewer); - org.bukkit.Location textLocation = new org.bukkit.Location(baseLocation.getWorld(), textPos.x, textPos.y, textPos.z, yaw, pitch); - if (viewer != null && NMSHandlers.getHandler().spawnTextDisplay(viewer, virtualId, virtualUuid, textLocation, - text, def.getScale(), billboardByte(def), def.getViewRange(), def.getLineWidth(), - def.getBackgroundArgb(), def.getTextOpacity(), textFlags(def))) { - continue; - } - WrapperPlayServerEntityMetadata textMeta = new WrapperPlayServerEntityMetadata( virtualId, buildMetadata(def, viewer, text) @@ -213,11 +200,6 @@ void refresh(long tick) { FurnitureTextDefinition def = entry.getDefinitions().get(i); if (!entry.shouldRefresh(def, tick)) continue; Component text = def.renderComponent(viewer); - if (NMSHandlers.getHandler().sendTextDisplayMetadata(viewer, entry.virtualEntityId(i), text, - def.getScale(), billboardByte(def), def.getViewRange(), def.getLineWidth(), - def.getBackgroundArgb(), def.getTextOpacity(), textFlags(def))) { - continue; - } PacketEvents.getAPI().getPlayerManager().sendPacket(viewer, new WrapperPlayServerEntityMetadata(entry.virtualEntityId(i), buildMetadata(def, viewer, text))); } diff --git a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/noteblock/NoteBlockMechanic.java b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/noteblock/NoteBlockMechanic.java index 7b0579bafe..7cdba6b24b 100644 --- a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/noteblock/NoteBlockMechanic.java +++ b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/noteblock/NoteBlockMechanic.java @@ -14,6 +14,7 @@ import io.th0rgal.oraxen.mechanics.provided.gameplay.noteblock.logstrip.LogStripping; import io.th0rgal.oraxen.mechanics.provided.gameplay.storage.StorageMechanic; import io.th0rgal.oraxen.utils.actions.ClickAction; +import io.th0rgal.oraxen.utils.OraxenYaml; import io.th0rgal.oraxen.utils.blocksounds.BlockSounds; import io.th0rgal.oraxen.utils.drops.Drop; import org.bukkit.Material; @@ -131,7 +132,7 @@ public String getModel(ConfigurationSection section) { if (model != null) return model; // use the itemstack model if block model isn't set - return section.getString("Pack.model"); + return OraxenYaml.getString(section, "pack.model"); } public int getCustomVariation() { diff --git a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/noteblock/NoteBlockMechanicListener.java b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/noteblock/NoteBlockMechanicListener.java index c5bcf95da7..3fb204b438 100644 --- a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/noteblock/NoteBlockMechanicListener.java +++ b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/noteblock/NoteBlockMechanicListener.java @@ -8,6 +8,7 @@ import io.th0rgal.oraxen.mechanics.provided.gameplay.limitedplacing.LimitedPlacing; import io.th0rgal.oraxen.mechanics.provided.gameplay.noteblock.directional.DirectionalBlock; import io.th0rgal.oraxen.mechanics.provided.gameplay.storage.StorageMechanic; +import io.th0rgal.oraxen.nms.NMSHandlers; import io.th0rgal.oraxen.utils.*; import io.th0rgal.oraxen.protection.AntiGriefLib; import org.apache.commons.lang3.Range; @@ -34,6 +35,7 @@ import org.bukkit.util.RayTraceResult; import io.th0rgal.oraxen.utils.breaker.BreakerSystem; +import io.th0rgal.oraxen.utils.breaker.AdjacentNoteBlockUpdateHelper; import io.th0rgal.oraxen.utils.breaker.HardnessModifier; import java.util.*; @@ -253,6 +255,28 @@ public void onPlaceAgainstNoteBlock(PlayerInteractEvent event) { BlockData newData = type.isBlock() ? type.createBlockData() : null; makePlayerPlaceBlock(player, event.getHand(), item, block, blockFace, newData); + finishAdjacentBlockChange(player, block.getRelative(blockFace), block.getLocation()); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onPlaceVanillaBlockNextToCustomNoteBlock(final PlayerInteractEvent event) { + final Block placedAgainst = event.getClickedBlock(); + final ItemStack item = event.getItem(); + final EquipmentSlot hand = event.getHand(); + if (event.getAction() != Action.RIGHT_CLICK_BLOCK || placedAgainst == null || item == null || hand == null) + return; + if (OraxenBlocks.isOraxenNoteBlock(placedAgainst) || OraxenBlocks.isOraxenNoteBlock(item)) return; + if (!item.getType().isBlock() || BlockHelpers.isReplaceable(placedAgainst.getType())) return; + if (!event.getPlayer().isSneaking() && BlockHelpers.isInteractable(placedAgainst)) return; + + final Block target = placedAgainst.getRelative(event.getBlockFace()); + if (!BlockHelpers.isReplaceable(target.getType())) return; + if (!AdjacentNoteBlockUpdateHelper.hasCustomVerticalNeighbor(target)) return; + + event.setUseInteractedBlock(Event.Result.DENY); + event.setUseItemInHand(Event.Result.DENY); + makePlayerPlaceBlock(event.getPlayer(), hand, item, placedAgainst, event.getBlockFace(), item.getType().createBlockData()); + finishAdjacentBlockChange(event.getPlayer(), target, placedAgainst.getLocation()); } @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) @@ -527,6 +551,13 @@ public void makePlayerPlaceBlock(final Player player, final EquipmentSlot hand, target.getWorld().sendGameEvent(player, GameEvent.BLOCK_PLACE, target.getLocation().toVector()); } + private void finishAdjacentBlockChange(final Player player, final Block changedBlock, final Location packetBlock) { + if (!AdjacentNoteBlockUpdateHelper.hasCustomVerticalNeighbor(changedBlock)) return; + player.sendBlockChange(changedBlock.getLocation(), changedBlock.getBlockData()); + AdjacentNoteBlockUpdateHelper.resendCustomVerticalNeighbors(changedBlock, player); + NMSHandlers.getHandler().acknowledgeBlockChanges(player, packetBlock, true); + } + private boolean isUnsupportedBlockAboveNoteBlock(Material material) { return material == Material.PISTON || material == Material.STICKY_PISTON || material == Material.SHULKER_BOX || material.name().endsWith("_SHULKER_BOX"); diff --git a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/shaped/ShapedBlockMechanic.java b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/shaped/ShapedBlockMechanic.java index ff29ae4a3d..c4a77c42f5 100644 --- a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/shaped/ShapedBlockMechanic.java +++ b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/shaped/ShapedBlockMechanic.java @@ -8,6 +8,7 @@ import io.th0rgal.oraxen.mechanics.provided.gameplay.block.Placeable; import io.th0rgal.oraxen.mechanics.provided.gameplay.light.LightMechanic; import io.th0rgal.oraxen.mechanics.provided.gameplay.limitedplacing.LimitedPlacing; +import io.th0rgal.oraxen.utils.OraxenYaml; import io.th0rgal.oraxen.utils.blocksounds.BlockSounds; import io.th0rgal.oraxen.utils.drops.Drop; import io.th0rgal.oraxen.utils.drops.Loot; @@ -103,7 +104,7 @@ public Material getPlacedMaterial() { public String getModel(ConfigurationSection section) { if (model != null) return model; // Try to get explicit model from Pack config - String packModel = section.getString("Pack.model"); + String packModel = OraxenYaml.getString(section, "pack.model"); if (packModel != null) return packModel; // Fall back to item ID as model name (used when generate_model: true) return getItemID(); diff --git a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/shaped/ShapedBlockMechanicFactory.java b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/shaped/ShapedBlockMechanicFactory.java index 6075fd0490..ad214f3b48 100644 --- a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/shaped/ShapedBlockMechanicFactory.java +++ b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/shaped/ShapedBlockMechanicFactory.java @@ -11,6 +11,7 @@ import io.th0rgal.oraxen.mechanics.MechanicsManager; import io.th0rgal.oraxen.mechanics.PropertyType; import io.th0rgal.oraxen.utils.BlockHelpers; +import io.th0rgal.oraxen.utils.OraxenYaml; import io.th0rgal.oraxen.utils.VersionUtil; import io.th0rgal.oraxen.utils.logs.Logs; import org.bukkit.Material; @@ -191,7 +192,8 @@ public Mechanic parse(ConfigurationSection section) { List blockTextures = getBlockTextures(section, type); if (blockTextures.isEmpty()) { // Fall back to Pack textures - ConfigurationSection packSection = section.getParent().getParent().getConfigurationSection("Pack"); + ConfigurationSection packSection = OraxenYaml.getConfigurationSection( + section.getParent().getParent(), "pack"); if (packSection != null) { blockTextures = new ArrayList<>(packSection.getStringList("textures")); } @@ -203,7 +205,7 @@ public Mechanic parse(ConfigurationSection section) { texturesByMaterial.put(mechanic.getPlacedMaterial(), blockTextures); } - String parentModel = section.getParent().getParent().getString("Pack.parent_model"); + String parentModel = OraxenYaml.getString(section.getParent().getParent(), "pack.parent_model"); if (parentModel != null && !parentModel.isBlank()) { parentModelByMaterial.put(mechanic.getPlacedMaterial(), parentModel); } diff --git a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/stringblock/StringBlockMechanic.java b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/stringblock/StringBlockMechanic.java index 2c8cae909f..405eb9c829 100644 --- a/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/stringblock/StringBlockMechanic.java +++ b/src/main/java/io/th0rgal/oraxen/mechanics/provided/gameplay/stringblock/StringBlockMechanic.java @@ -11,6 +11,7 @@ import io.th0rgal.oraxen.mechanics.provided.gameplay.storage.StorageMechanic; import io.th0rgal.oraxen.mechanics.provided.gameplay.stringblock.sapling.SaplingMechanic; import io.th0rgal.oraxen.utils.actions.ClickAction; +import io.th0rgal.oraxen.utils.OraxenYaml; import io.th0rgal.oraxen.utils.blocksounds.BlockSounds; import io.th0rgal.oraxen.utils.drops.Drop; import org.bukkit.Material; @@ -117,7 +118,7 @@ public StringBlockMechanic(MechanicFactory mechanicFactory, ConfigurationSection } public String getModel(ConfigurationSection section) { - return model != null ? model : section.getString("Pack.model"); + return model != null ? model : OraxenYaml.getString(section, "pack.model"); } public boolean canPlaceOn(org.bukkit.block.BlockFace face) { return placeable == null || placeable.canPlaceOn(face); } diff --git a/src/main/java/io/th0rgal/oraxen/nms/NMSHandler.java b/src/main/java/io/th0rgal/oraxen/nms/NMSHandler.java index 1e9549023c..89c7ab49f3 100644 --- a/src/main/java/io/th0rgal/oraxen/nms/NMSHandler.java +++ b/src/main/java/io/th0rgal/oraxen/nms/NMSHandler.java @@ -1,7 +1,6 @@ package io.th0rgal.oraxen.nms; import io.th0rgal.oraxen.items.ItemBuilder; -import net.kyori.adventure.text.Component; import org.bukkit.Location; import org.bukkit.block.data.BlockData; import org.bukkit.configuration.ConfigurationSection; @@ -11,11 +10,9 @@ import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.joml.Vector3f; import java.util.Map; import java.util.Set; -import java.util.UUID; public interface NMSHandler { @@ -53,13 +50,7 @@ default Listener packDispatchListener() { @Nullable BlockData correctBlockStates(Player player, EquipmentSlot slot, ItemStack itemStack); - /** - * Removes mineable/axe tag from noteblocks for custom blocks - */ - void customBlockDefaultTools(Player player); - - default void foodComponent(ItemBuilder itemBuilder, ConfigurationSection foodSection) { - + default void acknowledgeBlockChanges(Player player, Location packetBlock, boolean placement) { } default Object consumableComponent(ItemStack itemStack) { @@ -74,36 +65,32 @@ default void consumableComponent(ItemBuilder itemBuilder, ConfigurationSection c } + default Object deathProtectionComponent(ItemStack itemStack) { + return null; + } + + default ItemStack deathProtectionComponent(ItemStack itemStack, Object deathProtectionComponent) { + return itemStack; + } + + default void deathProtectionComponent(ItemBuilder itemBuilder, ConfigurationSection deathProtectionSection) { + + } + default boolean supportsJukeboxPlaying() { return false; } default void playJukeBoxSong(Location location, ItemStack itemStack) { } - default void stopJukeBox(Location location) { - } - // Backpack cosmetic packet methods - /** - * Get the next available entity ID for packet-based entities - */ - default int getNextEntityId() { - return -1; - } - /** * Spawn an invisible armor stand for backpack display */ default void spawnBackpackArmorStand(Player viewer, int entityId, Location location, ItemStack displayItem, boolean small) { } - /** - * Send entity teleport packet - */ - default void sendEntityTeleport(Player viewer, int entityId, Location location) { - } - /** * Send entity head rotation packet */ @@ -116,18 +103,6 @@ default void sendEntityHeadRotation(Player viewer, int entityId, float yaw) { default void sendEntityDestroy(Player viewer, int... entityIds) { } - default boolean spawnTextDisplay(Player viewer, int entityId, UUID uuid, Location location, Component text, - Vector3f scale, byte billboard, float viewRange, int lineWidth, - int backgroundArgb, byte textOpacity, byte flags) { - return false; - } - - default boolean sendTextDisplayMetadata(Player viewer, int entityId, Component text, - Vector3f scale, byte billboard, float viewRange, int lineWidth, - int backgroundArgb, byte textOpacity, byte flags) { - return false; - } - /** * Send mount/ride packet (make entity ride another) */ @@ -148,10 +123,6 @@ default void sendMountPacket(Player viewer, int vehicleId, int... passengerIds) "map", "map_scale_direction", "map_to_lock", "Decorations", "SkullOwner", "Effects", "BlockEntityTag", "BlockStateTag"); - default boolean getSupported() { - return false; - } - /** * Sets a component on an item using the DataComponents registry. * The parsed component is stored in ItemBuilder's generic components map @@ -174,10 +145,6 @@ default boolean getSupported() { */ ItemStack applyGenericComponents(ItemStack itemStack, Map components); - default @NotNull ItemStack paintingVariantComponent(@NotNull ItemStack itemStack, @NotNull String paintingVariant) { - return itemStack; - } - class EmptyNMSHandler implements NMSHandler { @Override @@ -207,28 +174,33 @@ public BlockData correctBlockStates(Player player, EquipmentSlot slot, ItemStack } @Override - public void customBlockDefaultTools(Player player) { - + public void consumableComponent(ItemBuilder item, ConfigurationSection section) { } @Override - public void foodComponent(ItemBuilder item, ConfigurationSection foodSection) { + public Object consumableComponent(ItemStack itemStack) { + return null; } @Override - public void consumableComponent(ItemBuilder item, ConfigurationSection section) { + public ItemStack consumableComponent(ItemStack itemStack, Object consumable) { + return itemStack; } @Override - public Object consumableComponent(ItemStack itemStack) { + public Object deathProtectionComponent(ItemStack itemStack) { return null; } @Override - public ItemStack consumableComponent(ItemStack itemStack, Object consumable) { + public ItemStack deathProtectionComponent(ItemStack itemStack, Object deathProtection) { return itemStack; } + @Override + public void deathProtectionComponent(ItemBuilder item, ConfigurationSection section) { + } + @Override public boolean setComponent(ItemBuilder item, String componentKey, Object component) { return false; diff --git a/src/main/java/io/th0rgal/oraxen/nms/NMSHandlers.java b/src/main/java/io/th0rgal/oraxen/nms/NMSHandlers.java index 6cd2069873..927fa6ed06 100644 --- a/src/main/java/io/th0rgal/oraxen/nms/NMSHandlers.java +++ b/src/main/java/io/th0rgal/oraxen/nms/NMSHandlers.java @@ -56,7 +56,6 @@ public static void setup() { Logs.logSuccess("Version " + version + " has been detected."); Logs.logInfo("Oraxen will use " + handlerClass + "."); } - Bukkit.getPluginManager().registerEvents(new NMSListeners(), OraxenPlugin.get()); Listener packDispatchListener = handler.packDispatchListener(); if (packDispatchListener != null) { Bukkit.getPluginManager().registerEvents(packDispatchListener, OraxenPlugin.get()); diff --git a/src/main/java/io/th0rgal/oraxen/nms/NMSListeners.java b/src/main/java/io/th0rgal/oraxen/nms/NMSListeners.java deleted file mode 100644 index 9b24fb5cf9..0000000000 --- a/src/main/java/io/th0rgal/oraxen/nms/NMSListeners.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.th0rgal.oraxen.nms; - -import io.th0rgal.oraxen.mechanics.provided.gameplay.noteblock.NoteBlockMechanicFactory; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.player.PlayerJoinEvent; - -public class NMSListeners implements Listener { - - @EventHandler - public void onPlayerJoin(PlayerJoinEvent event) { - Player player = event.getPlayer(); - - if (NoteBlockMechanicFactory.isEnabled() && NoteBlockMechanicFactory.getInstance().removeMineableTag()) - NMSHandlers.getHandler().customBlockDefaultTools(player); - } -} diff --git a/src/main/java/io/th0rgal/oraxen/pack/generation/DuplicationHandler.java b/src/main/java/io/th0rgal/oraxen/pack/generation/DuplicationHandler.java index 0cc32373a1..d430329c5b 100644 --- a/src/main/java/io/th0rgal/oraxen/pack/generation/DuplicationHandler.java +++ b/src/main/java/io/th0rgal/oraxen/pack/generation/DuplicationHandler.java @@ -939,21 +939,21 @@ private static void setMigratedItemProperties(YamlConfiguration yaml, String id, yaml.set(id + ".material", materialName); yaml.set(id + ".excludeFromInventory", true); yaml.set(id + ".excludeFromCommands", true); - yaml.set(id + ".Pack.generate_model", false); - yaml.set(id + ".Pack.model", modelPath); + yaml.set(id + ".pack.generate_model", false); + yaml.set(id + ".pack.model", modelPath); if (pullingModels.containsKey(cmd)) - yaml.set(id + ".Pack.pulling_models", pullingModels.get(cmd)); + yaml.set(id + ".pack.pulling_models", pullingModels.get(cmd)); if (damagedModels.containsKey(cmd)) - yaml.set(id + ".Pack.damaged_models", damagedModels.get(cmd)); + yaml.set(id + ".pack.damaged_models", damagedModels.get(cmd)); if (chargedModels.containsKey(cmd)) - yaml.set(id + ".Pack.charged_model", chargedModels.get(cmd)); + yaml.set(id + ".pack.charged_model", chargedModels.get(cmd)); if (blockingModels.containsKey(cmd)) - yaml.set(id + ".Pack.blocking_model", blockingModels.get(cmd)); + yaml.set(id + ".pack.blocking_model", blockingModels.get(cmd)); if (castModels.containsKey(cmd)) - yaml.set(id + ".Pack.cast_model", castModels.get(cmd)); + yaml.set(id + ".pack.cast_model", castModels.get(cmd)); if (Settings.RETAIN_CUSTOM_MODEL_DATA.toBool()) - yaml.set(id + ".Pack.custom_model_data", cmd); + yaml.set(id + ".pack.custom_model_data", cmd); } private static boolean saveMigratedYaml(YamlConfiguration migratedYaml, Material material) { diff --git a/src/main/java/io/th0rgal/oraxen/pack/generation/MultiVersionPackGenerator.java b/src/main/java/io/th0rgal/oraxen/pack/generation/MultiVersionPackGenerator.java index 3ef4385879..86e56a5d7a 100644 --- a/src/main/java/io/th0rgal/oraxen/pack/generation/MultiVersionPackGenerator.java +++ b/src/main/java/io/th0rgal/oraxen/pack/generation/MultiVersionPackGenerator.java @@ -105,6 +105,7 @@ public void generateMultipleVersions(List output, boolean switching OraxenPackGeneratedEvent event = new OraxenPackGeneratedEvent(output); event.callEvent(); output = event.getOutput(); + UnprotectedPackWriter.writeConfigured(output, packFolder); PackObfuscator.obfuscate(output); // Define which pack versions to generate diff --git a/src/main/java/io/th0rgal/oraxen/pack/generation/ResourcePack.java b/src/main/java/io/th0rgal/oraxen/pack/generation/ResourcePack.java index 7bfa26a5b2..44f8297391 100644 --- a/src/main/java/io/th0rgal/oraxen/pack/generation/ResourcePack.java +++ b/src/main/java/io/th0rgal/oraxen/pack/generation/ResourcePack.java @@ -229,7 +229,6 @@ private void finishSinglePackOutputOnMain(ExecutorService packWorker, List try { if (shutdownRequested) return; filterGeneratedCoreShadersBelow1214(output, MinecraftVersion.getCurrentVersion()); + UnprotectedPackWriter.writeConfigured(output, packFolder); + PackObfuscator.obfuscate(output); if (shutdownRequested) return; ZipUtils.writeZipFile(pack, output); if (shutdownRequested) return; diff --git a/src/main/java/io/th0rgal/oraxen/pack/generation/UnprotectedPackWriter.java b/src/main/java/io/th0rgal/oraxen/pack/generation/UnprotectedPackWriter.java new file mode 100644 index 0000000000..b0d29e1d3c --- /dev/null +++ b/src/main/java/io/th0rgal/oraxen/pack/generation/UnprotectedPackWriter.java @@ -0,0 +1,73 @@ +package io.th0rgal.oraxen.pack.generation; + +import io.th0rgal.oraxen.configs.Settings; +import io.th0rgal.oraxen.utils.VirtualFile; +import io.th0rgal.oraxen.utils.logs.Logs; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.FileTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** Writes an additional ordinary ZIP without changing the files used for client packs. */ +final class UnprotectedPackWriter { + + private UnprotectedPackWriter() { + } + + static void writeConfigured(List output, File packFolder) { + Object location = Settings.UNPROTECTED_PACK_LOCATION.getValue(); + if (location == null || location.toString().isBlank()) return; + + try { + write(Path.of(location.toString()), output, packFolder); + } catch (IOException | RuntimeException exception) { + Logs.logWarning("Failed to write unprotected resource pack: " + exception.getMessage()); + } + } + + static void write(Path destination, List output, File packFolder) throws IOException { + Path target = destination.toFile().getCanonicalFile().toPath(); + Path sourceFolder = packFolder.getCanonicalFile().toPath(); + // Avoid overwriting client packs or importing the exported ZIP on the next generation. + if (target.startsWith(sourceFolder)) { + throw new IOException("unprotected-location must be outside the resource-pack source folder: " + sourceFolder); + } + + Files.createDirectories(target.getParent()); + Path temporary = Files.createTempFile(target.getParent(), ".oraxen-unprotected-", ".zip"); + try { + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(temporary))) { + Set paths = new HashSet<>(); + for (VirtualFile file : output) { + if (!paths.add(file.getPath())) continue; + byte[] content; + try (InputStream input = file.getInputStream()) { + if (input == null) throw new IOException("Cannot read " + file.getPath()); + content = input.readAllBytes(); + } + // Event listeners may supply one-shot streams. Restore before any ZIP writes + // so an export failure cannot leave the client pack with consumed streams. + file.setInputStream(new ByteArrayInputStream(content)); + ZipEntry entry = new ZipEntry(file.getPath()); + entry.setLastModifiedTime(FileTime.fromMillis(0L)); + zip.putNextEntry(entry); + zip.write(content); + zip.closeEntry(); + } + } + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(temporary); + } + } +} diff --git a/src/main/java/io/th0rgal/oraxen/recipes/builders/AnvilBuilder.java b/src/main/java/io/th0rgal/oraxen/recipes/builders/AnvilBuilder.java new file mode 100644 index 0000000000..6636a491ad --- /dev/null +++ b/src/main/java/io/th0rgal/oraxen/recipes/builders/AnvilBuilder.java @@ -0,0 +1,23 @@ +package io.th0rgal.oraxen.recipes.builders; + +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; + +public class AnvilBuilder extends WorkstationBuilder { + + public AnvilBuilder(Player player) { + super(player, "anvil", "experience_cost"); + } + + @Override + Inventory createInventory(Player player, Component inventoryTitle) { + return Bukkit.createInventory(player, InventoryType.ANVIL, inventoryTitle); + } + + public void setExperienceCost(int experienceCost) { + setValue(experienceCost); + } +} diff --git a/src/main/java/io/th0rgal/oraxen/recipes/builders/GrindstoneBuilder.java b/src/main/java/io/th0rgal/oraxen/recipes/builders/GrindstoneBuilder.java new file mode 100644 index 0000000000..b6b8410cf9 --- /dev/null +++ b/src/main/java/io/th0rgal/oraxen/recipes/builders/GrindstoneBuilder.java @@ -0,0 +1,28 @@ +package io.th0rgal.oraxen.recipes.builders; + +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; + +public class GrindstoneBuilder extends WorkstationBuilder { + + public GrindstoneBuilder(Player player) { + super(player, "grindstone", "experience"); + } + + @Override + Inventory createInventory(Player player, Component inventoryTitle) { + // Paper opens custom grindstone inventories through a separate native GrindstoneMenu. + // Its visible slots are not backed by the Inventory returned here and vanilla grindstone + // slot predicates would also reject otherwise valid custom recipe ingredients. A hopper + // gives us unrestricted, directly-backed authoring slots; slots 0-2 map to base, + // addition and result, while the builder listener keeps the remaining slots unused. + return Bukkit.createInventory(player, InventoryType.HOPPER, inventoryTitle); + } + + public void setExperience(int experience) { + setValue(experience); + } +} diff --git a/src/main/java/io/th0rgal/oraxen/recipes/builders/RecipeBuilder.java b/src/main/java/io/th0rgal/oraxen/recipes/builders/RecipeBuilder.java index c5f9a54b02..36ee1b4736 100644 --- a/src/main/java/io/th0rgal/oraxen/recipes/builders/RecipeBuilder.java +++ b/src/main/java/io/th0rgal/oraxen/recipes/builders/RecipeBuilder.java @@ -33,7 +33,10 @@ public abstract class RecipeBuilder { protected RecipeBuilder(Player player, String builderName) { this.player = player; this.builderName = builderName; - this.inventoryTitle = player.getName() + " " + builderName + " builder"; + this.inventoryTitle = switch (builderName) { + case "shaped", "shapeless" -> "Recipe builder"; + default -> Character.toUpperCase(builderName.charAt(0)) + builderName.substring(1) + " builder"; + }; UUID playerId = player.getUniqueId(); RecipeBuilder existingBuilder = MAP.get(playerId); inventory = existingBuilder != null && existingBuilder.builderName.equals(builderName) @@ -96,6 +99,10 @@ public String getInventoryTitle() { return inventoryTitle; } + public boolean matchesInventory(Inventory inventory) { + return this.inventory == inventory; + } + public Player getPlayer() { return player; } diff --git a/src/main/java/io/th0rgal/oraxen/recipes/builders/SmithingBuilder.java b/src/main/java/io/th0rgal/oraxen/recipes/builders/SmithingBuilder.java new file mode 100644 index 0000000000..0f0f7b45d6 --- /dev/null +++ b/src/main/java/io/th0rgal/oraxen/recipes/builders/SmithingBuilder.java @@ -0,0 +1,49 @@ +package io.th0rgal.oraxen.recipes.builders; + +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; + +public class SmithingBuilder extends RecipeBuilder { + + public SmithingBuilder(Player player) { + super(player, "smithing"); + } + + @Override + Inventory createInventory(Player player, Component inventoryTitle) { + return Bukkit.createInventory(player, InventoryType.SMITHING, inventoryTitle); + } + + @Override + public void saveRecipe(String name) { + saveRecipe(name, null); + } + + @Override + public void saveRecipe(String name, String permission) { + ItemStack[] content = getInventory().getContents(); + ConfigurationSection newCraftSection = getConfig().createSection(name); + + setSingleIngredient(newCraftSection.createSection("template"), content[0]); + setSingleIngredient(newCraftSection.createSection("base"), content[1]); + setSingleIngredient(newCraftSection.createSection("addition"), content[2]); + setSerializedItem(newCraftSection.createSection("result"), content[3]); + + if (permission != null && !permission.isEmpty()) + newCraftSection.set("permission", permission); + + saveConfig(); + close(); + } + + private void setSingleIngredient(ConfigurationSection section, ItemStack itemStack) { + ItemStack singleItem = itemStack.clone(); + singleItem.setAmount(1); + setSerializedItem(section, singleItem); + } +} diff --git a/src/main/java/io/th0rgal/oraxen/recipes/builders/WorkstationBuilder.java b/src/main/java/io/th0rgal/oraxen/recipes/builders/WorkstationBuilder.java new file mode 100644 index 0000000000..0fb104f51c --- /dev/null +++ b/src/main/java/io/th0rgal/oraxen/recipes/builders/WorkstationBuilder.java @@ -0,0 +1,44 @@ +package io.th0rgal.oraxen.recipes.builders; + +import io.th0rgal.oraxen.utils.ItemUtils; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +public abstract class WorkstationBuilder extends RecipeBuilder { + + private final String valueKey; + private int value; + + protected WorkstationBuilder(Player player, String builderName, String valueKey) { + super(player, builderName); + this.valueKey = valueKey; + } + + @Override + public void saveRecipe(String name) { + saveRecipe(name, null); + } + + @Override + public void saveRecipe(String name, String permission) { + ItemStack[] content = getInventory().getContents(); + ConfigurationSection newCraftSection = getConfig().createSection(name); + + setSerializedItem(newCraftSection.createSection("base"), content[0]); + if (!ItemUtils.isEmpty(content[1])) + setSerializedItem(newCraftSection.createSection("addition"), content[1]); + setSerializedItem(newCraftSection.createSection("result"), content[2]); + newCraftSection.set(valueKey, value); + + if (permission != null && !permission.isEmpty()) + newCraftSection.set("permission", permission); + + saveConfig(); + close(); + } + + protected void setValue(int value) { + this.value = Math.max(0, value); + } +} diff --git a/src/main/java/io/th0rgal/oraxen/recipes/listeners/CrafterRecipeEvents.java b/src/main/java/io/th0rgal/oraxen/recipes/listeners/CrafterRecipeEvents.java new file mode 100644 index 0000000000..edf483525b --- /dev/null +++ b/src/main/java/io/th0rgal/oraxen/recipes/listeners/CrafterRecipeEvents.java @@ -0,0 +1,17 @@ +package io.th0rgal.oraxen.recipes.listeners; + +import org.bukkit.block.Crafter; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.CrafterCraftEvent; + +public class CrafterRecipeEvents implements Listener { + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + public void onCraft(CrafterCraftEvent event) { + if (!(event.getBlock().getState() instanceof Crafter crafter)) return; + if (RecipesEventsManager.containsRestrictedCraftingIngredient(crafter.getInventory().getContents())) + event.setCancelled(true); + } +} diff --git a/src/main/java/io/th0rgal/oraxen/recipes/listeners/CustomWorkstationEvents.java b/src/main/java/io/th0rgal/oraxen/recipes/listeners/CustomWorkstationEvents.java index a98cb03dbb..911f11ba0c 100644 --- a/src/main/java/io/th0rgal/oraxen/recipes/listeners/CustomWorkstationEvents.java +++ b/src/main/java/io/th0rgal/oraxen/recipes/listeners/CustomWorkstationEvents.java @@ -2,6 +2,8 @@ import io.th0rgal.oraxen.recipes.CustomWorkstationRecipe; import io.th0rgal.oraxen.recipes.CustomWorkstationRegistry; +import io.th0rgal.oraxen.recipes.builders.AnvilBuilder; +import io.th0rgal.oraxen.recipes.builders.GrindstoneBuilder; import io.th0rgal.oraxen.utils.InventoryUtils; import org.bukkit.GameMode; import org.bukkit.Material; @@ -31,6 +33,11 @@ public class CustomWorkstationEvents implements Listener { @EventHandler(priority = EventPriority.HIGHEST) public void prepareAnvil(PrepareAnvilEvent event) { + if (RecipesBuilderEvents.isBuilderInventory(event, AnvilBuilder.class)) { + event.setResult(null); + return; + } + Player player = InventoryUtils.playerFromView(event); CustomWorkstationRecipe recipe = CustomWorkstationRegistry.match(CustomWorkstationRecipe.Type.ANVIL, event.getInventory().getItem(0), event.getInventory().getItem(1)); @@ -45,6 +52,11 @@ public void prepareAnvil(PrepareAnvilEvent event) { @EventHandler(priority = EventPriority.HIGHEST) public void prepareGrindstone(PrepareGrindstoneEvent event) { + if (RecipesBuilderEvents.isBuilderInventory(event, GrindstoneBuilder.class)) { + event.setResult(null); + return; + } + Player player = InventoryUtils.playerFromView(event); Match match = grindstoneRecipe(event.getInventory().getItem(0), event.getInventory().getItem(1)); if (match == null) return; diff --git a/src/main/java/io/th0rgal/oraxen/recipes/listeners/RecipesBuilderEvents.java b/src/main/java/io/th0rgal/oraxen/recipes/listeners/RecipesBuilderEvents.java index cc963a4439..f72694c60f 100644 --- a/src/main/java/io/th0rgal/oraxen/recipes/listeners/RecipesBuilderEvents.java +++ b/src/main/java/io/th0rgal/oraxen/recipes/listeners/RecipesBuilderEvents.java @@ -1,13 +1,19 @@ package io.th0rgal.oraxen.recipes.listeners; import io.th0rgal.oraxen.recipes.builders.RecipeBuilder; +import io.th0rgal.oraxen.recipes.builders.GrindstoneBuilder; +import io.th0rgal.oraxen.recipes.builders.WorkstationBuilder; import io.th0rgal.oraxen.utils.InventoryUtils; import org.bukkit.Material; +import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryAction; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryEvent; import org.bukkit.event.inventory.InventoryType; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.inventory.ItemStack; @@ -16,10 +22,35 @@ public class RecipesBuilderEvents implements Listener { + static boolean isBuilderInventory(InventoryEvent event, Class builderType) { + Player player = InventoryUtils.playerFromView(event); + RecipeBuilder recipeBuilder = player == null ? null : RecipeBuilder.get(player.getUniqueId()); + return builderType.isInstance(recipeBuilder) + && recipeBuilder.matchesInventory(event.getInventory()); + } + @EventHandler(priority = EventPriority.HIGH) public void setCursor(InventoryClickEvent event) { - String recipeBuilderTitle = Optional.ofNullable(RecipeBuilder.get(event.getWhoClicked().getUniqueId())).map(RecipeBuilder::getInventoryTitle).orElse(null); - if (!InventoryUtils.getTitleFromView(event).equals(recipeBuilderTitle) || event.getSlotType() != InventoryType.SlotType.RESULT) return; + RecipeBuilder recipeBuilder = RecipeBuilder.get(event.getWhoClicked().getUniqueId()); + if (recipeBuilder == null || !recipeBuilder.matchesInventory(event.getInventory())) return; + + // Paper delegates shift-clicks in custom anvil menus to a separate native AnvilMenu. + // Prevent that path for workstation builders. Grindstone authoring uses a generic hopper + // inventory, where disabling quick-move also keeps items out of the two unused slots. + if (recipeBuilder instanceof WorkstationBuilder + && event.getAction() == InventoryAction.MOVE_TO_OTHER_INVENTORY) { + event.setCancelled(true); + return; + } + + if (recipeBuilder instanceof GrindstoneBuilder && isUnusedGrindstoneSlot(event.getRawSlot(), event.getInventory().getSize())) { + event.setCancelled(true); + return; + } + + boolean resultSlot = event.getSlotType() == InventoryType.SlotType.RESULT + || recipeBuilder instanceof GrindstoneBuilder && event.getRawSlot() == 2; + if (!resultSlot) return; event.setCancelled(true); ItemStack currentResult = Optional.ofNullable(event.getCurrentItem()).orElse(new ItemStack(Material.AIR)).clone(); @@ -28,10 +59,24 @@ public void setCursor(InventoryClickEvent event) { event.getView().setCursor(currentResult); } + @EventHandler(priority = EventPriority.HIGH) + public void restrictGrindstoneDrag(InventoryDragEvent event) { + RecipeBuilder recipeBuilder = RecipeBuilder.get(event.getWhoClicked().getUniqueId()); + if (!(recipeBuilder instanceof GrindstoneBuilder) || !recipeBuilder.matchesInventory(event.getInventory())) return; + + int inventorySize = event.getInventory().getSize(); + if (event.getRawSlots().stream().anyMatch(slot -> isUnusedGrindstoneSlot(slot, inventorySize))) + event.setCancelled(true); + } + + private boolean isUnusedGrindstoneSlot(int rawSlot, int inventorySize) { + return rawSlot >= 3 && rawSlot < inventorySize; + } + @EventHandler(priority = EventPriority.HIGH) public void onInventoryClosed(InventoryCloseEvent event) { RecipeBuilder recipeBuilder = RecipeBuilder.get(event.getPlayer().getUniqueId()); - if (recipeBuilder == null || !InventoryUtils.getTitleFromView(event).equals(recipeBuilder.getInventoryTitle())) + if (recipeBuilder == null || !recipeBuilder.matchesInventory(event.getInventory())) return; recipeBuilder.setInventory(event.getInventory()); diff --git a/src/main/java/io/th0rgal/oraxen/recipes/listeners/RecipesEventsManager.java b/src/main/java/io/th0rgal/oraxen/recipes/listeners/RecipesEventsManager.java index a10249924e..75dc13ca3f 100644 --- a/src/main/java/io/th0rgal/oraxen/recipes/listeners/RecipesEventsManager.java +++ b/src/main/java/io/th0rgal/oraxen/recipes/listeners/RecipesEventsManager.java @@ -56,6 +56,13 @@ public void registerEvents() { Bukkit.getPluginManager().registerEvents(instance, OraxenPlugin.get()); Bukkit.getPluginManager().registerEvents(new SmithingRecipeEvents(), OraxenPlugin.get()); Bukkit.getPluginManager().registerEvents(new CustomWorkstationEvents(), OraxenPlugin.get()); + // Keep newer event types out of this listener so it still loads on 1.20.1. + try { + Class.forName("org.bukkit.event.block.CrafterCraftEvent"); + Bukkit.getPluginManager().registerEvents(new CrafterRecipeEvents(), OraxenPlugin.get()); + } catch (ClassNotFoundException ignored) { + // Crafters are unavailable on this server version. + } eventsRegistered = true; } @@ -90,11 +97,7 @@ public void onCrafted(PrepareItemCraftEvent event) { boolean containsOraxenItem = Arrays.stream(event.getInventory().getMatrix()).anyMatch(OraxenItems::exists); if (!containsOraxenItem || recipe == null) return; - if (Arrays.stream(event.getInventory().getMatrix()).anyMatch(item -> { - if (MiscMechanicFactory.get() == null) return false; - MiscMechanic mechanic = MiscMechanicFactory.get().getMechanic(item); - return mechanic != null && !mechanic.isAllowedInVanillaRecipes(); - })) { + if (containsRestrictedCraftingIngredient(event.getInventory().getMatrix())) { event.getInventory().setResult(null); return; } @@ -108,6 +111,17 @@ public void onCrafted(PrepareItemCraftEvent event) { persistBackpackContents(event); } + static boolean containsRestrictedCraftingIngredient(ItemStack[] ingredients) { + MiscMechanicFactory factory = MiscMechanicFactory.get(); + if (factory == null) return false; + for (ItemStack item : ingredients) { + if (item == null || item.isEmpty()) continue; + MiscMechanic mechanic = factory.getMechanic(item); + if (mechanic != null && !mechanic.isAllowedInVanillaRecipes()) return true; + } + return false; + } + private void persistBackpackContents(PrepareItemCraftEvent event) { ItemStack result = event.getInventory().getResult(); if (!hasBackpackMechanic(result)) return; diff --git a/src/main/java/io/th0rgal/oraxen/recipes/listeners/SmithingRecipeEvents.java b/src/main/java/io/th0rgal/oraxen/recipes/listeners/SmithingRecipeEvents.java index e37203acf0..5f783d731d 100644 --- a/src/main/java/io/th0rgal/oraxen/recipes/listeners/SmithingRecipeEvents.java +++ b/src/main/java/io/th0rgal/oraxen/recipes/listeners/SmithingRecipeEvents.java @@ -3,6 +3,7 @@ import io.th0rgal.oraxen.api.OraxenItems; import io.th0rgal.oraxen.mechanics.provided.misc.misc.MiscMechanic; import io.th0rgal.oraxen.mechanics.provided.misc.misc.MiscMechanicFactory; +import io.th0rgal.oraxen.recipes.builders.SmithingBuilder; import io.th0rgal.oraxen.utils.InventoryUtils; import io.th0rgal.oraxen.utils.ItemUtils; import org.bukkit.Bukkit; @@ -22,6 +23,11 @@ public class SmithingRecipeEvents implements Listener { @EventHandler public void onSmithingRecipe(PrepareSmithingEvent event) { + if (RecipesBuilderEvents.isBuilderInventory(event, SmithingBuilder.class)) { + event.setResult(null); + return; + } + SmithingInventory inventory = event.getInventory(); ItemStack template = inventory.getInputTemplate(); ItemStack material = inventory.getInputMineral(); diff --git a/src/main/java/io/th0rgal/oraxen/utils/MusicDiscHelpers.java b/src/main/java/io/th0rgal/oraxen/utils/MusicDiscHelpers.java index 23a922a776..5a83314061 100644 --- a/src/main/java/io/th0rgal/oraxen/utils/MusicDiscHelpers.java +++ b/src/main/java/io/th0rgal/oraxen/utils/MusicDiscHelpers.java @@ -11,6 +11,7 @@ import net.kyori.adventure.sound.Sound; import net.kyori.adventure.sound.SoundStop; import org.bukkit.Bukkit; +import org.bukkit.Effect; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.NamespacedKey; @@ -79,7 +80,7 @@ public static ItemStack stopJukeboxAt(Entity entity, float volume, float pitch) if(record == null) return null; pdc.remove(MUSIC_DISC_KEY); if(ItemUtils.isMusicDisc(record) && volume == 1F && pitch == 1F && NMSHandlers.getHandler().supportsJukeboxPlaying()) { - NMSHandlers.getHandler().stopJukeBox(entity.getLocation()); + entity.getWorld().playEffect(entity.getLocation(), Effect.SOUND_STOP_JUKEBOX_SONG, 0); } else { var song = MusicDiscHelpers.getSong(record); if (song == null) return record; diff --git a/src/main/java/io/th0rgal/oraxen/utils/breaker/AdjacentNoteBlockUpdateHelper.java b/src/main/java/io/th0rgal/oraxen/utils/breaker/AdjacentNoteBlockUpdateHelper.java new file mode 100644 index 0000000000..7072c76d6a --- /dev/null +++ b/src/main/java/io/th0rgal/oraxen/utils/breaker/AdjacentNoteBlockUpdateHelper.java @@ -0,0 +1,71 @@ +package io.th0rgal.oraxen.utils.breaker; + +import io.th0rgal.oraxen.api.OraxenBlocks; +import io.th0rgal.oraxen.utils.SchedulerUtil; +import io.th0rgal.oraxen.utils.VersionUtil; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.BlockData; +import org.bukkit.entity.Player; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +public final class AdjacentNoteBlockUpdateHelper { + + private AdjacentNoteBlockUpdateHelper() { + } + + public static boolean hasCustomVerticalNeighbor(final Block block) { + return OraxenBlocks.isOraxenNoteBlock(block.getRelative(BlockFace.UP)) + || OraxenBlocks.isOraxenNoteBlock(block.getRelative(BlockFace.DOWN)); + } + + public static void resendCustomVerticalNeighbors(final Block changedBlock, final Player actor) { + final Map updates = customVerticalNeighborStates(changedBlock); + if (updates.isEmpty()) return; + + updates.forEach(actor::sendBlockChange); + + final World world = changedBlock.getWorld(); + final Location origin = changedBlock.getLocation(); + final UUID actorId = actor.getUniqueId(); + if (!VersionUtil.isFoliaServer()) { + for (final Player viewer : world.getPlayers()) { + if (viewer.getUniqueId().equals(actorId) || !isWithinTrackingDistance(viewer, origin)) continue; + updates.forEach(viewer::sendBlockChange); + } + return; + } + + for (final Player viewer : Bukkit.getOnlinePlayers()) { + if (viewer.getUniqueId().equals(actorId)) continue; + SchedulerUtil.runForEntity(viewer, () -> { + if (!viewer.isOnline() || !viewer.getWorld().equals(world) + || !isWithinTrackingDistance(viewer, origin)) return; + updates.forEach(viewer::sendBlockChange); + }, null); + } + } + + static Map customVerticalNeighborStates(final Block block) { + final Map updates = new LinkedHashMap<>(2); + for (final BlockFace face : new BlockFace[]{BlockFace.DOWN, BlockFace.UP}) { + final Block neighbor = block.getRelative(face); + if (OraxenBlocks.isOraxenNoteBlock(neighbor)) + updates.put(neighbor.getLocation(), neighbor.getBlockData()); + } + return updates; + } + + private static boolean isWithinTrackingDistance(final Player player, final Location origin) { + final Location playerLocation = player.getLocation(); + final int trackingDistance = (Bukkit.getViewDistance() + 1) * 16; + return Math.abs(playerLocation.getX() - origin.getX()) <= trackingDistance + && Math.abs(playerLocation.getZ() - origin.getZ()) <= trackingDistance; + } +} diff --git a/src/main/java/io/th0rgal/oraxen/utils/breaker/BreakerSystem.java b/src/main/java/io/th0rgal/oraxen/utils/breaker/BreakerSystem.java index e0a7e5bfe9..f4a2d11bc2 100644 --- a/src/main/java/io/th0rgal/oraxen/utils/breaker/BreakerSystem.java +++ b/src/main/java/io/th0rgal/oraxen/utils/breaker/BreakerSystem.java @@ -14,6 +14,7 @@ import io.th0rgal.oraxen.mechanics.provided.gameplay.noteblock.NoteBlockMechanic; import io.th0rgal.oraxen.mechanics.provided.gameplay.shaped.ShapedBlockMechanic; import io.th0rgal.oraxen.mechanics.provided.gameplay.stringblock.StringBlockMechanic; +import io.th0rgal.oraxen.nms.NMSHandlers; import io.th0rgal.oraxen.utils.BlockHelpers; import io.th0rgal.oraxen.utils.ItemUtils; import io.th0rgal.oraxen.utils.SchedulerUtil; @@ -29,6 +30,7 @@ import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; +import org.bukkit.block.data.BlockData; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; @@ -66,12 +68,20 @@ public class BreakerSystem implements Listener { private final Set breakerLocations = ConcurrentHashMap.newKeySet(); private final Map breakerTasks = new ConcurrentHashMap<>(); private final Map breakerPlaySound = new ConcurrentHashMap<>(); + private final Map clientSideBreakSuppressions = new ConcurrentHashMap<>(); @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) public void onBlockDamage(final BlockDamageEvent event) { handleEvent(event.getPlayer(), event.getBlock(), event.getBlockFace(), () -> event.setCancelled(true), true); } + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onAdjacentVanillaBlockDamage(final BlockDamageEvent event) { + if (!shouldProtectAdjacentVanillaBlock(event.getPlayer(), event.getBlock())) return; + handleAdjacentVanillaBlock(event.getPlayer(), event.getBlock(), () -> event.setCancelled(true), + event.getInstaBreak()); + } + @EventHandler(priority = EventPriority.LOW) public void onBlockDamageAbort(final BlockDamageAbortEvent event) { handleEvent(event.getPlayer(), event.getBlock(), BlockFace.UP, () -> { @@ -86,13 +96,19 @@ private void sendBlockBreak(final Player player, final Location location, final } private void handleEvent(Player player, Block block, BlockFace blockFace, Runnable cancel, boolean startedDigging) { - if (player.getGameMode() == GameMode.CREATIVE) return; - final Location location = block.getLocation(); final World world = block.getWorld(); - final ItemStack item = player.getInventory().getItemInMainHand(); + // Always release client-side suppression on abort, even if the block was replaced or the + // player changed game mode since START_DESTROY_BLOCK and no modifier matches any more. + if (!startedDigging) { + stopBlockBreaker(location); + stopBlockHitSound(location); + } + + if (player.getGameMode() == GameMode.CREATIVE) return; + final ItemStack item = player.getInventory().getItemInMainHand(); HardnessModifier triggeredModifier = null; for (final HardnessModifier modifier : MODIFIERS) { if (modifier.isTriggered(player, block, item)) { @@ -100,6 +116,7 @@ private void handleEvent(Player player, Block block, BlockFace blockFace, Runnab break; } } + if (triggeredModifier == null) return; final long period = triggeredModifier.getPeriod(player, block, item); if (period == 0) return; @@ -113,9 +130,16 @@ private void handleEvent(Player player, Block block, BlockFace blockFace, Runnab if (block.getType() == Material.TRIPWIRE && stringMechanic == null) return; if (block.getType() == Material.BARRIER && furnitureMechanic == null) return; + // A vanilla client predicts the removal of a mined note block and immediately runs + // NoteBlock#updateShape on the blocks above and below it. Their instrument states then + // briefly select vanilla resource-pack models until Paper acknowledges the dig sequence. + // Keep these vulnerable stack breaks server-authoritative so that prediction never starts. + final boolean protectNoteBlockStack = noteMechanic != null + && ClientSideBlockBreakSuppressor.isSupported() + && hasCustomVerticalNoteBlockNeighbor(block); if (CustomBlockMiningListener.isSupported() && (noteMechanic != null || stringMechanic != null || shapedMechanic != null - || chorusMechanic != null)) { + || chorusMechanic != null) && !protectNoteBlockStack) { return; } @@ -145,16 +169,18 @@ private void handleEvent(Player player, Block block, BlockFace blockFace, Runnab durabilityAction = null; } - if (breakerLocations.contains(location)) { - SchedulerUtil.ScheduledTask existingTask = breakerTasks.remove(location); - if (existingTask != null) existingTask.cancel(); - } + if (breakerLocations.contains(location)) stopBlockBreaker(location); breakerLocations.add(location); + if (protectNoteBlockStack) suppressClientSideBreaking(player, location); // Defer the rest to the next tick so the PlayerInteractEvent and Oraxen damage // events fire after BlockDamageEvent processing has fully completed. final HardnessModifier modifier = triggeredModifier; + final boolean useAttributeTiming = protectNoteBlockStack && CustomBlockMiningListener.isSupported(); + final float serverBreakProgress = useAttributeTiming + ? CustomBlockMiningListener.serverDrivenBreakProgress(player, block, item) + : 0.0F; SchedulerUtil.runAtLocation(location, () -> { // Fire PlayerInteractEvent for plugin support (cancellation state is ignored) final PlayerInteractEvent playerInteractEvent = @@ -178,14 +204,16 @@ private void handleEvent(Player player, Block block, BlockFace blockFace, Runnab final int[] valueHolder = {0}; final float[] clientProgress = {0f}; - SchedulerUtil.ScheduledTask breakerTask = SchedulerUtil.runAtLocationTimer(location, period, period, () -> { + final float[] serverProgress = {0f}; + final long timerPeriod = useAttributeTiming ? 1L : period; + SchedulerUtil.ScheduledTask breakerTask = SchedulerUtil.runAtLocationTimer(location, timerPeriod, timerPeriod, () -> { if (!breakerLocations.contains(location)) { stopBlockBreaker(location); stopBlockHitSound(location); return; } - if (item.getEnchantmentLevel(EnchantmentWrapper.EFFICIENCY) >= 5) + if (!useAttributeTiming && item.getEnchantmentLevel(EnchantmentWrapper.EFFICIENCY) >= 5) valueHolder[0] = 10; // Replaces the old STOP_DESTROY_BLOCK packet handling: once the client's own @@ -194,19 +222,28 @@ private void handleEvent(Player player, Block block, BlockFace blockFace, Runnab // and break the block by itself after the player let go. Stop once the client // must have finished (or quit); if the player is still holding the button, the // client re-starts digging and the new BlockDamageEvent restarts this breaker. - clientProgress[0] += clientBreakSpeed * period; - if (valueHolder[0] < 10 && (!player.isOnline() || clientProgress[0] >= 1f)) { + clientProgress[0] += clientBreakSpeed * timerPeriod; + if (!player.isOnline() || (!protectNoteBlockStack && valueHolder[0] < 10 && clientProgress[0] >= 1f)) { stopBlockBreaker(location); stopBlockHitSound(location); resetBlockBreakAnimations(world, Collections.singletonList(location)); return; } - sendBlockBreakToViewers(world, location, - furnitureMechanic != null ? furnitureBarrierLocations : Collections.singletonList(location), - valueHolder[0]); + if (useAttributeTiming) { + serverProgress[0] += serverBreakProgress; + valueHolder[0] = Math.min(10, (int) (serverProgress[0] * 10.0F)); + if (valueHolder[0] < 10) { + sendBlockBreakToViewers(world, location, Collections.singletonList(location), valueHolder[0]); + return; + } + } else { + sendBlockBreakToViewers(world, location, + furnitureMechanic != null ? furnitureBarrierLocations : Collections.singletonList(location), + valueHolder[0]); + if (valueHolder[0]++ < 10) return; + } - if (valueHolder[0]++ < 10) return; BlockDurability.setSuppressVanillaDamageCancellation(true); boolean canBreak; try { @@ -229,11 +266,6 @@ private void handleEvent(Player player, Block block, BlockFace blockFace, Runnab breakerTasks.put(location, breakerTask); }); } else { - // Cancel the breaker immediately to prevent race conditions. - // This must happen synchronously before any scheduled tasks. - stopBlockBreaker(location); - stopBlockHitSound(location); - // Use entity scheduler for player operations on Folia (player may move to different region) SchedulerUtil.runForEntity(player, () -> { if (!AntiGriefLib.canBreak(player, location)) @@ -341,6 +373,90 @@ private void stopBlockBreaker(Location location) { breakerLocations.remove(location); SchedulerUtil.ScheduledTask task = breakerTasks.remove(location); if (task != null) task.cancel(); + restoreClientSideBreaking(location); + } + + static boolean hasCustomVerticalNoteBlockNeighbor(final Block block) { + return AdjacentNoteBlockUpdateHelper.hasCustomVerticalNeighbor(block); + } + + static boolean shouldProtectAdjacentVanillaBlock(final Player player, final Block block) { + if (!ClientSideBlockBreakSuppressor.isSupported() || OraxenBlocks.isOraxenBlock(block) + || OraxenFurniture.getFurnitureMechanic(block) != null + || !hasCustomVerticalNoteBlockNeighbor(block)) return false; + + final ItemStack item = player.getInventory().getItemInMainHand(); + return MODIFIERS.stream().noneMatch(modifier -> modifier.isTriggered(player, block, item)); + } + + private void handleAdjacentVanillaBlock(final Player player, final Block block, final Runnable cancel, + final boolean instantBreak) { + // The target update changes a vertical note block's rendered state on the client even + // though its authoritative server state is unchanged. Own the break until completion, + // then send that unchanged state directly after the target's removal packet. + cancel.run(); + + final Location location = block.getLocation(); + final World world = block.getWorld(); + final BlockData originalData = block.getBlockData(); + final float progressPerTick = instantBreak || player.getGameMode() == GameMode.CREATIVE + ? 1.0F + : Math.max(0.0F, block.getBreakSpeed(player)); + + if (breakerLocations.contains(location)) stopBlockBreaker(location); + breakerLocations.add(location); + suppressClientSideBreaking(player, location); + + player.sendBlockChange(location, originalData); + AdjacentNoteBlockUpdateHelper.resendCustomVerticalNeighbors(block, player); + NMSHandlers.getHandler().acknowledgeBlockChanges(player, location, false); + + final float[] progress = {0.0F}; + final SchedulerUtil.ScheduledTask task = SchedulerUtil.runAtLocationTimer(location, 1L, 1L, () -> { + if (!breakerLocations.contains(location) || !player.isOnline() + || !block.getBlockData().equals(originalData)) { + stopBlockBreaker(location); + resetBlockBreakAnimations(world, Collections.singletonList(location)); + return; + } + + progress[0] += progressPerTick; + if (progress[0] < 1.0F) { + final int stage = Math.min(9, (int) (progress[0] * 10.0F)); + sendBlockBreakToViewers(world, location, Collections.singletonList(location), stage); + return; + } + + if (AntiGriefLib.canBreak(player, location) && player.breakBlock(block)) + AdjacentNoteBlockUpdateHelper.resendCustomVerticalNeighbors(block, player); + + stopBlockBreaker(location); + resetBlockBreakAnimations(world, Collections.singletonList(location)); + }); + breakerTasks.put(location, task); + } + + private void suppressClientSideBreaking(final Player player, final Location location) { + clientSideBreakSuppressions.put(location, player); + ClientSideBlockBreakSuppressor.suppress(player); + } + + private void restoreClientSideBreaking(final Location location) { + final Player player = clientSideBreakSuppressions.remove(location); + if (player == null) return; + + // A new break can begin before this entity task runs. In that case the newer break still + // owns the suppression and will restore the real effects when it stops. + final Runnable restore = () -> { + if (clientSideBreakSuppressions.containsValue(player)) return; + ClientSideBlockBreakSuppressor.restore(player); + }; + try { + SchedulerUtil.runForEntity(player, restore, null); + } catch (final RuntimeException ignored) { + // The scheduler can already be unavailable while the plugin/server is disabling. + restore.run(); + } } private void startBlockHitSound(Location location) { diff --git a/src/main/java/io/th0rgal/oraxen/utils/breaker/ClientSideBlockBreakSuppressor.java b/src/main/java/io/th0rgal/oraxen/utils/breaker/ClientSideBlockBreakSuppressor.java new file mode 100644 index 0000000000..3b880e26ff --- /dev/null +++ b/src/main/java/io/th0rgal/oraxen/utils/breaker/ClientSideBlockBreakSuppressor.java @@ -0,0 +1,76 @@ +package io.th0rgal.oraxen.utils.breaker; + +import io.th0rgal.oraxen.utils.PotionUtils; +import org.bukkit.entity.Player; +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionEffectType; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * Prevents a client from completing a vanilla block break while Oraxen owns the break progress. + * The effects are sent only to the client; the server-side player and its mining calculations are + * left unchanged. + */ +final class ClientSideBlockBreakSuppressor { + + // Resolve through the compatibility helper because older Bukkit versions exposed these under + // the FAST_DIGGING/SLOW_DIGGING names. + private static final PotionEffectType HASTE = PotionUtils.getEffectType("haste"); + private static final PotionEffectType MINING_FATIGUE = PotionUtils.getEffectType("mining_fatigue"); + private static final int CLIENT_ONLY_DURATION = Integer.MAX_VALUE; + // These Paper methods were introduced after 1.20.1. Resolve them reflectively so Oraxen can + // still load on its oldest supported server version, where the legacy breaker remains in use. + private static final Method SEND_EFFECT_CHANGE = playerMethod("sendPotionEffectChange", org.bukkit.entity.LivingEntity.class, PotionEffect.class); + private static final Method SEND_EFFECT_REMOVE = playerMethod("sendPotionEffectChangeRemove", org.bukkit.entity.LivingEntity.class, PotionEffectType.class); + + private ClientSideBlockBreakSuppressor() { + } + + static boolean isSupported() { + return SEND_EFFECT_CHANGE != null && SEND_EFFECT_REMOVE != null; + } + + static void suppress(final Player player) { + send(player, MINING_FATIGUE, 9); + send(player, HASTE, 0); + } + + static void restore(final Player player) { + restore(player, MINING_FATIGUE); + restore(player, HASTE); + } + + private static void send(final Player player, @Nullable final PotionEffectType type, final int amplifier) { + if (type == null || SEND_EFFECT_CHANGE == null) return; + invoke(SEND_EFFECT_CHANGE, player, player, + new PotionEffect(type, CLIENT_ONLY_DURATION, amplifier, false, false, false)); + } + + private static void restore(final Player player, @Nullable final PotionEffectType type) { + if (type == null || SEND_EFFECT_CHANGE == null || SEND_EFFECT_REMOVE == null) return; + + invoke(SEND_EFFECT_REMOVE, player, player, type); + final PotionEffect serverEffect = player.getPotionEffect(type); + if (serverEffect != null) invoke(SEND_EFFECT_CHANGE, player, player, serverEffect); + } + + @Nullable + private static Method playerMethod(final String name, final Class... parameterTypes) { + try { + return Player.class.getMethod(name, parameterTypes); + } catch (final NoSuchMethodException ignored) { + return null; + } + } + + private static void invoke(final Method method, final Player player, final Object... arguments) { + try { + method.invoke(player, arguments); + } catch (final IllegalAccessException | InvocationTargetException exception) { + throw new IllegalStateException("Could not update the player's client-side mining effects", exception); + } + } +} diff --git a/src/main/java/io/th0rgal/oraxen/utils/breaker/CustomBlockMiningListener.java b/src/main/java/io/th0rgal/oraxen/utils/breaker/CustomBlockMiningListener.java index d9c008efec..e0b966d0dd 100644 --- a/src/main/java/io/th0rgal/oraxen/utils/breaker/CustomBlockMiningListener.java +++ b/src/main/java/io/th0rgal/oraxen/utils/breaker/CustomBlockMiningListener.java @@ -104,7 +104,7 @@ public void onDropHand(final PlayerDropItemEvent event) { } @Nullable - private MiningProfile getMiningProfile(final Block block, final ItemStack tool) { + private static MiningProfile getMiningProfile(final Block block, final ItemStack tool) { if (block.getType() == Material.NOTE_BLOCK) { NoteBlockMechanic mechanic = OraxenBlocks.getNoteBlockMechanic(block); if (mechanic == null) return null; @@ -137,7 +137,7 @@ private MiningProfile getMiningProfile(final Block block, final ItemStack tool) return null; } - private double breakSpeedMultiplier(final Player player, final MiningProfile miningProfile) { + private static double breakSpeedMultiplier(final Player player, final MiningProfile miningProfile) { double speedFactor = VANILLA_BREAK_SPEED_BASE / miningProfile.hardness() * miningProfile.speedMultiplier(); if (miningProfile.normalizeNativeMiningCost()) { speedFactor *= nativeMiningCostMultiplier(player.getInventory().getItemInMainHand(), miningProfile.block()); @@ -146,7 +146,7 @@ private double breakSpeedMultiplier(final Player player, final MiningProfile min return Math.max(0.01D, speedFactor); } - private double nativeMiningCostMultiplier(final ItemStack tool, final Block block) { + private static double nativeMiningCostMultiplier(final ItemStack tool, final Block block) { final double fullBlockMiningCost = FULL_BLOCK_MINING_COST > 0.0D ? FULL_BLOCK_MINING_COST : HARVESTABLE_BLOCK_DIVISOR; @@ -156,12 +156,12 @@ private double nativeMiningCostMultiplier(final ItemStack tool, final Block bloc return nativeHardness * nativeMiningDivisor(tool, block) / fullBlockMiningCost; } - private double nativeMiningDivisor(final ItemStack tool, final Block block) { + private static double nativeMiningDivisor(final ItemStack tool, final Block block) { final Material blockType = block.getType(); return canHarvest(blockType, tool) ? HARVESTABLE_BLOCK_DIVISOR : UNHARVESTABLE_BLOCK_DIVISOR; } - private boolean canHarvest(final Material blockType, final ItemStack tool) { + private static boolean canHarvest(final Material blockType, final ItemStack tool) { if (!requiresCorrectTool(blockType)) return true; if (tool == null) return false; @@ -171,13 +171,13 @@ private boolean canHarvest(final Material blockType, final ItemStack tool) { return mineableTag != null && isTagged(blockType, mineableTag) && hasRequiredTier(blockType, toolName); } - private boolean requiresCorrectTool(final Material blockType) { + private static boolean requiresCorrectTool(final Material blockType) { return isTagged(blockType, "needs_stone_tool") || isTagged(blockType, "needs_iron_tool") || isTagged(blockType, "needs_diamond_tool"); } - private boolean hasRequiredTier(final Material blockType, final String toolName) { + private static boolean hasRequiredTier(final Material blockType, final String toolName) { if (isTagged(blockType, "needs_diamond_tool")) { return toolName.startsWith("DIAMOND_") || toolName.startsWith("NETHERITE_"); } @@ -192,7 +192,7 @@ private boolean hasRequiredTier(final Material blockType, final String toolName) } @Nullable - private String mineableTagName(final String toolName) { + private static String mineableTagName(final String toolName) { if (toolName.endsWith("_PICKAXE")) return "mineable/pickaxe"; if (toolName.endsWith("_AXE")) return "mineable/axe"; if (toolName.endsWith("_SHOVEL")) return "mineable/shovel"; @@ -200,10 +200,23 @@ private String mineableTagName(final String toolName) { return null; } - private boolean isTagged(final Material blockType, final String tagName) { + private static boolean isTagged(final Material blockType, final String tagName) { final Tag tag = org.bukkit.Bukkit.getTag(Tag.REGISTRY_BLOCKS, NamespacedKey.minecraft(tagName), Material.class); return tag != null && tag.isTagged(blockType); } private record MiningProfile(Block block, double hardness, double speedMultiplier, boolean normalizeNativeMiningCost) {} + + /** + * Calculates the progress the attribute-driven client would make in one tick without actually + * applying the Oraxen modifier. Used when the server must own a note-block break to avoid the + * client's predicted neighbour-state updates. + */ + static float serverDrivenBreakProgress(final Player player, final Block block, final ItemStack tool) { + final MiningProfile miningProfile = getMiningProfile(block, tool); + if (miningProfile == null) return 0.0F; + if (miningProfile.hardness() <= 0.0D) return 1.0F; + + return (float) (block.getBreakSpeed(player) * breakSpeedMultiplier(player, miningProfile)); + } } diff --git a/src/main/java/io/th0rgal/oraxen/utils/inventories/PickItemUtils.java b/src/main/java/io/th0rgal/oraxen/utils/inventories/PickItemUtils.java new file mode 100644 index 0000000000..a55855c477 --- /dev/null +++ b/src/main/java/io/th0rgal/oraxen/utils/inventories/PickItemUtils.java @@ -0,0 +1,78 @@ +package io.th0rgal.oraxen.utils.inventories; + +import org.bukkit.GameMode; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; + +public final class PickItemUtils { + + private PickItemUtils() { + } + + public static void pickItem(Player player, ItemStack item) { + if (item == null || item.getType().isAir()) return; + + PlayerInventory inventory = player.getInventory(); + int sourceSlot = findMatchingSlot(inventory, item); + int targetSlot = isHotbarSlot(sourceSlot) ? sourceSlot : findSuitableHotbarSlot(inventory); + if (sourceSlot >= 0) { + if (sourceSlot != targetSlot) { + ItemStack targetItem = inventory.getItem(targetSlot); + inventory.setItem(targetSlot, inventory.getItem(sourceSlot)); + inventory.setItem(sourceSlot, targetItem); + } + inventory.setHeldItemSlot(targetSlot); + return; + } + + if (player.getGameMode() != GameMode.CREATIVE) return; + + ItemStack targetItem = inventory.getItem(targetSlot); + if (targetItem != null && !targetItem.getType().isAir()) { + int emptySlot = findEmptySlot(inventory, targetSlot); + if (emptySlot >= 0) inventory.setItem(emptySlot, targetItem); + } + + inventory.setItem(targetSlot, item); + inventory.setHeldItemSlot(targetSlot); + } + + static int findSuitableHotbarSlot(PlayerInventory inventory) { + int selectedSlot = inventory.getHeldItemSlot(); + for (int offset = 0; offset < 9; offset++) { + int slot = (selectedSlot + offset) % 9; + ItemStack candidate = inventory.getItem(slot); + if (candidate == null || candidate.getType().isAir()) return slot; + } + + for (int offset = 0; offset < 9; offset++) { + int slot = (selectedSlot + offset) % 9; + ItemStack candidate = inventory.getItem(slot); + if (candidate == null || candidate.getEnchantments().isEmpty()) return slot; + } + + return selectedSlot; + } + + private static boolean isHotbarSlot(int slot) { + return slot >= 0 && slot < 9; + } + + private static int findMatchingSlot(PlayerInventory inventory, ItemStack item) { + for (int slot = 0; slot < 36; slot++) { + ItemStack candidate = inventory.getItem(slot); + if (candidate != null && candidate.isSimilar(item)) return slot; + } + return -1; + } + + private static int findEmptySlot(PlayerInventory inventory, int excludedSlot) { + for (int slot = 0; slot < 36; slot++) { + if (slot == excludedSlot) continue; + ItemStack candidate = inventory.getItem(slot); + if (candidate == null || candidate.getType().isAir()) return slot; + } + return -1; + } +} diff --git a/src/main/java/io/th0rgal/oraxen/utils/schema/SchemaGenerator.java b/src/main/java/io/th0rgal/oraxen/utils/schema/SchemaGenerator.java index 866fb451fe..d842b2922a 100644 --- a/src/main/java/io/th0rgal/oraxen/utils/schema/SchemaGenerator.java +++ b/src/main/java/io/th0rgal/oraxen/utils/schema/SchemaGenerator.java @@ -572,6 +572,23 @@ private static JsonObject generateComponents() { consumable.add("properties", consumeProps); components.add("consumable", consumable); + // death_protection (1.21.2+) + JsonObject deathProtection = new JsonObject(); + deathProtection.addProperty("type", "object"); + deathProtection.addProperty("minecraftVersion", "1.21.2+"); + deathProtection.addProperty("description", "Prevents death and applies configured death effects"); + JsonObject deathProtectionProps = new JsonObject(); + JsonObject deathEffects = new JsonObject(); + deathEffects.addProperty("type", "array"); + deathEffects.addProperty("description", + "Death effects: apply_effects, remove_effects, clear_all_effects, teleport_randomly, and play_sound"); + JsonObject deathEffect = new JsonObject(); + deathEffect.addProperty("type", "object"); + deathEffects.add("items", deathEffect); + deathProtectionProps.add("death_effects", deathEffects); + deathProtection.add("properties", deathProtectionProps); + components.add("death_protection", deathProtection); + // equippable (1.21.2+) JsonObject equippable = new JsonObject(); equippable.addProperty("type", "object"); diff --git a/src/main/resources/items/armors.yml b/src/main/resources/items/armors.yml index 00597f41d3..1b3be16743 100644 --- a/src/main/resources/items/armors.yml +++ b/src/main/resources/items/armors.yml @@ -8,14 +8,14 @@ magic_elytra: itemname: "Magic Elytra" material: ELYTRA - Components: + components: max_stack_size: 1 equippable: slot: CHEST model: oraxen:magic_elytra durability: value: 320 - Pack: + pack: generate_model: true parent_model: item/generated textures: @@ -26,7 +26,7 @@ emerald_helmet: material: PAPER lore: - "<#6f737d>» <#D5D6D8>Gives 1 extra " - Components: + components: max_stack_size: 1 durability: value: 437 @@ -47,7 +47,7 @@ emerald_helmet: operation: 0, slot: HEAD, } - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -58,7 +58,7 @@ emerald_chestplate: material: PAPER lore: - "<#6f737d>» <#D5D6D8>Gives 1.5 extra " - Components: + components: max_stack_size: 1 durability: value: 635 @@ -75,7 +75,7 @@ emerald_chestplate: operation: 0, slot: CHEST, } - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -86,7 +86,7 @@ emerald_leggings: material: PAPER lore: - "<#6f737d>» <#D5D6D8>Gives 1.5 extra " - Components: + components: max_stack_size: 1 durability: value: 595 @@ -103,7 +103,7 @@ emerald_leggings: operation: 0, slot: LEGS, } - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -114,7 +114,7 @@ emerald_boots: material: PAPER lore: - "<#6f737d>» <#D5D6D8>Gives 1 extra " - Components: + components: max_stack_size: 1 durability: value: 516 @@ -131,7 +131,7 @@ emerald_boots: operation: 0, slot: FEET, } - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -142,7 +142,7 @@ obsidian_helmet: material: PAPER lore: - "<#6f737d>» <#D5D6D8>Ludicrous durability" - Components: + components: max_stack_size: 1 durability: value: 4370 @@ -152,7 +152,7 @@ obsidian_helmet: model: oraxen:obsidian AttributeModifiers: - { attribute: ARMOR, amount: 2, operation: 0, slot: HEAD } - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -164,7 +164,7 @@ obsidian_chestplate: material: PAPER lore: - "<#6f737d>» <#D5D6D8>Ludicrous durability" - Components: + components: max_stack_size: 1 durability: value: 6350 @@ -174,7 +174,7 @@ obsidian_chestplate: model: oraxen:obsidian AttributeModifiers: - { attribute: ARMOR, amount: 6, operation: 0, slot: CHEST } - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -185,7 +185,7 @@ obsidian_leggings: material: PAPER lore: - "<#6f737d>» <#D5D6D8>Ludicrous durability" - Components: + components: max_stack_size: 1 durability: value: 5950 @@ -195,7 +195,7 @@ obsidian_leggings: model: oraxen:obsidian AttributeModifiers: - { attribute: ARMOR, amount: 5, operation: 0, slot: LEGS } - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -206,7 +206,7 @@ obsidian_boots: material: PAPER lore: - "<#6f737d>» <#D5D6D8>Ludicrous durability" - Components: + components: max_stack_size: 1 durability: value: 5160 @@ -216,7 +216,7 @@ obsidian_boots: model: oraxen:obsidian AttributeModifiers: - { attribute: ARMOR, amount: 2, operation: 0, slot: FEET } - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -225,7 +225,7 @@ obsidian_boots: ruby_helmet: displayname: "Ruby Helmet" material: PAPER - Components: + components: max_stack_size: 1 durability: value: 547 @@ -241,7 +241,7 @@ ruby_helmet: operation: 0, slot: HEAD, } - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -250,7 +250,7 @@ ruby_helmet: ruby_chestplate: displayname: "Ruby Chestplate" material: PAPER - Components: + components: max_stack_size: 1 durability: value: 792 @@ -266,7 +266,7 @@ ruby_chestplate: operation: 0, slot: CHEST, } - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -275,7 +275,7 @@ ruby_chestplate: ruby_leggings: displayname: "Ruby Leggings" material: PAPER - Components: + components: max_stack_size: 1 durability: value: 742 @@ -291,7 +291,7 @@ ruby_leggings: operation: 0, slot: LEGS, } - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -300,7 +300,7 @@ ruby_leggings: ruby_boots: displayname: "Ruby Boots" material: PAPER - Components: + components: max_stack_size: 1 durability: value: 643 @@ -316,7 +316,7 @@ ruby_boots: operation: 0, slot: FEET, } - Pack: + pack: generate_model: true parent_model: "item/generated" textures: diff --git a/src/main/resources/items/blocks.yml b/src/main/resources/items/blocks.yml index 3952ca256a..76dee728c1 100644 --- a/src/main/resources/items/blocks.yml +++ b/src/main/resources/items/blocks.yml @@ -9,10 +9,10 @@ caveblock: displayname: "<#D5D6D8>Cave Block" material: PAPER - Pack: + pack: generate_model: false # because this is a block, a 2nd model pointing to specified one will be generated anyway model: default/caveblock - Mechanics: + mechanics: block: type: FULL block-sounds: @@ -34,12 +34,12 @@ caveblock: amethyst_ore: displayname: 'Amethyst Ore' material: PAPER - Pack: + pack: generate_model: true parent_model: block/cube_all textures: - default/amethyst_ore - Mechanics: + mechanics: block: type: FULL block-sounds: @@ -72,12 +72,12 @@ amethyst_ore: ruby_ore: displayname: 'Ruby Ore' material: PAPER - Pack: + pack: generate_model: true parent_model: block/cube_all textures: - default/ruby_ore - Mechanics: + mechanics: block: type: FULL block-sounds: @@ -110,12 +110,12 @@ ruby_ore: onyx_ore: displayname: '<#6f737d>Onyx Ore' material: PAPER - Pack: + pack: generate_model: true parent_model: block/cube_all textures: - default/onyx_ore - Mechanics: + mechanics: block: type: FULL block-sounds: @@ -147,12 +147,12 @@ onyx_ore: orax_ore: displayname: 'Orax Ore' material: PAPER - Pack: + pack: generate_model: true parent_model: block/cube_all textures: - default/orax_ore - Mechanics: + mechanics: block: type: FULL block-sounds: diff --git a/src/main/resources/items/crystalmush.yml b/src/main/resources/items/crystalmush.yml index 3ed1218176..cbb451b3ab 100644 --- a/src/main/resources/items/crystalmush.yml +++ b/src/main/resources/items/crystalmush.yml @@ -5,13 +5,13 @@ crystalmush_log: displayname: "Crystal Mushroom Log" material: PAPER - Pack: + pack: generate_model: true parent_model: block/cube_column textures: side: default/crystalmush/wood_crystalmush_side_anim end: default/crystalmush/wood_crystalmush_top_anim - Mechanics: + mechanics: block: type: FULL block-sounds: @@ -46,12 +46,12 @@ crystalmush_log: crystalmush_planks: displayname: "Crystal Mushroom Planks" material: PAPER - Pack: + pack: generate_model: true parent_model: block/cube_all textures: - default/crystalmush/crystalmush_planks_anim - Mechanics: + mechanics: block: type: FULL block-sounds: @@ -87,12 +87,12 @@ crystalmush_planks: crystalmush_leaves: displayname: "Crystal Mushroom Leaves" material: PAPER - Pack: + pack: generate_model: true parent_model: block/leaves textures: - default/crystalmush/crystalmush_leaves_anim - Mechanics: + mechanics: block: # Use a copper grate as the placed block so Minecraft renders it with cutout rules # (lets the texture alpha show through like real leaves). @@ -126,12 +126,12 @@ crystalmush_leaves: crystalmush_trapdoor: displayname: "Crystal Mushroom Trapdoor" material: PAPER - Pack: + pack: generate_model: true parent_model: block/template_trapdoor_bottom textures: - default/crystalmush/crystalmush_trapdoor_anim - Mechanics: + mechanics: block: type: TRAPDOOR custom-variation: 2 @@ -166,12 +166,12 @@ crystalmush_trapdoor: crystalmush_door: displayname: "Crystal Mushroom Door" material: PAPER - Pack: + pack: generate_model: true parent_model: item/generated textures: - default/crystalmush/crystalmush_door_icon # Icon for hand - Mechanics: + mechanics: block: type: DOOR custom-variation: 2 @@ -209,12 +209,12 @@ crystalmush_door: crystalmush_stairs: displayname: "Crystal Mushroom Stairs" material: PAPER - Pack: + pack: generate_model: true parent_model: block/stairs textures: - default/crystalmush/crystalmush_planks_anim - Mechanics: + mechanics: block: type: STAIR custom-variation: 3 @@ -249,12 +249,12 @@ crystalmush_stairs: crystalmush_slab: displayname: "Crystal Mushroom Slab" material: PAPER - Pack: + pack: generate_model: true parent_model: block/slab textures: - default/crystalmush/crystalmush_planks_anim - Mechanics: + mechanics: block: type: SLAB custom-variation: 3 diff --git a/src/main/resources/items/flowers.yml b/src/main/resources/items/flowers.yml index 3e896694f3..d3e7317e49 100644 --- a/src/main/resources/items/flowers.yml +++ b/src/main/resources/items/flowers.yml @@ -7,12 +7,12 @@ brunnera: displayname: "<#34d8eb>Brunnera" material: PAPER - Pack: + pack: generate_model: true parent_model: "block/cross" textures: - default/flowers/brunnera.png # .png extension is not mandatory - Mechanics: + mechanics: block: type: STRING block-sounds: @@ -31,12 +31,12 @@ brunnera: daffodil: displayname: "<#f5ec42>Daffodil" material: PAPER - Pack: + pack: generate_model: true parent_model: "block/cross" textures: - default/flowers/daffodil.png # .png extension is not mandatory - Mechanics: + mechanics: block: type: STRING block-sounds: @@ -55,12 +55,12 @@ daffodil: dailily: displayname: "<#bf332c>Dailily" material: PAPER - Pack: + pack: generate_model: true parent_model: "block/cross" textures: - default/flowers/dailily.png # .png extension is not mandatory - Mechanics: + mechanics: block: type: STRING block-sounds: diff --git a/src/main/resources/items/furniture.yml b/src/main/resources/items/furniture.yml index 743b8a9ded..bedf1e0f4d 100644 --- a/src/main/resources/items/furniture.yml +++ b/src/main/resources/items/furniture.yml @@ -1,10 +1,10 @@ table: itemname: Table material: PAPER - Pack: + pack: generate_model: false model: default/table - Mechanics: + mechanics: furniture: type: ARMOR_STAND # Valid types are ITEM_FRAME, GLOW_ITEM_FRAME, ARMOR_STAND, and DISPLAY_ENTITY if server is 1.19.4+ small: false # Used by ARMOR_STAND furniture; defaults to true for ARMOR_STAND @@ -34,7 +34,7 @@ table: cart: displayname: "Cart" material: PAPER - Mechanics: + mechanics: furniture: type: DISPLAY_ENTITY hitboxes: @@ -50,14 +50,14 @@ cart: silktouch: false loots: - { oraxen_item: cart, probability: 1.0 } - Pack: + pack: generate_model: false model: default/cart chair: displayname: "Chair" material: PAPER - Mechanics: + mechanics: furniture: type: DISPLAY_ENTITY hitboxes: @@ -75,14 +75,14 @@ chair: silktouch: false loots: - { oraxen_item: chair, probability: 1.0 } - Pack: + pack: generate_model: false model: default/chair coach: displayname: "Coach" material: PAPER - Mechanics: + mechanics: furniture: type: DISPLAY_ENTITY display_entity_properties: @@ -104,14 +104,14 @@ coach: silktouch: false loots: - { oraxen_item: coach, probability: 1.0 } - Pack: + pack: generate_model: false model: default/coach shelf: displayname: "Shelf" material: PAPER - Mechanics: + mechanics: furniture: type: ITEM_FRAME limited_placing: @@ -127,14 +127,14 @@ shelf: silktouch: false loots: - { oraxen_item: shelf, probability: 1.0 } - Pack: + pack: generate_model: false model: default/shelf turntable: displayname: "Turntable" material: PAPER - Mechanics: + mechanics: furniture: type: DISPLAY_ENTITY limited_placing: @@ -161,11 +161,11 @@ turntable: y: 0.1 # Raise the turntable slightly off the ground z: 0.0 jukebox: - # NEW: Use active_model instead of active_stage (references Pack.models key) + # NEW: Use active_model instead of active_stage (references pack.models key) active_model: opened volume: 1.0 pitch: 1.0 - Pack: + pack: generate_model: false model: default/turntable_closed # NEW: Define additional models inline - no more fake items needed! @@ -174,4 +174,4 @@ turntable: opened: default/turntable_opened # REMOVED: turtable_active_stage is no longer needed! -# The "opened" model is now defined inline in turntable's Pack.models +# The "opened" model is now defined inline in turntable's pack.models diff --git a/src/main/resources/items/guis.yml b/src/main/resources/items/guis.yml index 8e70c5846e..2aa202229a 100644 --- a/src/main/resources/items/guis.yml +++ b/src/main/resources/items/guis.yml @@ -9,7 +9,7 @@ arrow_next_icon: displayname: "<#D5D6D8>Next page" material: PAPER excludeFromInventory: true - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -19,7 +19,7 @@ arrow_previous_icon: displayname: "<#D5D6D8>Previous page" material: PAPER excludeFromInventory: true - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -29,7 +29,7 @@ exit_icon: displayname: "Back to main menu" material: PAPER excludeFromInventory: true - Pack: + pack: generate_model: true parent_model: "item/generated" textures: diff --git a/src/main/resources/items/hats.yml b/src/main/resources/items/hats.yml index e041df1f49..7a0de10c53 100644 --- a/src/main/resources/items/hats.yml +++ b/src/main/resources/items/hats.yml @@ -13,14 +13,14 @@ anubis_head: AttributeModifiers: - { attribute: ARMOR, amount: 3, operation: 0, slot: HEAD } - { attribute: ARMOR_TOUGHNESS, amount: 2, operation: 0, slot: HEAD } - Pack: + pack: generate_model: false model: default/anubis_head # .json extension is not mandatory # before 1.21.2, simply enable hat mechanic - Components: + components: equippable: slot: HEAD - Mechanics: + mechanics: armor_effects: night_vision: amplifier: 0 @@ -34,10 +34,10 @@ crown: AttributeModifiers: - { attribute: ARMOR, amount: 3, operation: 0, slot: HEAD } - { attribute: ARMOR_TOUGHNESS, amount: 2, operation: 0, slot: HEAD } - Pack: + pack: generate_model: false model: default/crown - Components: + components: equippable: slot: HEAD @@ -47,10 +47,10 @@ pharaoh_head: AttributeModifiers: - { attribute: ARMOR, amount: 3, operation: 0, slot: HEAD } - { attribute: ARMOR_TOUGHNESS, amount: 2, operation: 0, slot: HEAD } - Pack: + pack: generate_model: false model: default/pharaoh_head - Components: + components: equippable: slot: HEAD @@ -60,20 +60,20 @@ witch_hat: AttributeModifiers: - { attribute: ARMOR, amount: 3, operation: 0, slot: HEAD } - { attribute: ARMOR_TOUGHNESS, amount: 2, operation: 0, slot: HEAD } - Pack: + pack: generate_model: false model: default/witch_hat - Components: + components: equippable: slot: HEAD space_helmet: displayname: "<#D5D6D8>Space Helmet" material: PAPER - Pack: + pack: generate_model: false model: default/space_helmet - Components: + components: equippable: slot: HEAD diff --git a/src/main/resources/items/items.yml b/src/main/resources/items/items.yml index 608af34773..c448af5522 100644 --- a/src/main/resources/items/items.yml +++ b/src/main/resources/items/items.yml @@ -38,7 +38,7 @@ example_sword: flame: 34 sharpness: 18 - Mechanics: + mechanics: durability: value: 10 misc: @@ -47,7 +47,7 @@ example_sword: example_custom_mechanic: displayname: "My Item With A Custom Mechanic" material: DIAMOND_SWORD - Mechanics: + mechanics: custom: test: one_usage: true @@ -61,14 +61,14 @@ example_custom_mechanic: example_efficient_pickaxe: displayname: "Turbo Pickaxe" material: DIAMOND_PICKAXE - Mechanics: + mechanics: efficiency: amount: 3 example_slow_pickaxe: displayname: "Slow Pickaxe" material: DIAMOND_PICKAXE - Mechanics: + mechanics: efficiency: amount: -3 @@ -96,12 +96,12 @@ example_potion: miner_sandwitch: itemname: Miner's sandwitch material: PAPER - Pack: + pack: generate_model: true parent_model: item/generated textures: - default/sandwitch.png - Components: + components: food: nutrition: 8 # Same as cooked beef saturation: 12.8 # Same as cooked beef @@ -125,7 +125,7 @@ miner_sandwitch: amethyst: displayname: "Amethyst" material: PAPER - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -134,7 +134,7 @@ amethyst: ruby: displayname: "Ruby" material: PAPER - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -143,7 +143,7 @@ ruby: onyx: displayname: "<#6f737d>Onyx" material: PAPER - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -152,7 +152,7 @@ onyx: orax: displayname: "Orax" material: PAPER - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -161,19 +161,19 @@ orax: fire: displayname: "Fire" material: PAPER - Pack: + pack: generate_model: false model: block/fire_floor0 # so we use the already existing fire model welcome_disk: displayname: "Welcome Disk" material: PAPER - Components: + components: max_stack_size: 1 jukebox_playable: show_in_tooltip: true song_key: "oraxen:welcome" - Pack: + pack: generate_model: true parent_model: "item/handheld" textures: @@ -186,10 +186,10 @@ welcome_disk: leather_backpack: displayname: "<#8B4513>Leather Backpack" material: PAPER - Pack: + pack: generate_model: false model: default/bag - Mechanics: + mechanics: # Storage functionality - right-click to open backpack: rows: 4 @@ -214,5 +214,5 @@ leather_backpack: # edna: # itemname: "Custom Painting" # material: PAINTING -# Components: +# components: # painting_variant: oraxen:edna diff --git a/src/main/resources/items/mystical.yml b/src/main/resources/items/mystical.yml index e36b684d36..31e4412b30 100644 --- a/src/main/resources/items/mystical.yml +++ b/src/main/resources/items/mystical.yml @@ -11,10 +11,10 @@ legendary_hammer: lore: - "<#ff455b>» <#D5D6D8>Strike your enemies with a devastating blow" - "<#ff455b>» <#D5D6D8>On the 15th hit, the player is launched forward" - Pack: + pack: generate_model: false model: default/legendary_hammer - Mechanics: + mechanics: durability: value: 10000 #diamond sword is 1561 knockback_strike: @@ -35,17 +35,17 @@ legendary_hammer: earth_hammer: displayname: "Earth Hammer" material: DIAMOND_PICKAXE - Pack: + pack: generate_model: false model: default/earth_hammer magical_wand: displayname: "Magical Wand" material: WOODEN_HOE - Pack: + pack: generate_model: false model: default/magical_wand - Mechanics: + mechanics: aura: type: helix particle: SOUL_FIRE_FLAME @@ -66,17 +66,17 @@ magical_axe: material: DIAMOND_AXE lore: - "<#6f737d>» <#D5D6D8>Convert experience to bottles" - Pack: + pack: generate_model: false model: default/magical_axe - Mechanics: + mechanics: bottledexp: # Because exp converting is cheated ratio: 0.95 # So you'll lose 1/20 of your exp by converting it ice_staff: displayname: "Ice Staff" material: WOODEN_HOE - Pack: + pack: generate_model: false model: default/ice_staff @@ -88,7 +88,7 @@ battle_axe: amount: 7 operation: 0 slot: HAND - Pack: + pack: generate_model: false model: default/battle_axe @@ -96,10 +96,10 @@ withooker: displayname: "Withooker" material: WOODEN_HOE unbreakable: true - Pack: + pack: generate_model: false model: default/withooker - Mechanics: + mechanics: witherskull: charged: false delay: 3000 # in milliseconds (3000ms = 3s) @@ -107,7 +107,7 @@ withooker: divine_shield: displayname: 'Divine Shield' material: SHIELD - Pack: + pack: generate_model: false model: default/divine_shield blocking_model: default/divine_shield_blocking @@ -115,10 +115,10 @@ divine_shield: magic_book: displayname: 'Magic Book' material: DIAMOND_SWORD - Pack: + pack: generate_model: false model: default/magic_book - Mechanics: + mechanics: aura: type: simple particle: PORTAL @@ -143,23 +143,23 @@ lotr_pike: # Low base melee damage - the spear is meant for lunge attacks, not regular swings - { attribute: ATTACK_DAMAGE, amount: 2, operation: 0, slot: HAND } - { attribute: ATTACK_SPEED, amount: 1.2, operation: 0, slot: HAND } - Pack: + pack: generate_model: false model: default/lotrpikeinactive - # Optional: You can also define models here using Pack.models + # Optional: You can also define models here using pack.models # These are auto-registered as oraxen:lotr_pike/active, oraxen:lotr_pike/frame0, etc. # models: # active: default/lotrpikeactive # frame0: default/lotrpike_frame0 # frame1: default/lotrpike_frame1 - Components: + components: # Cooldown between lunge attacks (1.5 seconds) use_cooldown: group: "oraxen:pike_lunge" seconds: 1.5 # Rarity indicator rarity: RARE - Mechanics: + mechanics: spear_lunge: # The active model path - mechanic handles registration automatically active_model: default/lotrpikeactive diff --git a/src/main/resources/items/plants.yml b/src/main/resources/items/plants.yml index 091b88f8b9..ca8d38f70a 100644 --- a/src/main/resources/items/plants.yml +++ b/src/main/resources/items/plants.yml @@ -8,12 +8,12 @@ weed_leaf: displayname: "Weed Leaf" material: COOKED_BEEF - Pack: + pack: generate_model: true parent_model: "item/generated" textures: - default/weed/leaf - Mechanics: + mechanics: consumable_potion_effects: confusion: amplifier: 0 @@ -44,7 +44,7 @@ weed_leaf: weed_seed: displayname: "Weed Seed" material: PAPER - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -55,14 +55,14 @@ weed_seed: stage1: default/weed/stage1 stage2: default/weed/stage2 stage3: default/weed/stage3 - Mechanics: + mechanics: furniture: barrier: false farmland_required: true initial_stage: 0 # Start at stage0 when placed # All growth stages defined inline - no separate items needed! stages: - - model: stage0 # References Pack.models key + - model: stage0 # References pack.models key evolution: delay: 10000 probability: 0.5 @@ -108,7 +108,7 @@ weed_seed: grape: displayname: "Grape" material: COOKED_BEEF - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -118,7 +118,7 @@ grape: grape_seeds: displayname: "Grape Seeds" material: PAPER - Pack: + pack: generate_model: true parent_model: "item/generated" textures: @@ -130,14 +130,14 @@ grape_seeds: stage2: default/grape/stage2 stage3: default/grape/stage3 stage4: default/grape/stage4 - Mechanics: + mechanics: furniture: barrier: false farmland_required: true initial_stage: 0 # Start at stage0 when placed # All growth stages defined inline - no separate items needed! stages: - - model: stage0 # References Pack.models key + - model: stage0 # References pack.models key evolution: delay: 10000 probability: 0.5 diff --git a/src/main/resources/items/skins.yml b/src/main/resources/items/skins.yml index 6b9fe053a2..c2e8892f53 100644 --- a/src/main/resources/items/skins.yml +++ b/src/main/resources/items/skins.yml @@ -7,35 +7,35 @@ diamond_sword: # A simple skinnable diamond sword material: DIAMOND_SWORD - Mechanics: + mechanics: skinnable: {} great_sword: displayname: "<#D5D6D8>SKIN: Great Sword" material: DIAMOND_SWORD - Pack: + pack: generate_model: false model: default/great_sword - Mechanics: + mechanics: skin: consume: true bone_sword: displayname: "<#D5D6D8>SKIN: Bone Sword" material: DIAMOND_SWORD - Pack: + pack: generate_model: false model: default/bone_sword - Mechanics: + mechanics: skin: consume: true wood_sword: displayname: "<#D5D6D8>SKIN: Wood Sword" material: DIAMOND_SWORD - Pack: + pack: generate_model: false model: default/wood_sword - Mechanics: + mechanics: skin: consume: true \ No newline at end of file diff --git a/src/main/resources/items/tools.yml b/src/main/resources/items/tools.yml index 756aae2233..fab0e9a45f 100644 --- a/src/main/resources/items/tools.yml +++ b/src/main/resources/items/tools.yml @@ -10,12 +10,12 @@ obsidian_pickaxe: material: IRON_PICKAXE lore: - "<#6f737d>» <#D5D6D8>Ludicrous durability" - Pack: + pack: generate_model: true parent_model: "item/handheld" textures: - default/obsidian_pickaxe.png - Mechanics: + mechanics: durability: value: 15000 #diamond sword is 1561 @@ -24,10 +24,10 @@ bedrock_pickaxe: material: STONE_PICKAXE lore: - "<#6f737d>» <#D5D6D8>Mine bedrock blocks" - Pack: + pack: generate_model: false model: default/bedrock_pickaxe - Mechanics: + mechanics: durability: value: 5000 #diamond sword is 1561 bedrockbreak: @@ -39,12 +39,12 @@ emerald_hammer: material: DIAMOND_PICKAXE lore: - "<#6f737d>» <#D5D6D8>Break 3x3 blocks" - Pack: + pack: generate_model: true parent_model: "item/handheld" textures: - default/emerald_hammer.png - Mechanics: + mechanics: bigmining: radius: 1 depth: 1 @@ -54,12 +54,12 @@ amethyst_hammer: material: DIAMOND_PICKAXE lore: - "<#6f737d>» <#D5D6D8>Break 3x3x2 blocks" - Pack: + pack: generate_model: true parent_model: "item/handheld" textures: - default/amethyst_hammer.png - Mechanics: + mechanics: bigmining: radius: 1 depth: 2 @@ -69,12 +69,12 @@ onyx_hammer: material: DIAMOND_PICKAXE lore: - "<#6f737d>» <#D5D6D8>Break 3x3x2 blocks" - Pack: + pack: generate_model: true parent_model: "item/handheld" textures: - default/onyx_hammer.png - Mechanics: + mechanics: durability: value: 3122 bigmining: @@ -86,12 +86,12 @@ orax_hammer: material: DIAMOND_PICKAXE lore: - "<#6f737d>» <#D5D6D8>Break 5x5x2 blocks" - Pack: + pack: generate_model: true parent_model: "item/handheld" textures: - default/orax_hammer.png - Mechanics: + mechanics: durability: value: 4683 bigmining: @@ -104,12 +104,12 @@ fire_hammer: lore: - "<#6f737d>» <#D5D6D8>Break 3x3 blocks" - "<#6f737d>» <#D5D6D8>Instantly smelt ores" - Pack: + pack: generate_model: true parent_model: "item/handheld" textures: - default/fire_hammer.png - Mechanics: + mechanics: smelting: enabled: true play_sound: true @@ -125,12 +125,12 @@ iron_cog: lore: - "<#6f737d>» <#D5D6D8>Click on a damaged item to repair" - " <#D5D6D8>10% of its maximum durability" - Pack: + pack: generate_model: true parent_model: "item/handheld" textures: - default/iron_cog.png - Mechanics: + mechanics: repair: ratio: 0.10 @@ -140,12 +140,12 @@ gold_cog: lore: - "<#6f737d>» <#D5D6D8>Click on a damaged item to repair" - " <#D5D6D8>50% of its maximum durability" - Pack: + pack: generate_model: true parent_model: "item/handheld" textures: - default/gold_cog.png - Mechanics: + mechanics: repair: ratio: 0.50 @@ -155,24 +155,24 @@ diamond_cog: lore: - "<#6f737d>» <#D5D6D8>Click on a damaged item to repair" - " <#D5D6D8>100% of its maximum durability" - Pack: + pack: generate_model: true parent_model: "item/handheld" textures: - default/diamond_cog.png - Mechanics: + mechanics: repair: ratio: 1.0 iron_serpe: displayname: "<#D5D6D8>Iron Serpe" material: WOODEN_HOE - Pack: + pack: generate_model: true parent_model: "item/handheld" textures: - default/iron_serpe.png - Mechanics: + mechanics: harvesting: cooldown: 10000 # 10 seconds between usages radius: 5 # Blocks surrounding the clicked block diff --git a/src/main/resources/items/weapons.yml b/src/main/resources/items/weapons.yml index f12dc0e851..e9813fea0d 100644 --- a/src/main/resources/items/weapons.yml +++ b/src/main/resources/items/weapons.yml @@ -15,10 +15,10 @@ storm_sword: - { attribute: ATTACK_SPEED, amount: 3.2, operation: 0, slot: HAND } lore: - "<#6f737d>» <#D5D6D8>Right click to strike lightning" - Pack: + pack: generate_model: false model: default/storm_sword - Mechanics: + mechanics: thor: lightning_bolts_amount: 5 random_location_variation: 1.5 @@ -32,7 +32,7 @@ energy_crystal_sword: - { attribute: ATTACK_DAMAGE, amount: 10, operation: 0, slot: HAND } # it has as much full-strength attacks per second than diamond sword - { attribute: ATTACK_SPEED, amount: 1.6, operation: 0, slot: HAND } - Pack: + pack: generate_model: false model: default/energy_crystal_sword @@ -46,12 +46,12 @@ glass_sword: - { attribute: ATTACK_DAMAGE, amount: 10, operation: 0, slot: HAND } # it has as 5 full-strength attacks per second - { attribute: ATTACK_SPEED, amount: 1.6, operation: 0, slot: HAND } - Pack: + pack: generate_model: true parent_model: "item/handheld" textures: - default/glass_sword.png - Mechanics: + mechanics: durability: value: 8 @@ -65,12 +65,12 @@ obsidian_sword: - { attribute: ATTACK_DAMAGE, amount: 6, operation: 0, slot: HAND } # it has as 5 full-strength attacks per second - { attribute: ATTACK_SPEED, amount: 1.6, operation: 0, slot: HAND } - Pack: + pack: generate_model: true parent_model: "item/handheld" textures: - default/obsidian_sword.png - Mechanics: + mechanics: durability: value: 10000 @@ -85,10 +85,10 @@ blood_sword: - { attribute: ATTACK_DAMAGE, amount: 8, operation: 0, slot: HAND } # it has as much full-strength attacks per second than diamond sword - { attribute: ATTACK_SPEED, amount: 1.6, operation: 0, slot: HAND } - Pack: + pack: generate_model: false model: default/blood_sword - Mechanics: + mechanics: lifeleech: amount: 2 # the amount of 1/2 hearts that you'll steal to your opponents durability: @@ -102,20 +102,20 @@ blood_sword: octavia_sword: displayname: "<#D5D6D8>Octavia Sword" material: DIAMOND_SWORD - Pack: + pack: generate_model: false model: default/octavia_sword - Mechanics: + mechanics: durability: value: 600 #diamond sword is 1561 dagger: displayname: "Dagger" material: DIAMOND_SWORD - Pack: + pack: generate_model: false model: default/dagger - Mechanics: + mechanics: durability: value: 10 #diamond sword is 1561 @@ -127,10 +127,10 @@ katana: - { attribute: ATTACK_DAMAGE, amount: 5, operation: 0, slot: HAND } # it has as 5 full-strength attacks per second - { attribute: ATTACK_SPEED, amount: 5, operation: 0, slot: HAND } - Pack: + pack: generate_model: false model: default/katana - Mechanics: + mechanics: durability: value: 1000 #diamond sword is 1561 @@ -142,7 +142,7 @@ combat_bow: amount: 7 operation: 0 slot: HAND - Pack: + pack: generate_model: false model: default/combat_bow pulling_models: diff --git a/src/main/resources/languages/english.yml b/src/main/resources/languages/english.yml index ed828ef36e..e45685b4d8 100644 --- a/src/main/resources/languages/english.yml +++ b/src/main/resources/languages/english.yml @@ -69,7 +69,7 @@ logs: command: recipe: - no_builder: "<#fa4943>Please create a recipe first!" + no_builder: "<#fa4943>Please create a recipe first." no_furnace: "<#fa4943>This option is only available for Furnace Recipes!" no_name: "<#fa4943>Please specify a name for the recipe!" no_recipes: "<#fa4943>There are no recipes to show!" diff --git a/src/main/resources/mechanics.yml b/src/main/resources/mechanics.yml index 02c5e76bd2..3e0af624c0 100644 --- a/src/main/resources/mechanics.yml +++ b/src/main/resources/mechanics.yml @@ -94,6 +94,8 @@ aura: # Cosmetic backpacks displayed on player's back using packet-based armor stands backpack_cosmetic: enabled: true + armor_stand_enabled: true + armor_stand_range: 128 hat: enabled: true diff --git a/src/main/resources/settings.yml b/src/main/resources/settings.yml index 684a1e867e..dc4eb0cc9c 100644 --- a/src/main/resources/settings.yml +++ b/src/main/resources/settings.yml @@ -63,6 +63,11 @@ FurnitureUpdater: Pack: generation: generate: true # Unlike Plugin.generation.default_assets, this will enable/disable all pack generation + # Additional readable ZIP after pack generation events, before obfuscation. Never uploaded or sent to players. + # Relative paths use the server working directory. Set to "" to disable; parent directories are created. + # Choose a location outside plugins/Oraxen/pack to avoid importing the export on later generations. + unprotected-location: ./.output/unprotected.zip + # If true, Oraxen will not create or modify pack.mcmeta. # The file at plugins/Oraxen/pack/pack.mcmeta will be included in the final pack exactly as provided. # This also disables automatic overlay entries, so you must manage overlays in pack.mcmeta yourself. diff --git a/src/test/java/io/th0rgal/oraxen/items/ItemLoaderTest.java b/src/test/java/io/th0rgal/oraxen/items/ItemLoaderTest.java index 70f97e2949..359eebc3d6 100644 --- a/src/test/java/io/th0rgal/oraxen/items/ItemLoaderTest.java +++ b/src/test/java/io/th0rgal/oraxen/items/ItemLoaderTest.java @@ -49,6 +49,43 @@ void registersExplicitlyConfiguredCustomModelData() throws Exception { assertEquals(123, resolve.invoke(configured)); } + @Test + void migratesCapitalizedItemSectionsToLowercase() throws Exception { + YamlConfiguration config = new YamlConfiguration(); + config.loadFromString(""" + test_item: + Mechanics: + example: true + Components: + another-example: true + Pack: + model: test_item + """); + + ConfigurationSection itemSection = config.getConfigurationSection("test_item"); + assertNotNull(itemSection); + + ItemMigrator migrator = new ItemMigrator(itemSection); + + assertFalse(itemSection.contains("Mechanics")); + assertFalse(itemSection.contains("Components")); + assertFalse(itemSection.contains("Pack")); + assertTrue(itemSection.contains("mechanics")); + assertTrue(itemSection.contains("components")); + assertTrue(itemSection.contains("pack")); + assertTrue(itemSection.getBoolean("mechanics.example")); + assertTrue(itemSection.getBoolean("components.another-example")); + assertEquals("test_item", itemSection.getString("pack.model")); + String saved = config.saveToString(); + assertTrue(saved.contains("mechanics:")); + assertTrue(saved.contains("components:")); + assertTrue(saved.contains("pack:")); + assertFalse(saved.contains("Mechanics:")); + assertFalse(saved.contains("Components:")); + assertFalse(saved.contains("Pack:")); + assertTrue(migrator.configUpdated()); + } + @Test void templateChildrenDoNotInheritTheTemplateCustomModelData() throws Exception { YamlConfiguration config = new YamlConfiguration(); diff --git a/src/test/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/BlockLocationTest.java b/src/test/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/BlockLocationTest.java new file mode 100644 index 0000000000..00cddb6442 --- /dev/null +++ b/src/test/java/io/th0rgal/oraxen/mechanics/provided/gameplay/furniture/BlockLocationTest.java @@ -0,0 +1,21 @@ +package io.th0rgal.oraxen.mechanics.provided.gameplay.furniture; + +import org.bukkit.persistence.PersistentDataAdapterContext; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; + +class BlockLocationTest { + + @Test + void persistentDataTypeRoundTripsBlockLocation() { + BlockLocation location = new BlockLocation(3, -2, 7); + PersistentDataAdapterContext context = mock(PersistentDataAdapterContext.class); + + byte[] primitive = BlockLocation.dataType.toPrimitive(location, context); + BlockLocation deserialized = BlockLocation.dataType.fromPrimitive(primitive, context); + + assertEquals(location, deserialized); + } +} diff --git a/src/test/java/io/th0rgal/oraxen/pack/generation/UnprotectedPackWriterTest.java b/src/test/java/io/th0rgal/oraxen/pack/generation/UnprotectedPackWriterTest.java new file mode 100644 index 0000000000..b99adc1360 --- /dev/null +++ b/src/test/java/io/th0rgal/oraxen/pack/generation/UnprotectedPackWriterTest.java @@ -0,0 +1,82 @@ +package io.th0rgal.oraxen.pack.generation; + +import io.th0rgal.oraxen.utils.VirtualFile; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipFile; + +import static org.junit.jupiter.api.Assertions.*; + +class UnprotectedPackWriterTest { + @TempDir + Path directory; + + @Test + void exportsReadableFilesAndPreservesClientPackStreamsForObfuscation() throws Exception { + String model = "{\"textures\":{\"layer0\":\"oraxen:custom/sword\"}}"; + List output = new ArrayList<>(List.of( + file("assets/oraxen/models/custom", "sword.json", model), + file("assets/oraxen/textures/custom", "sword.png", "texture bytes"), + file("", "pack.mcmeta", "{\"pack\":{\"pack_format\":46,\"description\":\"Test\"}}"))); + Path destination = directory.resolve(".output/unprotected.zip"); + + UnprotectedPackWriter.write(destination, output, directory.resolve("pack").toFile()); + PackObfuscator.obfuscate(output, "FULL", false); + + assertTrue(output.stream().noneMatch(file -> file.getPath().equals("assets/oraxen/models/custom/sword.json"))); + VirtualFile texture = output.stream().filter(file -> file.getPath().endsWith(".png")).findFirst().orElseThrow(); + assertEquals("texture bytes", new String(texture.getInputStream().readAllBytes(), StandardCharsets.UTF_8)); + try (ZipFile zip = new ZipFile(destination.toFile())) { + assertEquals(3, zip.size()); + assertEquals(model, new String(zip.getInputStream(zip.getEntry("assets/oraxen/models/custom/sword.json")).readAllBytes(), StandardCharsets.UTF_8)); + assertNotNull(zip.getEntry("pack.mcmeta")); + } + } + + @Test + void refreshesExistingExportAndSupportsSupplierStreams() throws Exception { + Path destination = directory.resolve("unprotected.zip"); + VirtualFile file = new VirtualFile("", "pack.mcmeta", () -> new ByteArrayInputStream("new".getBytes(StandardCharsets.UTF_8))); + Files.writeString(destination, "old"); + UnprotectedPackWriter.write(destination, List.of(file), directory.resolve("pack").toFile()); + try (ZipFile zip = new ZipFile(destination.toFile())) { + assertEquals("new", new String(zip.getInputStream(zip.getEntry("pack.mcmeta")).readAllBytes(), StandardCharsets.UTF_8)); + } + assertEquals("new", new String(file.getInputStream().readAllBytes(), StandardCharsets.UTF_8)); + } + + @Test + void rejectsClientPackAndSourceFolderDestinationsBeforeConsumingStreams() throws Exception { + Path packFolder = directory.resolve("pack"); + VirtualFile file = file("", "pack.mcmeta", "content"); + for (String name : List.of("pack.zip", "pack_1_21_4.zip", "exports/unprotected.zip")) { + assertThrows(java.io.IOException.class, () -> UnprotectedPackWriter.write(packFolder.resolve(name), List.of(file), packFolder.toFile())); + } + assertEquals("content", new String(file.getInputStream().readAllBytes(), StandardCharsets.UTF_8)); + } + + @Test + void failedExportKeepsPreviousArchiveAndRestoresStreams() throws Exception { + Path destination = directory.resolve("unprotected.zip"); + Files.writeString(destination, "previous export"); + VirtualFile valid = file("", "pack.mcmeta", "content"); + VirtualFile invalid = new VirtualFile("", "missing", (java.io.InputStream) null); + assertThrows(java.io.IOException.class, () -> UnprotectedPackWriter.write(destination, List.of(valid, invalid), directory.resolve("pack").toFile())); + assertEquals("previous export", Files.readString(destination)); + assertEquals("content", new String(valid.getInputStream().readAllBytes(), StandardCharsets.UTF_8)); + try (var files = Files.list(directory)) { + assertEquals(List.of(destination), files.toList()); + } + } + + private static VirtualFile file(String parent, String name, String content) { + return new VirtualFile(parent, name, new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/src/test/java/io/th0rgal/oraxen/recipes/listeners/CrafterRecipeEventsTest.java b/src/test/java/io/th0rgal/oraxen/recipes/listeners/CrafterRecipeEventsTest.java new file mode 100644 index 0000000000..c15c6f51b0 --- /dev/null +++ b/src/test/java/io/th0rgal/oraxen/recipes/listeners/CrafterRecipeEventsTest.java @@ -0,0 +1,72 @@ +package io.th0rgal.oraxen.recipes.listeners; + +import io.th0rgal.oraxen.mechanics.provided.misc.misc.MiscMechanic; +import io.th0rgal.oraxen.mechanics.provided.misc.misc.MiscMechanicFactory; +import org.bukkit.block.Block; +import org.bukkit.block.Crafter; +import org.bukkit.event.block.CrafterCraftEvent; +import org.bukkit.inventory.CrafterInventory; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import static org.mockito.Mockito.*; + +class CrafterRecipeEventsTest { + + @Test + void restrictedIngredientCancelsAutomaticCrafting() { + checkCraft(false, true, true); + } + + @Test + void explicitlyAllowedIngredientCanBeCrafted() { + checkCraft(true, true, false); + } + + @Test + void ordinaryIngredientCanBeCrafted() { + checkCraft(false, false, false); + } + + @Test + void disabledMiscMechanicDoesNotBlockCrafting() { + try (MockedStatic factories = mockStatic(MiscMechanicFactory.class)) { + CrafterCraftEvent event = event(new ItemStack[9]); + new CrafterRecipeEvents().onCraft(event); + verify(event, never()).setCancelled(anyBoolean()); + } + } + + private void checkCraft(boolean allowed, boolean hasMechanic, boolean cancelled) { + MiscMechanicFactory factory = mock(MiscMechanicFactory.class); + ItemStack ingredient = mock(ItemStack.class); + if (hasMechanic) { + MiscMechanic mechanic = mock(MiscMechanic.class); + when(factory.getMechanic(ingredient)).thenReturn(mechanic); + when(mechanic.isAllowedInVanillaRecipes()).thenReturn(allowed); + } + ItemStack[] contents = new ItemStack[9]; + // A restricted input must be detected even in the final slot among empty slots. + contents[8] = ingredient; + try (MockedStatic factories = mockStatic(MiscMechanicFactory.class)) { + factories.when(MiscMechanicFactory::get).thenReturn(factory); + CrafterCraftEvent event = event(contents); + new CrafterRecipeEvents().onCraft(event); + if (cancelled) verify(event).setCancelled(true); + else verify(event, never()).setCancelled(anyBoolean()); + } + } + + private CrafterCraftEvent event(ItemStack[] contents) { + CrafterCraftEvent event = mock(CrafterCraftEvent.class); + Block block = mock(Block.class); + Crafter crafter = mock(Crafter.class); + CrafterInventory inventory = mock(CrafterInventory.class); + when(event.getBlock()).thenReturn(block); + when(block.getState()).thenReturn(crafter); + when(crafter.getInventory()).thenReturn(inventory); + when(inventory.getContents()).thenReturn(contents); + return event; + } +} diff --git a/src/test/java/io/th0rgal/oraxen/utils/breaker/NoteBlockClientPredictionTest.java b/src/test/java/io/th0rgal/oraxen/utils/breaker/NoteBlockClientPredictionTest.java new file mode 100644 index 0000000000..30bad92f0f --- /dev/null +++ b/src/test/java/io/th0rgal/oraxen/utils/breaker/NoteBlockClientPredictionTest.java @@ -0,0 +1,112 @@ +package io.th0rgal.oraxen.utils.breaker; + +import io.th0rgal.oraxen.api.OraxenBlocks; +import io.th0rgal.oraxen.utils.PotionUtils; +import org.bukkit.Location; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.BlockData; +import org.bukkit.entity.Player; +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionEffectType; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class NoteBlockClientPredictionTest { + + @Test + void fullBlockBetweenCustomNoteBlocksRequiresServerAuthoritativeBreaking() { + final Block block = mock(Block.class); + final Block blockAbove = mock(Block.class); + final Block blockBelow = mock(Block.class); + when(block.getRelative(BlockFace.UP)).thenReturn(blockAbove); + when(block.getRelative(BlockFace.DOWN)).thenReturn(blockBelow); + + try (MockedStatic blocks = mockStatic(OraxenBlocks.class)) { + blocks.when(() -> OraxenBlocks.isOraxenNoteBlock(blockAbove)).thenReturn(true); + blocks.when(() -> OraxenBlocks.isOraxenNoteBlock(blockBelow)).thenReturn(true); + + assertTrue(BreakerSystem.hasCustomVerticalNoteBlockNeighbor(block)); + } + } + + @Test + void isolatedFullBlockKeepsNativeAttributeBreaking() { + final Block block = mock(Block.class); + final Block blockAbove = mock(Block.class); + final Block blockBelow = mock(Block.class); + when(block.getRelative(BlockFace.UP)).thenReturn(blockAbove); + when(block.getRelative(BlockFace.DOWN)).thenReturn(blockBelow); + + try (MockedStatic blocks = mockStatic(OraxenBlocks.class)) { + assertFalse(BreakerSystem.hasCustomVerticalNoteBlockNeighbor(block)); + } + } + + @Test + void vanillaBlockChangeReplaysTheUnchangedCustomNeighborState() { + final Block changedBlock = mock(Block.class); + final Block blockAbove = mock(Block.class); + final Block blockBelow = mock(Block.class); + final Location aboveLocation = mock(Location.class); + final BlockData aboveData = mock(BlockData.class); + when(changedBlock.getRelative(BlockFace.UP)).thenReturn(blockAbove); + when(changedBlock.getRelative(BlockFace.DOWN)).thenReturn(blockBelow); + when(blockAbove.getLocation()).thenReturn(aboveLocation); + when(blockAbove.getBlockData()).thenReturn(aboveData); + + try (MockedStatic blocks = mockStatic(OraxenBlocks.class)) { + blocks.when(() -> OraxenBlocks.isOraxenNoteBlock(blockAbove)).thenReturn(true); + blocks.when(() -> OraxenBlocks.isOraxenNoteBlock(blockBelow)).thenReturn(false); + + final Map updates = + AdjacentNoteBlockUpdateHelper.customVerticalNeighborStates(changedBlock); + + assertEquals(1, updates.size()); + assertSame(aboveData, updates.get(aboveLocation)); + } + } + + @Test + void suppressesClientMiningWithoutChangingServerPotionEffects() { + final Player player = mock(Player.class); + final ArgumentCaptor effects = ArgumentCaptor.forClass(PotionEffect.class); + + ClientSideBlockBreakSuppressor.suppress(player); + + verify(player, org.mockito.Mockito.times(2)).sendPotionEffectChange(org.mockito.ArgumentMatchers.eq(player), effects.capture()); + final List effectTypes = effects.getAllValues().stream().map(PotionEffect::getType).toList(); + assertTrue(effectTypes.stream().anyMatch(type -> type.getKey().getKey().equals("mining_fatigue"))); + assertTrue(effectTypes.stream().anyMatch(type -> type.getKey().getKey().equals("haste"))); + verify(player, org.mockito.Mockito.never()).addPotionEffect(org.mockito.ArgumentMatchers.any(PotionEffect.class)); + } + + @Test + void restoresThePlayersRealPotionEffectAfterBreaking() { + final Player player = mock(Player.class); + final PotionEffectType miningFatigue = PotionUtils.getEffectType("mining_fatigue"); + final PotionEffect realEffect = new PotionEffect(miningFatigue, 200, 1); + when(player.getPotionEffect(argThat(type -> type.getKey().getKey().equals("mining_fatigue")))) + .thenReturn(realEffect); + + ClientSideBlockBreakSuppressor.restore(player); + + verify(player).sendPotionEffectChangeRemove(org.mockito.ArgumentMatchers.eq(player), + argThat(type -> type.getKey().getKey().equals("mining_fatigue"))); + verify(player).sendPotionEffectChange(player, realEffect); + } +} diff --git a/src/test/java/io/th0rgal/oraxen/utils/inventories/PickItemUtilsTest.java b/src/test/java/io/th0rgal/oraxen/utils/inventories/PickItemUtilsTest.java new file mode 100644 index 0000000000..06f8a7ea9c --- /dev/null +++ b/src/test/java/io/th0rgal/oraxen/utils/inventories/PickItemUtilsTest.java @@ -0,0 +1,74 @@ +package io.th0rgal.oraxen.utils.inventories; + +import org.bukkit.entity.Player; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.junit.jupiter.api.Test; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PickItemUtilsTest { + + @Test + void selectsMatchingHotbarItemWithoutMovingIt() { + ItemStack pickedItem = item(); + ItemStack[] slots = new ItemStack[36]; + slots[2] = pickedItem; + + PlayerInventory inventory = inventory(slots, 7); + Player player = mock(Player.class); + when(player.getInventory()).thenReturn(inventory); + + PickItemUtils.pickItem(player, pickedItem); + + verify(inventory).setHeldItemSlot(2); + verify(inventory, never()).setItem(anyInt(), nullable(ItemStack.class)); + } + + @Test + void movesMatchingStorageItemToSuitableHotbarSlot() { + ItemStack pickedItem = item(); + ItemStack selectedItem = item(); + ItemStack[] slots = new ItemStack[36]; + slots[4] = selectedItem; + slots[20] = pickedItem; + + PlayerInventory inventory = inventory(slots, 4); + Player player = mock(Player.class); + when(player.getInventory()).thenReturn(inventory); + + PickItemUtils.pickItem(player, pickedItem); + + verify(inventory).setItem(5, pickedItem); + verify(inventory).setItem(20, null); + verify(inventory).setHeldItemSlot(5); + } + + private static ItemStack item() { + Material material = mock(Material.class); + when(material.isAir()).thenReturn(false); + ItemStack item = mock(ItemStack.class); + when(item.getType()).thenReturn(material); + when(item.isSimilar(item)).thenReturn(true); + when(item.getEnchantments()).thenReturn(java.util.Map.of()); + return item; + } + + private static PlayerInventory inventory(ItemStack[] slots, int selectedSlot) { + PlayerInventory inventory = mock(PlayerInventory.class); + when(inventory.getHeldItemSlot()).thenReturn(selectedSlot); + when(inventory.getItem(anyInt())).thenAnswer(invocation -> slots[invocation.getArgument(0)]); + doAnswer(invocation -> { + slots[invocation.getArgument(0)] = invocation.getArgument(1); + return null; + }).when(inventory).setItem(anyInt(), nullable(ItemStack.class)); + return inventory; + } +}