feat: add bundles

This commit is contained in:
Alexander Medvedev
2026-06-22 16:59:31 +02:00
parent eaedfdc13b
commit 65fe9f0af0
7 changed files with 281 additions and 19 deletions

View File

@@ -4,11 +4,11 @@ use crate::codec::var_int::VarInt;
use pumpkin_data::Enchantment;
use pumpkin_data::data_component::DataComponent;
use pumpkin_data::data_component_impl::{
ConsumableImpl, ConsumeAnimation, ConsumeEffect, CustomNameImpl, DamageImpl, DataComponentImpl,
EnchantmentsImpl, EquipmentSlot, EquippableImpl, FireworkExplosionImpl, FireworkExplosionShape,
FireworksImpl, IDSet, IDSetContent, IdOr, ItemModelImpl, MapIdImpl, MaxStackSizeImpl,
PotionContentsImpl, SoundEvent, StatusEffectInstance, StoredEnchantmentsImpl, UnbreakableImpl,
UseCooldownImpl, get,
BundleContentsImpl, ConsumableImpl, ConsumeAnimation, ConsumeEffect, CustomNameImpl,
DamageImpl, DataComponentImpl, EnchantmentsImpl, EquipmentSlot, EquippableImpl,
FireworkExplosionImpl, FireworkExplosionShape, FireworksImpl, IDSet, IDSetContent, IdOr,
ItemModelImpl, MapIdImpl, MaxStackSizeImpl, PotionContentsImpl, SoundEvent,
StatusEffectInstance, StoredEnchantmentsImpl, UnbreakableImpl, UseCooldownImpl, get,
};
use pumpkin_data::effect::StatusEffect;
use pumpkin_data::entity::EntityType;
@@ -845,6 +845,7 @@ pub fn deserialize<'a, A: SeqAccess<'a>>(
DataComponent::StoredEnchantments => Ok(StoredEnchantmentsImpl::deserialize(seq)?.to_dyn()),
DataComponent::UseCooldown => Ok(UseCooldownImpl::deserialize(seq)?.to_dyn()),
DataComponent::MapId => Ok(MapIdImpl::deserialize(seq)?.to_dyn()),
DataComponent::BundleContents => Ok(BundleContentsImpl::deserialize(seq)?.to_dyn()),
_ => Err(serde::de::Error::custom(format!("{id:?} (TODO)"))),
}
}
@@ -868,6 +869,7 @@ pub fn serialize<T: SerializeStruct>(
DataComponent::StoredEnchantments => get::<StoredEnchantmentsImpl>(value).serialize(seq),
DataComponent::UseCooldown => get::<UseCooldownImpl>(value).serialize(seq),
DataComponent::MapId => get::<MapIdImpl>(value).serialize(seq),
DataComponent::BundleContents => get::<BundleContentsImpl>(value).serialize(seq),
_ => Err(serde::ser::Error::custom(format!(
"{} not yet implemented",
id.to_name()
@@ -910,3 +912,134 @@ impl DataComponentCodec<Self> for UseCooldownImpl {
})
}
}
fn deserialize_item_stack_template<'a, A: SeqAccess<'a>>(
seq: &mut A,
) -> Result<pumpkin_data::item_stack::ItemStack, A::Error> {
let item_id = seq
.next_element::<VarInt>()?
.ok_or_else(|| de::Error::custom("Missing item_id in ItemStackTemplate"))?
.0 as u16;
let count = seq
.next_element::<VarInt>()?
.ok_or_else(|| de::Error::custom("Missing count in ItemStackTemplate"))?
.0 as u8;
let num_to_add = seq.next_element::<VarInt>()?.map_or(0, |v| v.0);
let num_to_remove = seq.next_element::<VarInt>()?.map_or(0, |v| v.0);
if num_to_add < 0 || num_to_remove < 0 {
return Err(de::Error::custom("Negative component count"));
}
const MAX_COMPONENTS: i32 = 256;
let total_components = num_to_add
.checked_add(num_to_remove)
.ok_or_else(|| de::Error::custom("Component count overflow"))?;
if total_components > MAX_COMPONENTS {
return Err(de::Error::custom(
"Too many components in ItemStackTemplate patch",
));
}
let mut patch = Vec::with_capacity((num_to_add + num_to_remove) as usize);
for _ in 0..num_to_add {
let id_val = seq
.next_element::<VarInt>()?
.ok_or_else(|| de::Error::custom("Missing component ID"))?
.0;
let id = DataComponent::try_from_id(id_val as u8)
.ok_or_else(|| de::Error::custom(format!("Unknown component ID: {id_val}")))?;
let _byte_len = seq
.next_element::<VarInt>()?
.ok_or_else(|| de::Error::custom("No data len VarInt!"))?;
let component_impl = deserialize(id, seq)?;
patch.push((id, Some(component_impl)));
}
for _ in 0..num_to_remove {
let id_val = seq
.next_element::<VarInt>()?
.ok_or_else(|| de::Error::custom("Missing remove component ID"))?
.0;
let id = DataComponent::try_from_id(id_val as u8)
.ok_or_else(|| de::Error::custom("Unknown component ID"))?;
patch.push((id, None));
}
Ok(pumpkin_data::item_stack::ItemStack::new_with_component(
count,
pumpkin_data::item::Item::from_id(item_id).unwrap_or(&pumpkin_data::item::Item::AIR),
patch,
))
}
fn serialize_item_stack_template<T: SerializeStruct>(
stack: &pumpkin_data::item_stack::ItemStack,
seq: &mut T,
) -> Result<(), T::Error> {
seq.serialize_field::<VarInt>("", &VarInt::from(stack.item.id))?;
seq.serialize_field::<VarInt>("", &VarInt::from(stack.item_count))?;
let mut to_add = 0u8;
let mut to_remove = 0u8;
for (_id, data) in &stack.patch {
if data.is_none() {
to_remove += 1;
} else {
to_add += 1;
}
}
seq.serialize_field::<VarInt>("", &VarInt::from(to_add))?;
seq.serialize_field::<VarInt>("", &VarInt::from(to_remove))?;
for (id, data) in &stack.patch {
if let Some(data) = data {
seq.serialize_field::<VarInt>("", &VarInt::from(id.to_id()))?;
serialize(*id, data.as_ref(), seq)?;
}
}
for (id, data) in &stack.patch {
if data.is_none() {
seq.serialize_field::<VarInt>("", &VarInt::from(id.to_id()))?;
}
}
Ok(())
}
impl DataComponentCodec<Self> for BundleContentsImpl {
fn serialize<T: SerializeStruct>(&self, seq: &mut T) -> Result<(), T::Error> {
seq.serialize_field::<VarInt>("", &VarInt::from(self.items.len() as i32))?;
for item in &self.items {
serialize_item_stack_template(item, seq)?;
}
Ok(())
}
fn deserialize<'a, A: SeqAccess<'a>>(seq: &mut A) -> Result<Self, A::Error> {
const MAX_BUNDLE_ITEMS: usize = 64;
let len = seq
.next_element::<VarInt>()?
.ok_or(de::Error::custom("No BundleContentsImpl len VarInt!"))?
.0 as usize;
if len > MAX_BUNDLE_ITEMS {
return Err(de::Error::custom("Too many items in BundleContents"));
}
let mut items = Vec::with_capacity(len);
for _ in 0..len {
items.push(deserialize_item_stack_template(seq)?);
}
Ok(Self { items })
}
}

View File

@@ -0,0 +1,12 @@
use pumpkin_data::packet::serverbound::PLAY_BUNDLE_ITEM_SELECTED;
use pumpkin_macros::java_packet;
use serde::{Deserialize, Serialize};
use crate::VarInt;
#[derive(Deserialize, Serialize)]
#[java_packet(PLAY_BUNDLE_ITEM_SELECTED)]
pub struct SBundleItemSelected {
pub slot_id: VarInt,
pub selected_item_index: VarInt,
}

View File

@@ -1,4 +1,5 @@
mod attack;
mod bundle_item_selected;
mod change_game_mode;
mod chat_command;
mod chat_message;
@@ -47,6 +48,7 @@ mod use_item;
mod use_item_on;
pub use attack::*;
pub use bundle_item_selected::*;
pub use change_game_mode::*;
pub use chat_command::*;
pub use chat_message::*;

View File

@@ -0,0 +1,81 @@
use std::pin::Pin;
use crate::entity::player::Player;
use crate::item::{ItemBehaviour, ItemMetadata};
use pumpkin_data::data_component_impl::BundleContentsImpl;
use pumpkin_data::item::Item;
use pumpkin_data::sound::Sound;
use pumpkin_data::tag;
pub struct BundleItem;
impl ItemMetadata for BundleItem {
fn ids() -> Box<[u16]> {
tag::Item::MINECRAFT_BUNDLES.1.into()
}
}
impl ItemBehaviour for BundleItem {
fn normal_use<'a>(
&'a self,
_item: &'a Item,
player: &'a Player,
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
Box::pin(async move {
let held_item_ref = player.inventory.held_item();
let mut held_item = held_item_ref.lock().await;
let mut matched = false;
let mut used_slot_index = player.inventory.get_selected_slot() as usize;
if !held_item.is_empty() && Self::ids().contains(&held_item.item.id) {
matched = true;
if let Some(bundle_contents) =
held_item.get_data_component_mut::<BundleContentsImpl>()
{
if let Some(extracted_stack) = bundle_contents.try_extract() {
let position = player.position();
player.world().play_sound(
Sound::ItemBundleRemoveOne,
pumpkin_data::sound::SoundCategory::Players,
&position,
);
let updated_bundle = held_item.clone();
drop(held_item);
player.drop_item(extracted_stack).await;
player.sync_hand_slot(used_slot_index, updated_bundle).await;
}
}
}
if !matched {
let off_hand_item_ref = player.inventory.off_hand_item().await;
let mut off_hand_item = off_hand_item_ref.lock().await;
if !off_hand_item.is_empty() && Self::ids().contains(&off_hand_item.item.id) {
used_slot_index = 40; // OFF_HAND_SLOT
if let Some(bundle_contents) =
off_hand_item.get_data_component_mut::<BundleContentsImpl>()
{
if let Some(extracted_stack) = bundle_contents.try_extract() {
let position = player.position();
player.world().play_sound(
Sound::ItemBundleRemoveOne,
pumpkin_data::sound::SoundCategory::Players,
&position,
);
let updated_bundle = off_hand_item.clone();
drop(off_hand_item);
player.drop_item(extracted_stack).await;
player.sync_hand_slot(used_slot_index, updated_bundle).await;
}
}
}
}
})
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@@ -4,6 +4,7 @@ pub mod axe;
pub mod boat;
pub mod bow;
pub mod bucket;
pub mod bundle;
pub mod crossbow;
pub mod dye;
pub mod egg;
@@ -31,6 +32,7 @@ pub mod wind_charge;
use crate::item::items::armor_stand::ArmorStandItem;
use crate::item::items::boat::BoatItem;
use crate::item::items::bundle::BundleItem;
use crate::item::items::end_crystal::EndCrystalItem;
use crate::item::items::map::MapItem;
use crate::item::items::minecart::MinecartItem;
@@ -105,6 +107,7 @@ pub fn default_registry() -> Arc<ItemRegistry> {
manager.register(PotionItem);
manager.register(SplashPotionItem);
manager.register(LingeringPotionItem);
manager.register(BundleItem);
Arc::new(manager)
}

View File

@@ -13,11 +13,12 @@ use pumpkin_config::networking::compression::CompressionInfo;
use pumpkin_data::packet::CURRENT_MC_VERSION;
use pumpkin_data::translation;
use pumpkin_protocol::java::server::play::{
SAttack, SChangeGameMode, SChatCommand, SChatMessage, SChunkBatch, SClickSlot, SClientCommand,
SClientInformationPlay, SClientTickEnd, SCloseContainer, SCommandSuggestion, SConfirmTeleport,
SContainerButtonClick, SCookieResponse as SPCookieResponse, SCustomPayload, SInteract,
SJigsawGenerate, SMoveVehicle, SPaddleBoat, SPickItemFromBlock, SPlaceRecipe, SPlayPingRequest,
SPlayerAbilities, SPlayerAction, SPlayerCommand, SPlayerInput, SPlayerLoaded, SPlayerPosition,
SAttack, SBundleItemSelected, SChangeGameMode, SChatCommand, SChatMessage, SChunkBatch,
SClickSlot, SClientCommand, SClientInformationPlay, SClientTickEnd, SCloseContainer,
SCommandSuggestion, SConfirmTeleport, SContainerButtonClick,
SCookieResponse as SPCookieResponse, SCustomPayload, SInteract, SJigsawGenerate, SMoveVehicle,
SPaddleBoat, SPickItemFromBlock, SPlaceRecipe, SPlayPingRequest, SPlayerAbilities,
SPlayerAction, SPlayerCommand, SPlayerInput, SPlayerLoaded, SPlayerPosition,
SPlayerPositionRotation, SPlayerRotation, SPlayerSession, SRecipeBookChangeSettings,
SRecipeBookSeenRecipe, SRenameItem, SSelectTrade, SSetCommandBlock, SSetCreativeSlot,
SSetHeldItem, SSetJigsawBlock, SSetPlayerGround, SSwingArm, SUpdateSign, SUseItem, SUseItemOn,
@@ -911,6 +912,13 @@ impl JavaClient {
self.handle_interact(player, SInteract::read(payload, &version)?, server)
.await;
}
id if id == SBundleItemSelected::to_id(version) => {
self.handle_bundle_item_selected(
player,
SBundleItemSelected::read(payload, &version)?,
)
.await;
}
id if id == SAttack::to_id(version) => {
self.handle_attack(player, SAttack::read(payload, &version)?, server)
.await;

View File

@@ -65,15 +65,15 @@ use pumpkin_protocol::java::client::play::{
CUpdateEntityPos, CUpdateEntityPosRot, CUpdateEntityRot, InitChat, PlayerAction,
};
use pumpkin_protocol::java::server::play::{
Action, ActionType, CommandBlockMode, FLAG_ON_GROUND, SAttack, SChangeGameMode, SChatCommand,
SChatMessage, SChunkBatch, SClientCommand, SClientInformationPlay, SCloseContainer,
SCommandSuggestion, SConfirmTeleport, SCookieResponse as SPCookieResponse, SInteract,
SJigsawGenerate, SKeepAlive, SMoveVehicle, SPaddleBoat, SPickItemFromBlock, SPlaceRecipe,
SPlayPingRequest, SPlayerAbilities, SPlayerAction, SPlayerCommand, SPlayerInput,
SPlayerPosition, SPlayerPositionRotation, SPlayerRotation, SPlayerSession,
SRecipeBookChangeSettings, SRecipeBookSeenRecipe, SSelectTrade, SSetCommandBlock,
SSetCreativeSlot, SSetHeldItem, SSetJigsawBlock, SSetPlayerGround, SSwingArm, SUpdateSign,
SUseItem, SUseItemOn, Status,
Action, ActionType, CommandBlockMode, FLAG_ON_GROUND, SAttack, SBundleItemSelected,
SChangeGameMode, SChatCommand, SChatMessage, SChunkBatch, SClientCommand,
SClientInformationPlay, SCloseContainer, SCommandSuggestion, SConfirmTeleport,
SCookieResponse as SPCookieResponse, SInteract, SJigsawGenerate, SKeepAlive, SMoveVehicle,
SPaddleBoat, SPickItemFromBlock, SPlaceRecipe, SPlayPingRequest, SPlayerAbilities,
SPlayerAction, SPlayerCommand, SPlayerInput, SPlayerPosition, SPlayerPositionRotation,
SPlayerRotation, SPlayerSession, SRecipeBookChangeSettings, SRecipeBookSeenRecipe,
SSelectTrade, SSetCommandBlock, SSetCreativeSlot, SSetHeldItem, SSetJigsawBlock,
SSetPlayerGround, SSwingArm, SUpdateSign, SUseItem, SUseItemOn, Status,
};
use pumpkin_util::math::boundingbox::BoundingBox;
use pumpkin_util::math::vector3::Vector3;
@@ -2919,4 +2919,27 @@ impl JavaClient {
.await;
}
}
pub async fn handle_bundle_item_selected(
&self,
player: &Arc<Player>,
packet: SBundleItemSelected,
) {
if !player.has_client_loaded() {
return;
}
player.update_last_action_time();
let selected_item_index = packet.selected_item_index.0;
if selected_item_index < 0 && selected_item_index != -1 {
self.kick(TextComponent::text("Invalid selected item index"))
.await;
return;
}
debug!(
"Bundle item selected: Slot ID {}, Selected Item Index {}",
packet.slot_id.0, selected_item_index
);
}
}