diff --git a/crates/pumpkin/src/net/bedrock/login.rs b/crates/pumpkin/src/net/bedrock/login.rs deleted file mode 100644 index f4c697ab5..000000000 --- a/crates/pumpkin/src/net/bedrock/login.rs +++ /dev/null @@ -1,317 +0,0 @@ -use crate::{ - net::{ - DisconnectReason, GameProfile, PacketHandlerResult, PlayerConfig, bedrock::BedrockClient, - }, - server::Server, -}; -use arc_swap::ArcSwap; -use pumpkin_protocol::bedrock::{ - client::{ - network_settings::CNetworkSettings, play_status::CPlayStatus, - resource_pack_stack::CResourcePackStackPacket, resource_packs_info::CResourcePacksInfo, - start_game::Experiments, - }, - server::{login::SLogin, request_network_settings::SRequestNetworkSettings}, -}; -use pumpkin_protocol::bedrock::{ - client::{resource_pack_stack::ResourcePackStackEntry, resource_packs_info::ResourcePackEntry}, - server::{login::ClientData, resource_pack_response::SResourcePackResponse}, -}; -use pumpkin_util::jwt::AuthError; -use pumpkin_util::version::BedrockMinecraftVersion; -use pumpkin_world::{CURRENT_BEDROCK_MC_PROTOCOL, CURRENT_BEDROCK_MC_VERSION}; -use serde::{Deserialize, de::Error}; -use serde_repr::Deserialize_repr; -use std::sync::Arc; -use thiserror::Error; -use tracing::debug; -use uuid::Uuid; - -#[derive(Debug, Error)] -pub enum LoginError { - #[error("Login packet data is not valid JSON")] - InvalidTokenFormat(#[from] serde_json::Error), - #[error("JWT chain validation failed: {0}")] - ChainValidationFailed(#[from] AuthError), - #[error("The validated username is invalid")] - InvalidUsername, - #[error("Could not parse UUID from validated token")] - InvalidUuid, - #[error("Cannot accept self-signed token. Authentication is enforced by server config.")] - SelfSignedNotAllowed, - #[error("Got a guest/splitscreen login request. Currently unimplemented.")] - GuestUnimplemented, - #[error("Failed to decode extra using decode_b64_url_nopad.")] - DecodeExtraError, -} - -#[derive(Deserialize_repr)] -#[repr(u8)] -enum AuthenticationType { - Full, - Guest, - SelfSigned, -} - -#[derive(Deserialize)] -#[serde(rename_all = "PascalCase")] -struct AuthPayload { - authentication_type: AuthenticationType, - token: String, -} - -/// Verifies OIDC tokens for Bedrock 1.26.10+ clients. -fn verify_oidc_token_path( - server: &Server, - token: &str, - self_signed: bool, -) -> Result { - if self_signed { - pumpkin_util::jwt::verify_oidc_token_self_signed(token) - .map_err(LoginError::ChainValidationFailed) - } else { - let (issuer, jwks) = - server - .bedrock_oidc_keys - .get() - .ok_or(LoginError::ChainValidationFailed( - AuthError::PublicKeyBuild("OIDC keys not initialized".into()), - ))?; - - pumpkin_util::jwt::verify_oidc_token(token, issuer, jwks) - .map_err(LoginError::ChainValidationFailed) - } -} - -impl BedrockClient { - pub async fn handle_request_network_settings( - &self, - packet: SRequestNetworkSettings, - server: &Server, - ) { - if packet.protocol_version < CURRENT_BEDROCK_MC_PROTOCOL as i32 { - self.send_game_packet(&CPlayStatus::OutdatedClient).await; - return; - } else if packet.protocol_version > CURRENT_BEDROCK_MC_PROTOCOL as i32 { - self.send_game_packet(&CPlayStatus::OutdatedServer).await; - return; - } - - self.version.store(BedrockMinecraftVersion::from_protocol( - packet.protocol_version as u32, - )); - - let compression = server - .advanced_config - .networking - .bedrock - .compression - .info - .clone(); - self.send_game_packet(&CNetworkSettings::new( - compression.threshold as u16, - 0, - false, - 0, - 0.0, - )) - .await; - self.set_compression(compression).await; - } - - pub async fn handle_login( - self: &Arc, - packet: SLogin, - server: &Server, - ) -> Result { - self.try_handle_login(packet, server).await - } - - pub async fn try_handle_login( - self: &Arc, - packet: SLogin, - server: &Server, - ) -> Result { - let auth_payload: AuthPayload = serde_json::from_slice(&packet.jwt)?; - let player_data = if server.advanced_config.networking.bedrock.online_mode { - match auth_payload.authentication_type { - AuthenticationType::Full => { - verify_oidc_token_path(server, &auth_payload.token, false)? - } - AuthenticationType::SelfSigned => { - if server - .advanced_config - .networking - .bedrock - .authentication - .enabled - { - return Err(LoginError::SelfSignedNotAllowed); - } - - verify_oidc_token_path(server, &auth_payload.token, true)? - } - AuthenticationType::Guest => { - return Err(LoginError::GuestUnimplemented); - } - } - } else { - pumpkin_util::jwt::extract_oidc_token_player_claims(&auth_payload.token)? - }; - - let raw_token_str = std::str::from_utf8(&packet.raw_token).map_err(|_| { - LoginError::InvalidTokenFormat(serde_json::Error::custom( - "raw_token is not valid UTF-8", - )) - })?; // You'll need to add a string conversion error to LoginError, or handle it cleanly. - - let mut parts = raw_token_str.split('.'); - let _header = parts.next().ok_or(AuthError::InvalidTokenFormat)?; - let payload_b64 = parts.next().ok_or(AuthError::InvalidTokenFormat)?; - - let payload_bytes = pumpkin_util::jwt::decode_b64_url_nopad(payload_b64) - .map_err(|_| LoginError::DecodeExtraError)?; - let client_data: ClientData = serde_json::from_slice(&payload_bytes)?; - - let real_name = player_data.display_name; - // IMPORTANT: Bedrock allows spaces in names. While we could support this, it would significantly complicate parsing player arguments in commands, so we don't - let under_score_name = real_name.replace(' ', "_"); - - let profile = GameProfile { - id: Uuid::parse_str(&player_data.uuid).map_err(|_| LoginError::InvalidUuid)?, - name: under_score_name, - properties: ArcSwap::new(Arc::new(Vec::new())), - profile_actions: None, - }; - - let login_public_key = pumpkin_util::jwt::extract_cpk_from_token(&auth_payload.token) - .map_err(LoginError::ChainValidationFailed)?; - if self - .nethernet_public_key() - .is_some_and(|public_key| public_key != &login_public_key) - { - return Err(LoginError::ChainValidationFailed( - AuthError::PublicKeyBuild( - "NetherNet and Bedrock login identities do not match".into(), - ), - )); - } - - self.enqueue_packet_internal(&CPlayStatus::LoginSuccess) - .await; - let br_config = &server.advanced_config.resource_pack.bedrock; - - let mut entries = Vec::new(); - if br_config.enabled { - for pack in &br_config.packs { - entries.push(ResourcePackEntry { - uuid: pack.uuid, - version: pack.version.clone(), - size: pack.size, - download_url: pack.download_url.clone(), - content_key: pack.content_key.clone(), - sub_pack_name: pack.sub_pack_name.clone(), - content_id: pack.content_id.clone(), - has_scripts: pack.has_scripts, - addon_pack: pack.addon_pack, - rtx_enabled: pack.rtx_enabled, - }); - } - } - - let packs_info = CResourcePacksInfo { - resource_pack_required: br_config.force, - has_addon_packs: false, - has_scripts: false, - is_vibrant_visuals_force_disabled: false, - world_template_id: uuid::Uuid::nil(), - world_template_version: String::new(), - resource_packs: entries, - }; - self.enqueue_packet_internal(&packs_info).await; - - let new_config = PlayerConfig { - locale: client_data.language_code.clone(), - ..Default::default() - }; - - self.client_data - .store(std::sync::Arc::new(Some(std::sync::Arc::new(client_data)))); - - Ok(PacketHandlerResult::ReadyToPlay(profile, new_config)) - } - - pub async fn handle_resource_pack_response( - &self, - packet: SResourcePackResponse, - server: &Arc, - ) { - // TODO: warn & ignore if the player is already spawned in - - match packet.response { - SResourcePackResponse::STATUS_REFUSED => { - debug!("Bedrock: SResourcePackResponse::STATUS_REFUSED"); - self.kick( - DisconnectReason::ResourcePackProblem, - "You must accept resource packs to join this server.".into(), - ) - .await; - } - SResourcePackResponse::STATUS_SEND_PACKS => { - debug!("Bedrock: SResourcePackResponse::STATUS_SEND_PACKS"); - // TODO: send packs - } - SResourcePackResponse::STATUS_HAVE_ALL_PACKS => { - debug!("Bedrock: SResourcePackResponse::STATUS_HAVE_ALL_PACKS"); - let br_config = &server.advanced_config.resource_pack.bedrock; - // Convert your config packs into protocol stack entries - let resource_packs = if br_config.enabled { - br_config - .packs - .iter() - .map(|pack| ResourcePackStackEntry { - uuid: pack.uuid.to_string(), - version: pack.version.clone(), - sub_pack_name: String::new(), - }) - .collect() - } else { - Vec::new() - }; - - self.enqueue_packet_internal(&CResourcePackStackPacket::new( - br_config.force, - resource_packs, - CURRENT_BEDROCK_MC_VERSION.to_string(), - Experiments { - names_size: 0, - experiments_ever_toggled: false, - }, - false, - )) - .await; - } - SResourcePackResponse::STATUS_COMPLETED => { - debug!("Bedrock: SResourcePackResponse::STATUS_COMPLETED"); - let player = self.player.load_full(); - if let Some(player) = player.as_ref() { - player - .world() - .spawn_bedrock_player(&server.basic_config, player.clone(), server) - .await; - } else { - tracing::error!( - "Got SResourcePackResponse::STATUS_COMPLETED before authentication was completed." - ); - self.kick(DisconnectReason::Disconnected, String::new()) - .await; - } - } - _ => { - tracing::error!("Bedrock: SResourcePackResponse bad response type"); - self.kick(DisconnectReason::Disconnected, String::new()) - .await; - } - } - } -} diff --git a/crates/pumpkin/src/net/bedrock/login/login.rs b/crates/pumpkin/src/net/bedrock/login/login.rs new file mode 100644 index 000000000..f21d187a4 --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/login/login.rs @@ -0,0 +1,126 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + pub async fn handle_login( + self: &Arc, + packet: SLogin, + server: &Server, + ) -> Result { + self.try_handle_login(packet, server).await + } + + pub async fn try_handle_login( + self: &Arc, + packet: SLogin, + server: &Server, + ) -> Result { + let auth_payload: AuthPayload = serde_json::from_slice(&packet.jwt)?; + let player_data = if server.advanced_config.networking.bedrock.online_mode { + match auth_payload.authentication_type { + AuthenticationType::Full => { + verify_oidc_token_path(server, &auth_payload.token, false)? + } + AuthenticationType::SelfSigned => { + if server + .advanced_config + .networking + .bedrock + .authentication + .enabled + { + return Err(LoginError::SelfSignedNotAllowed); + } + + verify_oidc_token_path(server, &auth_payload.token, true)? + } + AuthenticationType::Guest => { + return Err(LoginError::GuestUnimplemented); + } + } + } else { + pumpkin_util::jwt::extract_oidc_token_player_claims(&auth_payload.token)? + }; + + let raw_token_str = std::str::from_utf8(&packet.raw_token).map_err(|_| { + LoginError::InvalidTokenFormat(serde_json::Error::custom( + "raw_token is not valid UTF-8", + )) + })?; // You'll need to add a string conversion error to LoginError, or handle it cleanly. + + let mut parts = raw_token_str.split('.'); + let _header = parts.next().ok_or(AuthError::InvalidTokenFormat)?; + let payload_b64 = parts.next().ok_or(AuthError::InvalidTokenFormat)?; + + let payload_bytes = pumpkin_util::jwt::decode_b64_url_nopad(payload_b64) + .map_err(|_| LoginError::DecodeExtraError)?; + let client_data: ClientData = serde_json::from_slice(&payload_bytes)?; + + let real_name = player_data.display_name; + // IMPORTANT: Bedrock allows spaces in names. While we could support this, it would significantly complicate parsing player arguments in commands, so we don't + let under_score_name = real_name.replace(' ', "_"); + + let profile = GameProfile { + id: Uuid::parse_str(&player_data.uuid).map_err(|_| LoginError::InvalidUuid)?, + name: under_score_name, + properties: ArcSwap::new(Arc::new(Vec::new())), + profile_actions: None, + }; + + let login_public_key = pumpkin_util::jwt::extract_cpk_from_token(&auth_payload.token) + .map_err(LoginError::ChainValidationFailed)?; + if self + .nethernet_public_key() + .is_some_and(|public_key| public_key != &login_public_key) + { + return Err(LoginError::ChainValidationFailed( + AuthError::PublicKeyBuild( + "NetherNet and Bedrock login identities do not match".into(), + ), + )); + } + + self.enqueue_packet_internal(&CPlayStatus::LoginSuccess) + .await; + let br_config = &server.advanced_config.resource_pack.bedrock; + + let mut entries = Vec::new(); + if br_config.enabled { + for pack in &br_config.packs { + entries.push(ResourcePackEntry { + uuid: pack.uuid, + version: pack.version.clone(), + size: pack.size, + download_url: pack.download_url.clone(), + content_key: pack.content_key.clone(), + sub_pack_name: pack.sub_pack_name.clone(), + content_id: pack.content_id.clone(), + has_scripts: pack.has_scripts, + addon_pack: pack.addon_pack, + rtx_enabled: pack.rtx_enabled, + }); + } + } + + let packs_info = CResourcePacksInfo { + resource_pack_required: br_config.force, + has_addon_packs: false, + has_scripts: false, + is_vibrant_visuals_force_disabled: false, + world_template_id: uuid::Uuid::nil(), + world_template_version: String::new(), + resource_packs: entries, + }; + self.enqueue_packet_internal(&packs_info).await; + + let new_config = PlayerConfig { + locale: client_data.language_code.clone(), + ..Default::default() + }; + + self.client_data + .store(std::sync::Arc::new(Some(std::sync::Arc::new(client_data)))); + + Ok(PacketHandlerResult::ReadyToPlay(profile, new_config)) + } +} diff --git a/crates/pumpkin/src/net/bedrock/login/mod.rs b/crates/pumpkin/src/net/bedrock/login/mod.rs new file mode 100644 index 000000000..3046fd282 --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/login/mod.rs @@ -0,0 +1,89 @@ +use crate::{ + net::{ + DisconnectReason, GameProfile, PacketHandlerResult, PlayerConfig, bedrock::BedrockClient, + }, + server::Server, +}; +use arc_swap::ArcSwap; +use pumpkin_protocol::bedrock::{ + client::{ + network_settings::CNetworkSettings, play_status::CPlayStatus, + resource_pack_stack::CResourcePackStackPacket, resource_packs_info::CResourcePacksInfo, + start_game::Experiments, + }, + server::{login::SLogin, request_network_settings::SRequestNetworkSettings}, +}; +use pumpkin_protocol::bedrock::{ + client::{resource_pack_stack::ResourcePackStackEntry, resource_packs_info::ResourcePackEntry}, + server::{login::ClientData, resource_pack_response::SResourcePackResponse}, +}; +use pumpkin_util::jwt::AuthError; +use pumpkin_util::version::BedrockMinecraftVersion; +use pumpkin_world::{CURRENT_BEDROCK_MC_PROTOCOL, CURRENT_BEDROCK_MC_VERSION}; +use serde::{Deserialize, de::Error}; +use serde_repr::Deserialize_repr; +use std::sync::Arc; +use thiserror::Error; +use tracing::debug; +use uuid::Uuid; + +#[derive(Debug, Error)] +pub enum LoginError { + #[error("Login packet data is not valid JSON")] + InvalidTokenFormat(#[from] serde_json::Error), + #[error("JWT chain validation failed: {0}")] + ChainValidationFailed(#[from] AuthError), + #[error("The validated username is invalid")] + InvalidUsername, + #[error("Could not parse UUID from validated token")] + InvalidUuid, + #[error("Cannot accept self-signed token. Authentication is enforced by server config.")] + SelfSignedNotAllowed, + #[error("Got a guest/splitscreen login request. Currently unimplemented.")] + GuestUnimplemented, + #[error("Failed to decode extra using decode_b64_url_nopad.")] + DecodeExtraError, +} + +#[derive(Deserialize_repr)] +#[repr(u8)] +enum AuthenticationType { + Full, + Guest, + SelfSigned, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct AuthPayload { + authentication_type: AuthenticationType, + token: String, +} + +/// Verifies OIDC tokens for Bedrock 1.26.10+ clients. +fn verify_oidc_token_path( + server: &Server, + token: &str, + self_signed: bool, +) -> Result { + if self_signed { + pumpkin_util::jwt::verify_oidc_token_self_signed(token) + .map_err(LoginError::ChainValidationFailed) + } else { + let (issuer, jwks) = + server + .bedrock_oidc_keys + .get() + .ok_or(LoginError::ChainValidationFailed( + AuthError::PublicKeyBuild("OIDC keys not initialized".into()), + ))?; + + pumpkin_util::jwt::verify_oidc_token(token, issuer, jwks) + .map_err(LoginError::ChainValidationFailed) + } +} + +#[allow(clippy::module_inception)] +pub mod login; +pub mod request_network_settings; +pub mod resource_pack_response; diff --git a/crates/pumpkin/src/net/bedrock/login/request_network_settings.rs b/crates/pumpkin/src/net/bedrock/login/request_network_settings.rs new file mode 100644 index 000000000..42aacda77 --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/login/request_network_settings.rs @@ -0,0 +1,39 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + pub async fn handle_request_network_settings( + &self, + packet: SRequestNetworkSettings, + server: &Server, + ) { + if packet.protocol_version < CURRENT_BEDROCK_MC_PROTOCOL as i32 { + self.send_game_packet(&CPlayStatus::OutdatedClient).await; + return; + } else if packet.protocol_version > CURRENT_BEDROCK_MC_PROTOCOL as i32 { + self.send_game_packet(&CPlayStatus::OutdatedServer).await; + return; + } + + self.version.store(BedrockMinecraftVersion::from_protocol( + packet.protocol_version as u32, + )); + + let compression = server + .advanced_config + .networking + .bedrock + .compression + .info + .clone(); + self.send_game_packet(&CNetworkSettings::new( + compression.threshold as u16, + 0, + false, + 0, + 0.0, + )) + .await; + self.set_compression(compression).await; + } +} diff --git a/crates/pumpkin/src/net/bedrock/login/resource_pack_response.rs b/crates/pumpkin/src/net/bedrock/login/resource_pack_response.rs new file mode 100644 index 000000000..196fa7d21 --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/login/resource_pack_response.rs @@ -0,0 +1,78 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + pub async fn handle_resource_pack_response( + &self, + packet: SResourcePackResponse, + server: &Arc, + ) { + // TODO: warn & ignore if the player is already spawned in + + match packet.response { + SResourcePackResponse::STATUS_REFUSED => { + debug!("Bedrock: SResourcePackResponse::STATUS_REFUSED"); + self.kick( + DisconnectReason::ResourcePackProblem, + "You must accept resource packs to join this server.".into(), + ) + .await; + } + SResourcePackResponse::STATUS_SEND_PACKS => { + debug!("Bedrock: SResourcePackResponse::STATUS_SEND_PACKS"); + // TODO: send packs + } + SResourcePackResponse::STATUS_HAVE_ALL_PACKS => { + debug!("Bedrock: SResourcePackResponse::STATUS_HAVE_ALL_PACKS"); + let br_config = &server.advanced_config.resource_pack.bedrock; + // Convert your config packs into protocol stack entries + let resource_packs = if br_config.enabled { + br_config + .packs + .iter() + .map(|pack| ResourcePackStackEntry { + uuid: pack.uuid.to_string(), + version: pack.version.clone(), + sub_pack_name: String::new(), + }) + .collect() + } else { + Vec::new() + }; + + self.enqueue_packet_internal(&CResourcePackStackPacket::new( + br_config.force, + resource_packs, + CURRENT_BEDROCK_MC_VERSION.to_string(), + Experiments { + names_size: 0, + experiments_ever_toggled: false, + }, + false, + )) + .await; + } + SResourcePackResponse::STATUS_COMPLETED => { + debug!("Bedrock: SResourcePackResponse::STATUS_COMPLETED"); + let player = self.player.load_full(); + if let Some(player) = player.as_ref() { + player + .world() + .spawn_bedrock_player(&server.basic_config, player.clone(), server) + .await; + } else { + tracing::error!( + "Got SResourcePackResponse::STATUS_COMPLETED before authentication was completed." + ); + self.kick(DisconnectReason::Disconnected, String::new()) + .await; + } + } + _ => { + tracing::error!("Bedrock: SResourcePackResponse bad response type"); + self.kick(DisconnectReason::Disconnected, String::new()) + .await; + } + } + } +} diff --git a/crates/pumpkin/src/net/bedrock/play.rs b/crates/pumpkin/src/net/bedrock/play.rs deleted file mode 100644 index f28f0cd4b..000000000 --- a/crates/pumpkin/src/net/bedrock/play.rs +++ /dev/null @@ -1,2384 +0,0 @@ -use std::{ - num::{NonZero, NonZeroI32}, - sync::{Arc, atomic::Ordering}, -}; - -use pumpkin_data::{ - data_component_impl::{ - BlocksAttacksImpl, ConsumableImpl, EquipmentSlot, EquippableImpl, FoodImpl, - }, - item_stack::ItemStack, -}; -use pumpkin_inventory::{ - player::player_inventory::PlayerInventory, - screen_handler::{InventoryPlayer, ScreenHandler}, -}; -use pumpkin_protocol::bedrock::{ - client::{inventory_content::CInventoryContent, respawn::CRespawn}, - network_item::{ - ContainerName, FullContainerName, NetworkItemDescriptor, NetworkItemStackDescriptor, - }, - respawn::RespawnState, -}; -use pumpkin_protocol::{ - bedrock::{ - client::{ - chunk_radius_update::CChunkRadiusUpdate, container_open::CContainerOpen, - player_hotbar::CPlayerHotbar, update_block::CUpdateBlock, - }, - server::{ - animate::{AnimateAction, SAnimate}, - block_pick_request::SBlockPickRequest, - command_request::SCommandRequest, - container_close::SContainerClose, - emote::SEmote, - emote_list::SEmoteList, - interaction::{Action, SInteraction}, - inventory_transaction::{SInventoryTransaction, TransactionData}, - mob_equipment::SMobEquipment, - player_action::{Action as PlayerAction, SPlayerAction}, - player_auth_input::{InputData, SPlayerAuthInput}, - request_chunk_radius::SRequestChunkRadius, - respawn::SRespawn, - set_local_player_as_initialized::SSetLocalPlayerAsInitialized, - text::SText, - }, - }, - codec::{var_int::VarInt, var_long::VarLong, var_uint::VarUInt, var_ulong::VarULong}, - java::client::play::{Animation, CEntityAnimation, CSetSelectedSlot, CSystemChatMessage}, -}; -use pumpkin_util::{GameMode, Hand, math::position::BlockPos, text::TextComponent}; - -use pumpkin_world::inventory::Inventory; -use pumpkin_world::world::BlockFlags; - -use crate::{ - block::{BlockHitResult, registry::BlockActionResult}, - entity::{ - EntityBase, - player::{MINE_BLOCK_EXHAUSTION, Player}, - }, - net::{DisconnectReason, bedrock::BedrockClient}, - plugin::player::{ - item_held::PlayerItemHeldEvent, - player_chat::PlayerChatEvent, - player_command_send::PlayerCommandSendEvent, - player_interact_event::{InteractAction, PlayerInteractEvent}, - player_toggle_flight_event::PlayerToggleFlightEvent, - }, - server::{Server, seasonal_events}, - world::{BlockBreakingProgress, chunker}, -}; -use pumpkin_data::BlockDirection; -use tracing::{debug, info}; - -const MIN_PREDICTED_BREAK_PROGRESS: f32 = 0.65; - -fn descriptor_to_stack(desc: &NetworkItemDescriptor) -> ItemStack { - if desc.id.0 == 0 || desc.stack_size == 0 { - ItemStack::EMPTY.clone() - } else { - pumpkin_data::item::JavaToBedrockItemMapping::from_bedrock( - desc.id.0 as i16, - desc.aux_value.0, - ) - .map_or_else( - || { - tracing::warn!( - "Failed to map bedrock item id {} and data {} to Java item", - desc.id.0, - desc.aux_value.0 - ); - ItemStack::EMPTY.clone() - }, - |mapping| ItemStack::new(desc.stack_size as u8, mapping.java_item), - ) - } -} - -const fn map_bedrock_slot_to_screen_handler(window_id: i32, slot: u32) -> Option { - match window_id { - 0 => { - // WINDOW_ID_INVENTORY - if slot < 9 { - // Hotbar: Bedrock 0-8 -> Screen Handler 36-44 - Some(slot as usize + 36) - } else if slot < 36 { - // Main Inventory: Bedrock 9-35 -> Screen Handler 9-35 - Some(slot as usize) - } else { - None - } - } - 120 => { - // WINDOW_ID_ARMOUR - if slot < 4 { - // Armor: Bedrock 0-3 -> Screen Handler 5-8 - Some(slot as usize + 5) - } else { - None - } - } - 119 => { - // WINDOW_ID_OFF_HAND - if slot == 0 { - // Offhand: Bedrock 0 -> Screen Handler 45 - Some(45) - } else { - None - } - } - _ => None, - } -} - -impl BedrockClient { - pub async fn handle_request_chunk_radius( - &self, - player: &Arc, - packet: SRequestChunkRadius, - ) { - let chunk_radius = packet.chunk_radius; - if chunk_radius.0 < 1 { - self.kick( - DisconnectReason::Kicked, - "Cannot have zero or negative view distance!".to_string(), - ) - .await; - return; - } - let Some(server) = player.world().server.upgrade() else { - return; - }; - - let view_distance = chunk_radius.clamp( - 2, - NonZeroI32::from(server.advanced_config.networking.bedrock.view_distance).get(), - ); - - self.enqueue_packet(&CChunkRadiusUpdate { - chunk_radius: VarInt(view_distance), - }) - .await; - - let old_view_distance = { - let current_config = player.config.load(); - let old_vd = current_config.view_distance; - let mut new_config = (**current_config).clone(); - - new_config.view_distance = - NonZero::new(view_distance as u8).unwrap_or(NonZero::::MIN); - player.config.store(std::sync::Arc::new(new_config)); - - old_vd - }; - - debug!( - "Player {} updated their render distance: {} -> {}.", - player.gameprofile.name, old_view_distance, view_distance - ); - chunker::update_position(player).await; - } - - pub fn handle_set_local_player_as_initialized( - &self, - player: &Arc, - packet: &SSetLocalPlayerAsInitialized, - ) { - debug!( - "Player {} initialized (Runtime ID: {})", - player.gameprofile.name, packet.runtime_entity_id.0 - ); - // This is sent when the client has finished loading and rendering the world. - player.set_client_loaded(true); - } - - #[expect(clippy::too_many_lines)] - pub async fn handle_player_auth_input( - &self, - player: &Arc, - packet: SPlayerAuthInput, - server: &Arc, - ) { - if !player.has_client_loaded() { - return; - } - if player.living_entity.dead.load(Ordering::Relaxed) - || player.living_entity.health.load() <= 0.0 - { - return; - } - let entity = player.get_entity(); - let on_ground = packet.input_data.get(InputData::VerticalCollision as usize) - && packet.delta.y < 0.0 - && !entity.has_vehicle().await; - entity.on_ground.store(on_ground, Ordering::Relaxed); - - let new_pos = packet - .position - .add_raw(0.0, -entity.entity_type.eye_height, 0.0) - .to_f64(); - let old_pos = player.position(); - - let new_pitch = packet.pitch; - let new_yaw = packet.yaw; - - let old_pitch = entity.pitch.load(); - let old_yaw = entity.yaw.load(); - - let pos_changed = new_pos != old_pos; - let rot_changed = new_pitch != old_pitch || new_yaw != old_yaw; - - if pos_changed || rot_changed { - let world = player.world(); - - if pos_changed { - player.get_entity().set_pos(new_pos); - } - if rot_changed { - entity.pitch.store(new_pitch); - entity.yaw.store(new_yaw); - } - - let je_yaw = (new_yaw * 256.0 / 360.0).rem_euclid(256.0); - let je_pitch = (new_pitch * 256.0 / 360.0).rem_euclid(256.0); - - let delta = pumpkin_util::math::vector3::Vector3::new( - new_pos.x - old_pos.x, - new_pos.y - old_pos.y, - new_pos.z - old_pos.z, - ); - - let bedrock_move_packet = pumpkin_protocol::bedrock::client::CMovePlayer::new( - pumpkin_protocol::codec::var_ulong::VarULong(player.entity_id() as u64), - pumpkin_util::math::vector3::Vector3::new( - new_pos.x as f32, - new_pos.y as f32 + entity.entity_type.eye_height, - new_pos.z as f32, - ), - new_pitch, - new_yaw, - new_yaw, // Head yaw - pumpkin_protocol::bedrock::client::CMovePlayer::MODE_NORMAL, - on_ground, - pumpkin_protocol::codec::var_ulong::VarULong(0), - 0, - 0, - pumpkin_protocol::codec::var_ulong::VarULong(0), - ); - - if pos_changed && delta.length_squared() >= 64.0 { - world.broadcast_packet_except( - &[player.gameprofile.id], - &pumpkin_protocol::java::client::play::CEntityPositionSync::new( - player.entity_id().into(), - new_pos, - pumpkin_util::math::vector3::Vector3::new(0.0, 0.0, 0.0), - je_yaw, - je_pitch, - on_ground, - ), - ); - } else if pos_changed && rot_changed { - world.broadcast_packet_except_editioned_sync( - &[player.gameprofile.id], - &pumpkin_protocol::java::client::play::CUpdateEntityPosRot::new( - player.entity_id().into(), - pumpkin_util::math::vector3::Vector3::new( - new_pos.x.mul_add(4096.0, -(old_pos.x * 4096.0)) as i16, - new_pos.y.mul_add(4096.0, -(old_pos.y * 4096.0)) as i16, - new_pos.z.mul_add(4096.0, -(old_pos.z * 4096.0)) as i16, - ), - je_yaw as u8, // Use converted Java byte - je_pitch as u8, // Use converted Java byte - on_ground, - ), - &bedrock_move_packet, - ); - } else if pos_changed { - world.broadcast_packet_except_editioned_sync( - &[player.gameprofile.id], - &pumpkin_protocol::java::client::play::CUpdateEntityPos::new( - player.entity_id().into(), - pumpkin_util::math::vector3::Vector3::new( - new_pos.x.mul_add(4096.0, -(old_pos.x * 4096.0)) as i16, - new_pos.y.mul_add(4096.0, -(old_pos.y * 4096.0)) as i16, - new_pos.z.mul_add(4096.0, -(old_pos.z * 4096.0)) as i16, - ), - on_ground, - ), - &bedrock_move_packet, - ); - } else if rot_changed { - world.broadcast_packet_except_editioned_sync( - &[player.gameprofile.id], - &pumpkin_protocol::java::client::play::CUpdateEntityRot::new( - player.entity_id().into(), - je_yaw as u8, // Use converted Java byte - je_pitch as u8, // Use converted Java byte - on_ground, - ), - &bedrock_move_packet, - ); - } - - if rot_changed { - world.broadcast_packet_except( - &[player.gameprofile.id], - // Adjust to `CHeadRot` if that is what your crate currently calls it - &pumpkin_protocol::java::client::play::CHeadRot::new( - player.entity_id().into(), - je_yaw as u8, - ), - ); - } - - if pos_changed { - chunker::update_position(player).await; - player.progress_motion(delta).await; - } - } - - let input_data = packet.input_data; - - if input_data.get(InputData::StartSprinting as usize) { - entity.set_sprinting(true).await; - } else if input_data.get(InputData::StopSprinting as usize) { - entity.set_sprinting(false).await; - } - - if input_data.get(InputData::StartSneaking as usize) { - entity.set_sneaking(true).await; - } else if input_data.get(InputData::StopSneaking as usize) { - entity.set_sneaking(false).await; - } - - if input_data.get(InputData::StartFlying as usize) { - let flying = { player.abilities.lock().await.flying }; - if !flying { - send_cancellable! {{ - server; - PlayerToggleFlightEvent::new(player.clone(), true); - 'after: { - { - player.abilities.lock().await.flying = true; - }; - player.send_abilities_update().await; - } - 'cancelled: { - player.send_abilities_update().await; - } - }} - } - } else if input_data.get(InputData::StopFlying as usize) { - let flying = { player.abilities.lock().await.flying }; - if flying { - send_cancellable! {{ - server; - PlayerToggleFlightEvent::new(player.clone(), false); - 'after: { - { - player.abilities.lock().await.flying = false; - }; - player.send_abilities_update().await; - } - 'cancelled: { - player.send_abilities_update().await; - } - }} - } - } - - if let Some(block_actions) = packet.block_actions { - for action in block_actions { - self.handle_player_block_action(player, server, action) - .await; - } - } - } - - pub async fn handle_player_block_action( - &self, - player: &Arc, - server: &Server, - packet: pumpkin_protocol::bedrock::server::player_auth_input::PlayerBlockAction, - ) { - use pumpkin_protocol::bedrock::server::player_action::Action as PlayerAction; - let Ok(action) = PlayerAction::try_from(packet.action.0) else { - return; - }; - self.handle_player_action( - player, - server, - SPlayerAction { - runtime_id: VarULong(0), // Unused - action, - block_pos: packet.block_pos, - result_pos: BlockPos::ZERO, - face: packet.face, - }, - ) - .await; - } - - pub async fn handle_animate(&self, player: &Arc, _server: &Server, packet: &SAnimate) { - if !player.has_client_loaded() { - return; - } - - let entity = &player.get_entity(); - let world = entity.world.load(); - - let java_animation = match packet.action { - AnimateAction::NoAction => None, - AnimateAction::SwingArm => Some(Animation::SwingMainArm), - AnimateAction::WakeUp => Some(Animation::LeaveBed), - AnimateAction::CriticalHit => Some(Animation::CriticalEffect), - AnimateAction::MagicCriticalHit => Some(Animation::MagicCriticaleffect), - }; - - if let Some(animation) = java_animation { - let je_packet = CEntityAnimation::new(VarInt(entity.entity_id), animation); - let be_packet = SAnimate { - action: packet.action, - runtime_entity_id: VarULong(entity.entity_id as u64), - data: 0.0, - swing_source: None, - }; - world.broadcast_editioned(&je_packet, &be_packet).await; - } - } - - pub async fn handle_emote(&self, player: &Arc, _server: &Server, packet: SEmote<'_>) { - if !player.has_client_loaded() { - return; - } - - tracing::info!( - "handle_emote: player={} packet={:?}", - player.gameprofile.name, - packet - ); - - let entity = &player.living_entity.entity; - let world = entity.world.load(); - - let mut broadcast_packet = packet; - broadcast_packet.runtime_entity_id = VarULong(entity.entity_id as u64); - broadcast_packet.flags |= pumpkin_protocol::bedrock::server::emote::EMOTE_FLAG_SERVER_SIDE; - - world - .broadcast_packet_except_editioned( - &[player.gameprofile.id], - &CEntityAnimation::new( - VarInt(entity.entity_id), - Animation::SwingMainArm, // Fallback for Java? Or just ignore - ), - &broadcast_packet, - ) - .await; - } - - pub fn handle_emote_list(&self, player: &Arc, _server: &Server, packet: &SEmoteList) { - tracing::info!( - "handle_emote_list: player={} packet={:?}", - player.gameprofile.name, - packet - ); - } - - #[allow(clippy::too_many_lines, clippy::collapsible_if, clippy::unreachable)] - pub async fn handle_inventory_action( - &self, - player: &Arc, - packet: SInventoryTransaction, - ) { - tracing::debug!("handle_inventory_action: packet={:?}", packet); - let mut inventory_updated = false; - let mut updates = Vec::new(); - let result = 0u8; - - if packet.actions.is_empty() && packet.legacy_request_id.0 != 0 { - let mut player_screen_handler = player.player_screen_handler.lock().await; - for legacy_slot in &packet.legacy_set_item_slots { - let mapped_window_id = match legacy_slot.container_id { - 28 | 29 => 0, // HotBar or Inventory - 6 | 120 => 120, // Armor - 34 | 119 => 119, // Offhand - other => other as i32, - }; - for &slot_id in &legacy_slot.slots { - if let Some(screen_slot) = - map_bedrock_slot_to_screen_handler(mapped_window_id, slot_id as u32) - { - let current_stack = player_screen_handler - .get_slot(screen_slot) - .get_cloned_stack() - .await; - if !current_stack.is_empty() { - player.drop_item(current_stack.clone()).await; - - player_screen_handler - .get_slot(screen_slot) - .set_stack(ItemStack::EMPTY.clone()) - .await; - player_screen_handler - .set_received_stack(screen_slot, ItemStack::EMPTY.clone()); - - record_update( - &mut updates, - FullContainerName { - container_name: match legacy_slot.container_id { - 28 => ContainerName::HotBar, - _ => ContainerName::Inventory, - }, - dynamic_id: None, - }, - slot_id, - ItemStack::EMPTY, - ); - inventory_updated = true; - } - } - } - } - player_screen_handler.send_content_updates().await; - } - - for action in &packet.actions { - use pumpkin_protocol::bedrock::server::inventory_transaction::InventoryActionSource; - let source_type = InventoryActionSource::from(action.source_type); - if source_type == InventoryActionSource::World { - let old_stack = descriptor_to_stack(&action.old_item); - let new_stack = descriptor_to_stack(&action.new_item); - if old_stack.is_empty() && !new_stack.is_empty() { - player.drop_item(new_stack).await; - } - } else if let Some(window_id) = action.window_id { - if let Some(screen_slot) = - map_bedrock_slot_to_screen_handler(window_id, action.inventory_slot) - { - let item_stack = descriptor_to_stack(&action.new_item); - - let mut player_screen_handler = player.player_screen_handler.lock().await; - - let is_armor_equipped = player_screen_handler - .get_slot(screen_slot) - .get_stack() - .await - .are_equal(&item_stack); - - if !is_armor_equipped { - if (5..9).contains(&screen_slot) { - player - .enqueue_equipment_change( - &match screen_slot { - 5 => EquipmentSlot::HEAD, - 6 => EquipmentSlot::CHEST, - 7 => EquipmentSlot::LEGS, - 8 => EquipmentSlot::FEET, - _ => unreachable!(), - }, - &item_stack, - ) - .await; - } else if (36..45).contains(&screen_slot) { - let hotbar_slot = screen_slot - 36; - if player.inventory().get_selected_slot() == hotbar_slot as u8 { - let equipment = &[(EquipmentSlot::MAIN_HAND, item_stack.clone())]; - player.living_entity.send_equipment_changes(equipment); - } - } - } - - player_screen_handler - .get_slot(screen_slot) - .set_stack(item_stack.clone()) - .await; - player_screen_handler.set_received_stack(screen_slot, item_stack); - player_screen_handler.send_content_updates().await; - - inventory_updated = true; - } - } - } - - if inventory_updated { - self.enqueue_packet(&CInventoryContent { - container_id: VarUInt(0), - slots: player - .inventory() - .main_inventory - .read() - .await - .iter() - .map(NetworkItemStackDescriptor::from) - .collect(), - full_container_name: FullContainerName { - container_name: ContainerName::Inventory, - dynamic_id: None, - }, - storage_item: NetworkItemStackDescriptor::default(), - }) - .await; - } - - match packet.transaction_data { - TransactionData::Normal(_data) => { - // Actions are already applied to the inventory screen handler above. - } - TransactionData::Mismatch(_data) => { - // Actions are already applied to the inventory screen handler above. - } - TransactionData::UseItem(data) => { - let face = match data.block_face { - 0 => BlockDirection::Down, - 2 => BlockDirection::North, - 3 => BlockDirection::South, - 4 => BlockDirection::West, - 5 => BlockDirection::East, - _ => BlockDirection::Up, - }; - let world = player.world(); - let block = world.get_block(&data.block_position); - let Some(server) = world.server.upgrade() else { - return; - }; - - if player.gamemode.load() == GameMode::Spectator { - // TODO: openMenu ? - return; - } - - if data.action_type.0 == 0 { - // Click block - let client_stack = descriptor_to_stack(&data.item_in_hand); - - let mut held_item = player.inventory().held_item().await; - if !client_stack.is_empty() { - if held_item.is_empty() || held_item.item.id != client_stack.item.id { - held_item = client_stack.clone(); - } - } - - let result = server - .block_registry - .use_with_item( - block, - player, - &data.block_position, - &BlockHitResult { - face: &face, - cursor_pos: &data.click_position, - }, - &mut held_item, - &EquipmentSlot::MAIN_HAND, - &server, - &world, - ) - .await; - - if result.consumes_action() { - return; - } - - if matches!(result, BlockActionResult::PassToDefaultBlockAction) { - server - .block_registry - .on_use( - block, - player, - &data.block_position, - &BlockHitResult { - face: &face, - cursor_pos: &data.click_position, - }, - &server, - &world, - ) - .await; - } - - let mut stack = held_item; - if !stack.is_empty() { - server - .item_registry - .use_on_block( - &mut stack, - player, - data.block_position, - face, - data.click_position, - block, - &server, - ) - .await; - - let item_id = stack.item.id; - if let Some(placed_block) = pumpkin_data::Block::from_item_id(item_id) { - let dummy_use_item_on = - pumpkin_protocol::java::server::play::SUseItemOn { - hand: VarInt(0), - position: data.block_position, - face: VarInt(data.block_face), - cursor_pos: data.click_position, - inside_block: false, - is_against_world_border: false, - sequence: VarInt(0), - }; - - if let Ok(Some(_)) = server - .block_registry - .place_block( - player, - placed_block, - &server, - &dummy_use_item_on, - data.block_position, - face, - ) - .await - { - if player.gamemode.load() != GameMode::Creative { - stack.decrement(1); - } - } - } - player.inventory().set_held_item(stack).await; - } - } else if data.action_type.0 == 1 { - // Click air / Use item - let client_stack = descriptor_to_stack(&data.item_in_hand); - - let mut held = player.inventory.held_item().await; - if !client_stack.is_empty() - && (held.is_empty() || held.item.id != client_stack.item.id) - { - held = client_stack.clone(); - player.inventory.set_held_item(held.clone()).await; - } - - let event = PlayerInteractEvent::new( - player, - InteractAction::RightClickAir, - &pumpkin_data::Block::AIR, - None, - ); - - let stack_for_use = held.clone(); - - { - let mut cooldown_active = false; - if let Some(cooldown) = held.get_use_cooldown() { - let group = cooldown - .cooldown_group - .clone() - .unwrap_or_else(|| held.item.registry_key.to_string()); - if player.is_on_cooldown(&group).await { - cooldown_active = true; - } - } - - if !cooldown_active { - if held.get_data_component::().is_some() - || held.get_data_component::().is_some() - { - if let Some(food) = held.get_data_component::() { - if player.abilities.lock().await.invulnerable - || food.can_always_eat - || player.hunger_manager.level.load() < 20 - { - player - .living_entity - .set_active_hand( - Hand::Left, - held.clone(), - held.get_max_use_time(), - ) - .await; - } - } else { - player - .living_entity - .set_active_hand( - Hand::Left, - held.clone(), - held.get_max_use_time(), - ) - .await; - } - } - if let Some(equippable) = held.get_data_component::() { - let inventory = player.inventory(); - let mut equipment_guard = inventory.entity_equipment.lock().await; - let current_equipped = equipment_guard.get(equippable.slot); - if !current_equipped.are_items_and_components_equal(&held) { - player - .enqueue_equipment_change(equippable.slot, &held) - .await; - - let equip_item = equipment_guard - .equipment - .entry(equippable.slot.clone()) - .or_insert_with(|| ItemStack::EMPTY.clone()); - if equip_item.is_empty() { - *equip_item = held.clone(); - held.decrement_unless_creative(player.gamemode.load(), 1); - } else { - let old_held = held.clone(); - held = equip_item.clone(); - *equip_item = old_held; - } - player.inventory().set_held_item(held.clone()).await; - } - } - } - } - - send_cancellable! {{ - &server; - event; - 'after: { - server.item_registry.on_use(&stack_for_use, player).await; - } - }} - } - } - TransactionData::UseItemOnEntity(data) => { - let target_runtime_id = data.target_entity_runtime_id.0 as i32; - // TODO: replace with consts, i'm too lazy - match data.action_type.0 { - // Interact / Item Interact - 0 | 2 => { - let world = player.world(); - if let Some(target) = world.get_entity_by_id(target_runtime_id) { - let mut stack = player.inventory().held_item().await; - if !target.interact(player, &mut stack).await { - let Some(server) = world.server.upgrade() else { - return; - }; - server - .item_registry - .use_on_entity(&mut stack, player, target) - .await; - player.inventory().set_held_item(stack).await; - } - } - } - // Attack - 1 => { - let world = player.world(); - if let Some(target) = world.get_entity_by_id(target_runtime_id) { - player.attack(target).await; - } - } - _ => { - tracing::warn!( - "invalid UseItemOnEntity action type {}", - data.action_type.0 - ); - // Kick? - } - } - } - TransactionData::ReleaseItem(_data) => { - let item_in_use = player.living_entity.item_in_use.lock().await.clone(); - if let Some(stack) = item_in_use { - let Some(server) = player.world().server.upgrade() else { - return; - }; - server.item_registry.on_stopped_using(&stack, player).await; - } - player.living_entity.clear_active_hand().await; - } - } - - if packet.legacy_request_id.0 != 0 { - use pumpkin_protocol::bedrock::client::item_stack_response::{ - CItemStackResponse, ItemStackResponse, ItemStackResponseContainerInfo, - ItemStackResponseSlotInfo, - }; - - let mut container_infos = Vec::new(); - if result == 0 { - for update in updates { - let container_info = container_infos.iter_mut().find( - |info: &&mut ItemStackResponseContainerInfo| { - info.container_name == update.container_name - }, - ); - - let slot_info = ItemStackResponseSlotInfo { - slot: update.slot_id, - hotbar_slot: update.slot_id, - count: update.count, - item_stack_id: update.stack_id, - custom_name: String::new(), - filtered_custom_name: String::new(), - durability_correction: VarInt(0), - }; - - if let Some(info) = container_info { - info.slots.push(slot_info); - } else { - container_infos.push(ItemStackResponseContainerInfo { - container_name: update.container_name.clone(), - slots: vec![slot_info], - }); - } - } - } - - self.enqueue_packet(&CItemStackResponse { - responses: vec![ItemStackResponse { - result, - request_id: packet.legacy_request_id, - container_infos, - }], - }) - .await; - } - } - - pub async fn handle_interaction(&self, player: &Arc, packet: SInteraction) { - match packet.action { - Action::OpenInventory => { - if self.inventory_opened.load(Ordering::Relaxed) { - return; - } - self.inventory_opened.store(true, Ordering::Relaxed); - self.enqueue_packet(&CContainerOpen { - container_id: 0, - container_type: 0xff, - position: BlockPos::ZERO, - target_entity_id: VarLong(-1), - }) - .await; - } - // No longer used in newer versions - Action::Attack => { - let target_runtime_id = packet.target_runtime_id.0 as i32; - let world = player.world(); - if let Some(target) = world.get_entity_by_id(target_runtime_id) { - player.attack(target).await; - } - } - _ => {} - } - } - - pub async fn handle_container_close(&self, player: &Arc, packet: SContainerClose) { - if packet.container_id == 0 || packet.container_id == 0xff { - self.inventory_opened.store(false, Ordering::Relaxed); - } - player.on_handled_screen_closed().await; - - self.enqueue_packet(&SContainerClose { - container_id: packet.container_id, - container_type: packet.container_type, - server_initiated: false, - }) - .await; - - // Sync the cursor (make it empty) to Bedrock client - self.enqueue_packet(&CInventoryContent { - container_id: VarUInt(59), // Cursor container ID - slots: vec![NetworkItemStackDescriptor::default()], - full_container_name: FullContainerName { - container_name: ContainerName::Cursor, - dynamic_id: None, - }, - storage_item: NetworkItemStackDescriptor::default(), - }) - .await; - - // Sync the inventory content to Bedrock client - self.enqueue_packet(&CInventoryContent { - container_id: VarUInt(0), // player inventory - slots: player - .inventory() - .main_inventory - .read() - .await - .iter() - .map(NetworkItemStackDescriptor::from) - .collect(), - full_container_name: FullContainerName { - container_name: ContainerName::Inventory, - dynamic_id: None, - }, - storage_item: NetworkItemStackDescriptor::default(), - }) - .await; - } - - pub async fn handle_chat_message( - &self, - server: &Arc, - player: &Arc, - packet: SText<'_>, - ) { - let gameprofile = &player.gameprofile; - - send_cancellable! {{ - server; - PlayerChatEvent::new(player.clone(), packet.message.into_owned(), vec![]); - - 'after: { - info!(" {}: {}", gameprofile.name, event.message); - - let config = &server.advanced_config; - - let message = match seasonal_events::modify_chat_message(&event.message, config) { - Some(m) => m, - None => event.message.clone(), - }; - - let decorated_message = TextComponent::chat_decorated( - &config.chat.format, - &gameprofile.name, - &message, - ); - - let entity = &player.get_entity(); - if server.basic_config.allow_chat_reports { - //TODO Alex help, what is this? - //world.broadcast_secure_player_chat(player, &message, decorated_message).await; - } else { - let je_packet = CSystemChatMessage::new( - &decorated_message, - false, - ); - - let be_packet = SText::chat_with_xuid( - message, - gameprofile.name.clone(), - packet.xuid.into_owned(), - packet.filtered_message.map(std::borrow::Cow::into_owned), - ); - - entity.world.load().broadcast_editioned(&je_packet, &be_packet).await; - } - } - }} - } - - #[expect(clippy::match_same_arms)] - #[expect(clippy::too_many_lines)] - pub async fn handle_player_action( - &self, - player: &Arc, - server: &Server, - packet: SPlayerAction, - ) { - if !player.has_client_loaded() - || ((player.living_entity.dead.load(Ordering::Relaxed) - || player.living_entity.health.load() <= 0.0) - && !matches!(packet.action, PlayerAction::Respawn)) - { - return; - } - player.update_last_action_time(); - - match packet.action { - PlayerAction::StartBreak - | PlayerAction::CreativePlayerDestroyBlock - | PlayerAction::ContinueDestroyBlock => { - let location = packet.block_pos; - if !player.can_interact_with_block_at(&location, 1.0) { - return; - } - - let entity = &player.get_entity(); - let world = entity.world.load_full(); - let (block, state) = world.get_block_and_state(&location); - - if player.mining.load(Ordering::Relaxed) - && *player.mining_pos.lock().await != location - { - player.stop_mining().await; - } - - if player.gamemode.load() == GameMode::Creative { - let new_state = world - .break_block( - &location, - Some(player.clone()), - BlockFlags::NOTIFY_NEIGHBORS | BlockFlags::SKIP_DROPS, - ) - .await; - if new_state.is_some() { - server - .block_registry - .broken(&world, block, player, &location, server, state) - .await; - } - } else if !state.is_air() { - let speed = crate::block::calc_block_breaking(player, state, block).await; - if speed >= 1.0 { - player.stop_mining().await; - let broken_state = world.get_block_state(&location); - let can_harvest = player.can_harvest(broken_state, block).await; - let new_state = world - .break_block( - &location, - Some(player.clone()), - BlockFlags::NOTIFY_NEIGHBORS, - ) - .await; - if new_state.is_some() { - server - .block_registry - .broken(&world, block, player, &location, server, broken_state) - .await; - player.apply_tool_damage_for_block_break(broken_state).await; - if can_harvest { - player.add_exhaustion(MINE_BLOCK_EXHAUSTION).await; - } - } - } else { - let mut mining_pos = player.mining_pos.lock().await; - let starts_breaking = - !player.mining.load(Ordering::Relaxed) || *mining_pos != location; - let progress = if starts_breaking { - player.start_mining_time.store( - player.tick_counter.load(Ordering::Relaxed), - Ordering::Relaxed, - ); - player.mining.store(true, Ordering::Relaxed); - *mining_pos = location; - (speed * 10.0) as i32 - } else { - player.current_block_destroy_stage.load(Ordering::Relaxed) - }; - drop(mining_pos); - let old_speed = player - .current_block_breaking_speed - .swap(speed.to_bits(), Ordering::Relaxed); - if starts_breaking { - world - .set_block_breaking( - entity, - location, - BlockBreakingProgress::Start { - stage: progress, - speed, - }, - ) - .await; - player - .current_block_destroy_stage - .store(progress, Ordering::Relaxed); - } else if old_speed != speed.to_bits() { - world - .set_block_breaking( - entity, - location, - BlockBreakingProgress::Update { - stage: progress, - speed: Some(speed), - }, - ) - .await; - } - } - } - } - action @ (PlayerAction::PredictDestroyBlock | PlayerAction::StopBreak) => { - let location = packet.block_pos; - if !player.can_interact_with_block_at(&location, 1.0) { - return; - } - - let entity = &player.get_entity(); - let world = entity.world.load_full(); - - let (block, state) = world.get_block_and_state(&location); - if player.gamemode.load() != GameMode::Creative && !state.is_air() { - let speed = crate::block::calc_block_breaking(player, state, block).await; - let elapsed = player.tick_counter.load(Ordering::Relaxed) - - player.start_mining_time.load(Ordering::Relaxed) - + 1; - let same_block = *player.mining_pos.lock().await == location; - if player.mining.load(Ordering::Relaxed) - && same_block - && speed * elapsed as f32 >= MIN_PREDICTED_BREAK_PROGRESS - { - player.stop_mining().await; - - let can_harvest = player.can_harvest(state, block).await; - let flags = if can_harvest { - BlockFlags::NOTIFY_NEIGHBORS - } else { - BlockFlags::SKIP_DROPS | BlockFlags::NOTIFY_NEIGHBORS - }; - if world - .break_block(&location, Some(player.clone()), flags) - .await - .is_some() - { - server - .block_registry - .broken(&world, block, player, &location, server, state) - .await; - player.apply_tool_damage_for_block_break(state).await; - if can_harvest { - player.add_exhaustion(MINE_BLOCK_EXHAUSTION).await; - } - } - } else { - let runtime_id = pumpkin_data::BlockState::to_be_network_id(state.id); - self.enqueue_packet(&CUpdateBlock::new(location, runtime_id as u32)) - .await; - if matches!(action, PlayerAction::StopBreak) { - player.stop_mining().await; - } else { - world - .set_block_breaking( - entity, - location, - BlockBreakingProgress::Update { - stage: player - .current_block_destroy_stage - .load(Ordering::Relaxed), - speed: Some(speed), - }, - ) - .await; - } - } - } else if matches!(action, PlayerAction::StopBreak) { - player.stop_mining().await; - } - } - PlayerAction::CrackBreak => { - // Don't do anything for this action. It is no longer used. Block - // cracking is done fully server-side. - } - PlayerAction::AbortBreak => { - player.stop_mining().await; - } - PlayerAction::DropItem => { - player.drop_held_item(false).await; - } - PlayerAction::Respawn - if player.living_entity.dead.load(Ordering::Relaxed) - || player.living_entity.health.load() <= 0.0 => - { - player.world().respawn_player(player, false).await; - } - // TODO - _ => {} - } - } - - pub async fn handle_respawn(&self, player: &Arc, packet: SRespawn) { - if packet.state != RespawnState::ClientReadyToSpawn - || (!player.living_entity.dead.load(Ordering::Relaxed) - && player.living_entity.health.load() > 0.0) - { - return; - } - - let entity = player.get_entity(); - let position = entity.pos.load(); - self.enqueue_packet(&CRespawn::new( - pumpkin_util::math::vector3::Vector3::new( - position.x as f32, - position.y as f32 + entity.entity_type.eye_height, - position.z as f32, - ), - RespawnState::ReadyToSpawn, - VarULong(player.entity_id() as u64), - )) - .await; - } - - pub async fn handle_chat_command( - &self, - player: &Arc, - server: &Arc, - packet: SCommandRequest<'_>, - ) { - let player_clone = player.clone(); - let server_clone = server.clone(); - let command = packet.command.strip_prefix('/').unwrap_or(&packet.command); - - send_cancellable! {{ - server; - PlayerCommandSendEvent { - player: player.clone(), - command: command.to_string(), - cancelled: false - }; - - 'after: { - let command = event.command; - let command_clone = command.clone(); - - // Some commands can take a long time to execute. If they do, they block packet processing for the player. - // That's why we will spawn a task instead. - server.spawn_task(async move { - let dispatcher = server_clone.command_dispatcher.read().await; - dispatcher.handle_command( - &player_clone.get_command_source(&server_clone).await, - &command_clone - ).await; - }); - - if server.advanced_config.commands.log_console { - info!( - "Player ({}): executed command /{}", - player.gameprofile.name, - command - ); - } - } - }} - } - - pub async fn handle_modal_form_response( - &self, - player: &Arc, - server: &Arc, - packet: pumpkin_protocol::bedrock::server::modal_form_response::SModalFormResponse<'_>, - ) { - let mut event = - crate::plugin::api::events::player::bedrock_form_response::BedrockFormResponseEvent::new( - player.clone(), - packet.form_id.0, - packet - .form_data - .filter(|data| data != "null") - .map(std::borrow::Cow::into_owned), - ); - server.plugin_manager.fire(server, &mut event).await; - } - - #[allow(clippy::too_many_lines)] - pub async fn handle_item_stack_request( - &self, - player: &Arc, - packet: pumpkin_protocol::bedrock::server::item_stack_request::SItemStackRequest, - ) { - use pumpkin_protocol::bedrock::client::item_stack_response::{ - CItemStackResponse, ItemStackResponse, ItemStackResponseContainerInfo, - ItemStackResponseSlotInfo, - }; - use pumpkin_protocol::bedrock::server::item_stack_request::ItemStackRequestAction; - - let current_screen_handler = player.current_screen_handler.lock().await.clone(); - let mut screen_handler = current_screen_handler.lock().await; - - let mut responses = Vec::with_capacity(packet.requests.len()); - - for request in packet.requests { - let mut created_item: Option = None; - let mut crafting_inputs_consumed = false; - let mut updates = Vec::new(); - let mut result = 0u8; // 0 = Success, 1 = Error - - for action in request.actions { - match action { - ItemStackRequestAction::CraftCreative { - creative_item_id, - repetitions, - } => { - let index = (creative_item_id.0.saturating_sub(1)) as usize; - if index < pumpkin_data::bedrock_creative::CREATIVE_ENTRIES.len() { - let entry = pumpkin_data::bedrock_creative::CREATIVE_ENTRIES[index]; - if let Some(mapping) = - pumpkin_data::item::JavaToBedrockItemMapping::from_bedrock( - entry.item_id, - entry.item_aux_value, - ) - { - // Bedrock `repetitions` represents how many stacks to create; use the item's max stack size - let max_stack = ItemStack::static_new_java(1, mapping.java_item) - .get_max_stack_size(); - let count = ((max_stack as u16) * (repetitions as u16)) - .min(u8::MAX as u16) - as u8; - created_item = Some(ItemStack::new(count, mapping.java_item)); - } else { - tracing::warn!( - "Failed to map bedrock item id {} and data {} to Java item", - entry.item_id, - entry.item_aux_value - ); - result = 1; - break; - } - } else { - tracing::warn!( - "Creative item index {} out of bounds (len: {})", - index, - pumpkin_data::bedrock_creative::CREATIVE_ENTRIES.len() - ); - result = 1; - break; - } - } - ItemStackRequestAction::Take { - count, - source, - destination, - } - | ItemStackRequestAction::Place { - count, - source, - destination, - } => { - let mut source_stack = - get_slot_stack(&*screen_handler, &source, created_item.as_ref()).await; - if source_stack.is_empty() && created_item.is_none() { - tracing::debug!("Source stack is empty in Take/Place"); - result = 1; - break; - } - let count = count.min(source_stack.item_count); - if count > 0 { - let mut dest_stack = get_slot_stack( - &*screen_handler, - &destination, - created_item.as_ref(), - ) - .await; - if dest_stack.is_empty() { - dest_stack = source_stack.copy_with_count(count); - } else if dest_stack.are_items_and_components_equal(&source_stack) { - dest_stack.item_count = dest_stack.item_count.saturating_add(count); - } else { - tracing::debug!( - "Destination stack is not compatible with source stack" - ); - result = 1; - break; - } - - source_stack.decrement(count); - if source.container_name.container_name == ContainerName::CreatedOutput - && let Some(ref mut stack) = created_item - { - stack.decrement(count); - if stack.is_empty() { - created_item = None; - } - } - let source_stack = if source_stack.is_empty() { - ItemStack::EMPTY.clone() - } else { - source_stack - }; - - update_slot_stack( - player, - &mut *screen_handler, - &source, - source_stack.clone(), - ) - .await; - update_slot_stack( - player, - &mut *screen_handler, - &destination, - dest_stack.clone(), - ) - .await; - - record_update( - &mut updates, - source.container_name.clone(), - source.slot_id, - &source_stack, - ); - record_update( - &mut updates, - destination.container_name.clone(), - destination.slot_id, - &dest_stack, - ); - } - } - ItemStackRequestAction::Swap { slot1, slot2 } => { - let stack1 = - get_slot_stack(&*screen_handler, &slot1, created_item.as_ref()).await; - let stack2 = - get_slot_stack(&*screen_handler, &slot2, created_item.as_ref()).await; - - update_slot_stack(player, &mut *screen_handler, &slot1, stack2.clone()) - .await; - update_slot_stack(player, &mut *screen_handler, &slot2, stack1.clone()) - .await; - - record_update( - &mut updates, - slot1.container_name.clone(), - slot1.slot_id, - &stack2, - ); - record_update( - &mut updates, - slot2.container_name.clone(), - slot2.slot_id, - &stack1, - ); - } - ItemStackRequestAction::Drop { - count, - source, - randomly: _, - } => { - let mut source_stack = - get_slot_stack(&*screen_handler, &source, created_item.as_ref()).await; - if source_stack.is_empty() { - result = 1; - break; - } - let count = count.min(source_stack.item_count); - if count > 0 { - let dropped_stack = source_stack.copy_with_count(count); - player.drop_item(dropped_stack).await; - - source_stack.decrement(count); - let source_stack = if source_stack.is_empty() { - ItemStack::EMPTY.clone() - } else { - source_stack - }; - - update_slot_stack( - player, - &mut *screen_handler, - &source, - source_stack.clone(), - ) - .await; - - record_update( - &mut updates, - source.container_name.clone(), - source.slot_id, - &source_stack, - ); - } - } - ItemStackRequestAction::Destroy { count, source } - | ItemStackRequestAction::Consume { count, source } => { - if crafting_inputs_consumed - && source.container_name.container_name == ContainerName::CraftingInput - { - let source_stack = - get_slot_stack(&*screen_handler, &source, created_item.as_ref()) - .await; - record_update( - &mut updates, - source.container_name.clone(), - source.slot_id, - &source_stack, - ); - continue; - } - - let mut source_stack = - get_slot_stack(&*screen_handler, &source, created_item.as_ref()).await; - if source_stack.is_empty() { - result = 1; - break; - } - let count = count.min(source_stack.item_count); - if count > 0 { - source_stack.decrement(count); - let source_stack = if source_stack.is_empty() { - ItemStack::EMPTY.clone() - } else { - source_stack - }; - - update_slot_stack( - player, - &mut *screen_handler, - &source, - source_stack.clone(), - ) - .await; - - record_update( - &mut updates, - source.container_name.clone(), - source.slot_id, - &source_stack, - ); - } - } - ItemStackRequestAction::CraftRecipe { - recipe_id: _, - repetitions, - } - | ItemStackRequestAction::CraftRecipeAuto { - recipe_id: _, - repetitions, - .. - } => { - if repetitions > 0 { - screen_handler.update_to_client().await; - - let is_player = screen_handler.window_type().is_none(); - let grid_size = if is_player { 4 } else { 9 }; - let bedrock_grid_start = if is_player { 28 } else { 32 }; - for i in 0..grid_size { - let grid_slot_index = 1 + i; - let grid_slot = - screen_handler.get_behaviour().slots[grid_slot_index].clone(); - let grid_stack = grid_slot.get_cloned_stack().await; - tracing::info!( - "Crafting Grid slot {i} (slot index {grid_slot_index}): Item ID: {}, Count: {}", - grid_stack.item.id, - grid_stack.item_count - ); - } - - let output_slot = screen_handler.get_behaviour().slots[0].clone(); - let output_stack = output_slot.get_cloned_stack().await; - - if output_stack.is_empty() - || repetitions > output_slot.get_max_item_count().await - { - tracing::warn!("Client sent an invalid crafting request"); - result = 1; - break; - } - - let mut total_crafted = output_stack.clone(); - total_crafted.item_count = - total_crafted.item_count.saturating_mul(repetitions); - created_item = Some(total_crafted); - - for _ in 0..repetitions { - output_slot - .on_take_item(player.as_ref(), &output_stack) - .await; - } - crafting_inputs_consumed = true; - - // Record updates for all grid slots so Bedrock client is notified of consumed ingredients! - let is_player = screen_handler.window_type().is_none(); - let grid_size = if is_player { 4 } else { 9 }; - for i in 0..grid_size { - let grid_slot_index = 1 + i; - let grid_slot = - screen_handler.get_behaviour().slots[grid_slot_index].clone(); - let grid_stack = grid_slot.get_cloned_stack().await; - record_update( - &mut updates, - FullContainerName { - container_name: ContainerName::CraftingInput, - dynamic_id: None, - }, - (bedrock_grid_start + i) as u8, - &grid_stack, - ); - } - } - } - ItemStackRequestAction::CraftResultsDeprecated { .. } - | ItemStackRequestAction::MineBlock { .. } - | ItemStackRequestAction::BeaconPayment { .. } - | ItemStackRequestAction::Create { .. } - | ItemStackRequestAction::LabTableCombine - | ItemStackRequestAction::Optional { .. } - | ItemStackRequestAction::Grindstone { .. } - | ItemStackRequestAction::Loom { .. } - | ItemStackRequestAction::CraftNonImplemented => { - // Successful no-ops to prevent client-side transaction rollbacks - } - } - } - - let mut container_infos = Vec::new(); - if result == 0 { - for update in updates { - let container_info = container_infos.iter_mut().find( - |info: &&mut ItemStackResponseContainerInfo| { - info.container_name == update.container_name - }, - ); - - let slot_info = ItemStackResponseSlotInfo { - slot: update.slot_id, - hotbar_slot: update.slot_id, - count: update.count, - item_stack_id: update.stack_id, - custom_name: String::new(), - filtered_custom_name: String::new(), - durability_correction: VarInt(0), - }; - - if let Some(info) = container_info { - info.slots.push(slot_info); - } else { - container_infos.push(ItemStackResponseContainerInfo { - container_name: update.container_name, - slots: vec![slot_info], - }); - } - } - } - - responses.push(ItemStackResponse { - result, - request_id: request.request_id, - container_infos, - }); - } - - // Send updates to Java client - screen_handler.send_content_updates().await; - - // Collect inventory updates if we modified player inventory - let mut inventory_updated = false; - for response in &responses { - if response.result == 0 { - for info in &response.container_infos { - if info.container_name.container_name == ContainerName::Inventory - || info.container_name.container_name - == ContainerName::CombinedHotBarAndInventory - || info.container_name.container_name == ContainerName::HotBar - { - inventory_updated = true; - } - } - } - } - - // Send Bedrock specific responses and updates - self.enqueue_packet(&CItemStackResponse { responses }).await; - - if inventory_updated { - self.enqueue_packet(&CInventoryContent { - container_id: VarUInt(0), - slots: player - .inventory() - .main_inventory - .read() - .await - .iter() - .map(NetworkItemStackDescriptor::from) - .collect(), - full_container_name: FullContainerName { - container_name: ContainerName::Inventory, - dynamic_id: None, - }, - storage_item: NetworkItemStackDescriptor::default(), - }) - .await; - } - } - - #[allow(clippy::too_many_lines)] - pub async fn handle_block_pick_request(&self, player: &Arc, packet: SBlockPickRequest) { - if !player.can_interact_with_block_at(&packet.block_pos, 1.0) { - return; - } - - let world = player.world(); - let block = world.get_block(&packet.block_pos); - - if block.item_id == 0 { - return; - } - - let Some(item) = pumpkin_data::item::Item::from_id(block.item_id) else { - return; - }; - let stack = ItemStack::new(1, item); - - let target_hotbar_slot = packet.hotbar_slot as usize; - if target_hotbar_slot >= 9 { - return; - } - - let slot_with_stack = player.inventory().get_slot_with_stack(&stack).await; - - if slot_with_stack != -1 { - if pumpkin_inventory::player::player_inventory::PlayerInventory::is_valid_hotbar_index( - slot_with_stack as usize, - ) { - if slot_with_stack as usize != target_hotbar_slot { - let target_stack = player.inventory().get_stack(target_hotbar_slot).await; - let source_stack = player.inventory().get_stack(slot_with_stack as usize).await; - player - .inventory() - .set_stack(target_hotbar_slot, source_stack) - .await; - player - .inventory() - .set_stack(slot_with_stack as usize, target_stack) - .await; - } - } else { - let target_stack = player.inventory().get_stack(target_hotbar_slot).await; - let source_stack = player.inventory().get_stack(slot_with_stack as usize).await; - player - .inventory() - .set_stack(target_hotbar_slot, source_stack) - .await; - player - .inventory - .set_stack(slot_with_stack as usize, target_stack) - .await; - } - } else if player.gamemode.load() == GameMode::Creative { - player.inventory.set_stack(target_hotbar_slot, stack).await; - } else { - return; - } - - player.inventory.set_selected_slot(target_hotbar_slot as u8); - - // Send hotbar updates - player - .client - .enqueue_packet_editioned( - &CSetSelectedSlot::new(player.inventory.get_selected_slot() as i8), - &CPlayerHotbar { - selected_slot: VarUInt(player.inventory.get_selected_slot() as u32), - container_id: 0, - should_select_block: true, - }, - ) - .await; - - // Send screen handler / Java inventory updates - player - .player_screen_handler - .lock() - .await - .send_content_updates() - .await; - - // Sync main hand equipment to other players - let stack_in_hand = player.inventory().held_item().await; - let equipment = &[(EquipmentSlot::MAIN_HAND, stack_in_hand)]; - player.living_entity.send_equipment_changes(equipment); - - // Sync bedrock inventory updates - self.enqueue_packet(&CInventoryContent { - container_id: VarUInt(0), - slots: player - .inventory() - .main_inventory - .read() - .await - .iter() - .map(NetworkItemStackDescriptor::from) - .collect(), - full_container_name: FullContainerName { - container_name: ContainerName::Inventory, - dynamic_id: None, - }, - storage_item: NetworkItemStackDescriptor::default(), - }) - .await; - } - - pub async fn handle_mob_equipment( - &self, - _server: &Arc, - player: &Arc, - packet: SMobEquipment, - ) { - player.update_last_action_time(); - let slot = packet.hotbar_slot; - if slot >= 9 { - return; - } - let previous_slot = player.inventory.get_selected_slot(); - let server = player.world().server.upgrade(); - if let Some(server) = server { - let mut event = PlayerItemHeldEvent::new(player.clone(), previous_slot, slot); - server.plugin_manager.fire(&server, &mut event).await; - let is_cancelled = { - use crate::plugin::Cancellable; - event.cancelled() - }; - if is_cancelled { - self.enqueue_packet(&CPlayerHotbar { - selected_slot: VarUInt(previous_slot as u32), - container_id: 0, - should_select_block: true, - }) - .await; - return; - } - } - - let inv = player.inventory(); - inv.set_selected_slot(slot); - let stack = inv.held_item().await; - let equipment = &[(EquipmentSlot::MAIN_HAND, stack)]; - player.living_entity.send_equipment_changes(equipment); - } - - pub async fn handle_request_ability( - &self, - player: &Arc, - packet: pumpkin_protocol::bedrock::server::request_ability::SRequestAbility, - ) { - player.update_last_action_time(); - let ability_id = packet.ability.0; - match ability_id { - 9 => { - // Flying - if let pumpkin_protocol::bedrock::server::request_ability::AbilityValue::Bool( - requested_flying, - ) = packet.value - { - let mut abilities = player.abilities.lock().await; - if abilities.allow_flying { - abilities.flying = requested_flying; - } else { - abilities.flying = false; - } - drop(abilities); - player.send_abilities_update().await; - } - } - _ => { - debug!("Received RequestAbility packet for unhandled ability {ability_id}"); - } - } - } -} - -#[allow(clippy::too_many_lines)] -fn map_bedrock_container_slot( - screen_handler: &dyn ScreenHandler, - container_name: ContainerName, - slot_id: u8, -) -> Option { - let is_player_screen = screen_handler.window_type().is_none(); - let container_slots = screen_handler - .get_behaviour() - .slots - .len() - .saturating_sub(PlayerInventory::MAIN_SIZE); - - match container_name { - ContainerName::HotBar => { - if is_player_screen { - Some(36 + slot_id as usize) - } else { - Some(container_slots + 27 + slot_id as usize) - } - } - ContainerName::Inventory | ContainerName::CombinedHotBarAndInventory => { - if slot_id < 9 { - if is_player_screen { - Some(36 + slot_id as usize) - } else { - Some(container_slots + 27 + slot_id as usize) - } - } else if slot_id < 36 { - if is_player_screen { - Some(slot_id as usize) - } else { - Some(container_slots + (slot_id - 9) as usize) - } - } else { - None - } - } - ContainerName::Armor => (slot_id < 4).then(|| 5 + slot_id as usize), - ContainerName::Offhand => (slot_id == 0).then_some(45), - ContainerName::Cursor => None, - ContainerName::CraftingInput => { - if is_player_screen { - if slot_id < 4 { - Some(1 + slot_id as usize) - } else if (28..32).contains(&slot_id) { - Some(1 + (slot_id - 28) as usize) - } else { - None - } - } else if screen_handler.window_type() - == Some(pumpkin_data::screen::WindowType::Crafting) - { - if slot_id < 9 { - Some(1 + slot_id as usize) - } else if (32..41).contains(&slot_id) { - Some(1 + (slot_id - 32) as usize) - } else { - None - } - } else { - None - } - } - ContainerName::CraftingOutputPreview | ContainerName::CreatedOutput => { - if is_player_screen { - Some(0) - } else if let Some(window_type) = screen_handler.window_type() { - match window_type { - pumpkin_data::screen::WindowType::Crafting => Some(0), - pumpkin_data::screen::WindowType::Stonecutter => Some(1), - pumpkin_data::screen::WindowType::Anvil - | pumpkin_data::screen::WindowType::Furnace - | pumpkin_data::screen::WindowType::BlastFurnace - | pumpkin_data::screen::WindowType::Smoker - | pumpkin_data::screen::WindowType::Grindstone - | pumpkin_data::screen::WindowType::Merchant => Some(2), - pumpkin_data::screen::WindowType::Loom - | pumpkin_data::screen::WindowType::Smithing => Some(3), - _ => None, - } - } else { - None - } - } - ContainerName::AnvilInput => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Anvil) - ) - .then_some(0), - ContainerName::AnvilMaterial => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Anvil) - ) - .then_some(1), - ContainerName::AnvilResultPreview => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Anvil) - ) - .then_some(2), - ContainerName::BeaconPayment => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Beacon) - ) - .then_some(0), - ContainerName::BrewingStandResult => (matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::BrewingStand) - ) && slot_id < 3) - .then_some(slot_id as usize), - ContainerName::BrewingStandInput => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::BrewingStand) - ) - .then_some(3), - ContainerName::BrewingStandFuel => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::BrewingStand) - ) - .then_some(4), - ContainerName::FurnaceIngredient - | ContainerName::BlastFurnaceIngredient - | ContainerName::SmokerIngredient => matches!( - screen_handler.window_type(), - Some( - pumpkin_data::screen::WindowType::Furnace - | pumpkin_data::screen::WindowType::BlastFurnace - | pumpkin_data::screen::WindowType::Smoker - ) - ) - .then_some(0), - ContainerName::FurnaceFuel => matches!( - screen_handler.window_type(), - Some( - pumpkin_data::screen::WindowType::Furnace - | pumpkin_data::screen::WindowType::BlastFurnace - | pumpkin_data::screen::WindowType::Smoker - ) - ) - .then_some(1), - ContainerName::FurnaceResult => matches!( - screen_handler.window_type(), - Some( - pumpkin_data::screen::WindowType::Furnace - | pumpkin_data::screen::WindowType::BlastFurnace - | pumpkin_data::screen::WindowType::Smoker - ) - ) - .then_some(2), - ContainerName::EnchantingInput => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Enchantment) - ) - .then_some(0), - ContainerName::EnchantingMaterial => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Enchantment) - ) - .then_some(1), - ContainerName::GrindstoneInput => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Grindstone) - ) - .then_some(0), - ContainerName::GrindstoneAdditional => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Grindstone) - ) - .then_some(1), - ContainerName::GrindstoneResultPreview => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Grindstone) - ) - .then_some(2), - ContainerName::LoomInput => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Loom) - ) - .then_some(0), - ContainerName::LoomDye => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Loom) - ) - .then_some(1), - ContainerName::LoomMaterial => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Loom) - ) - .then_some(2), - ContainerName::LoomResultPreview => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Loom) - ) - .then_some(3), - ContainerName::StonecutterInput => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Stonecutter) - ) - .then_some(0), - ContainerName::StonecutterResultPreview => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Stonecutter) - ) - .then_some(1), - ContainerName::CartographyInput => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::CartographyTable) - ) - .then_some(0), - ContainerName::CartographyAdditional => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::CartographyTable) - ) - .then_some(1), - ContainerName::CartographyResultPreview => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::CartographyTable) - ) - .then_some(2), - ContainerName::SmithingTableTemplate => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Smithing) - ) - .then_some(0), - ContainerName::SmithingTableInput => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Smithing) - ) - .then_some(1), - ContainerName::SmithingTableMaterial => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Smithing) - ) - .then_some(2), - ContainerName::SmithingTableResultPreview => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Smithing) - ) - .then_some(3), - ContainerName::TradeIngredient1 | ContainerName::Trade2Ingredient1 => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Merchant) - ) - .then_some(0), - ContainerName::TradeIngredient2 | ContainerName::Trade2Ingredient2 => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Merchant) - ) - .then_some(1), - ContainerName::TradeResultPreview | ContainerName::Trade2ResultPreview => matches!( - screen_handler.window_type(), - Some(pumpkin_data::screen::WindowType::Merchant) - ) - .then_some(2), - _ => ((slot_id as usize) < container_slots).then_some(slot_id as usize), - } -} - -struct SlotUpdate { - container_name: FullContainerName, - slot_id: u8, - count: u8, - stack_id: VarInt, -} - -fn record_update( - updates: &mut Vec, - container_name: FullContainerName, - slot_id: u8, - stack: &ItemStack, -) { - let count = stack.item_count; - let stack_id = if stack.is_empty() { - VarInt(0) - } else { - VarInt(stack.uid.get()) - }; - if let Some(existing) = updates - .iter_mut() - .find(|u| u.container_name == container_name && u.slot_id == slot_id) - { - existing.count = count; - existing.stack_id = stack_id; - } else { - updates.push(SlotUpdate { - container_name, - slot_id, - count, - stack_id, - }); - } -} - -async fn get_slot_stack( - screen_handler: &dyn ScreenHandler, - slot_info: &pumpkin_protocol::bedrock::server::item_stack_request::ItemStackRequestSlotInfo, - created_item: Option<&ItemStack>, -) -> ItemStack { - if let (ContainerName::CreatedOutput, Some(stack)) = - (slot_info.container_name.container_name, created_item) - { - return stack.clone(); - } - if slot_info.container_name.container_name == ContainerName::Cursor { - return screen_handler - .get_behaviour() - .cursor_stack - .lock() - .await - .clone(); - } - if let Some(screen_slot) = map_bedrock_container_slot( - screen_handler, - slot_info.container_name.container_name, - slot_info.slot_id, - ) { - screen_handler.get_behaviour().slots[screen_slot] - .get_cloned_stack() - .await - } else { - ItemStack::EMPTY.clone() - } -} - -#[allow(clippy::unreachable)] -async fn update_slot_stack( - player: &Player, - screen_handler: &mut dyn ScreenHandler, - slot_info: &pumpkin_protocol::bedrock::server::item_stack_request::ItemStackRequestSlotInfo, - new_stack: ItemStack, -) { - if slot_info.container_name.container_name == ContainerName::Cursor { - let mut cursor_lock = screen_handler.get_behaviour().cursor_stack.lock().await; - *cursor_lock = new_stack; - return; - } - if let Some(screen_slot) = map_bedrock_container_slot( - screen_handler, - slot_info.container_name.container_name, - slot_info.slot_id, - ) { - let is_player_screen = screen_handler.window_type().is_none(); - if is_player_screen { - let current_stack = screen_handler.get_behaviour().slots[screen_slot] - .get_cloned_stack() - .await; - if !current_stack.are_items_and_components_equal(&new_stack) { - if (5..9).contains(&screen_slot) { - player - .enqueue_equipment_change( - &match screen_slot { - 5 => EquipmentSlot::HEAD, - 6 => EquipmentSlot::CHEST, - 7 => EquipmentSlot::LEGS, - 8 => EquipmentSlot::FEET, - _ => unreachable!(), - }, - &new_stack, - ) - .await; - } else if (36..45).contains(&screen_slot) { - let hotbar_slot = screen_slot - 36; - if player.inventory().get_selected_slot() == hotbar_slot as u8 { - let equipment = &[(EquipmentSlot::MAIN_HAND, new_stack.clone())]; - player.living_entity.send_equipment_changes(equipment); - } - } - } - } - - screen_handler.get_behaviour().slots[screen_slot] - .set_stack(new_stack.clone()) - .await; - screen_handler.set_received_stack(screen_slot, new_stack); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pumpkin_data::item::Item; - use pumpkin_inventory::{ - build_equipment_slots, crafting::crafting_screen_handler::CraftingTableScreenHandler, - entity_equipment::EntityEquipment, - }; - use tokio::sync::Mutex; - - #[tokio::test] - async fn crafting_table_maps_bedrock_player_inventory_after_its_ten_slots() { - let inventory = Arc::new(PlayerInventory::new( - Arc::new(Mutex::new(EntityEquipment::new())), - Arc::new(build_equipment_slots()), - )); - let handler = CraftingTableScreenHandler::new(1, &inventory, None).await; - - assert_eq!( - map_bedrock_container_slot(&handler, ContainerName::Inventory, 26), - Some(27) - ); - assert_eq!( - map_bedrock_container_slot(&handler, ContainerName::HotBar, 0), - Some(37) - ); - assert_eq!( - map_bedrock_container_slot(&handler, ContainerName::CraftingInput, 32), - Some(1) - ); - assert_eq!( - map_bedrock_container_slot(&handler, ContainerName::CraftingInput, 40), - Some(9) - ); - } - - #[test] - fn item_stack_response_uses_the_authoritative_stack_id() { - let stack = ItemStack::new(3, &Item::SPRUCE_DOOR); - let container = FullContainerName { - container_name: ContainerName::Cursor, - dynamic_id: None, - }; - let mut updates = Vec::new(); - - record_update(&mut updates, container.clone(), 0, &stack); - assert_eq!(updates[0].count, 3); - assert_eq!(updates[0].stack_id, VarInt(stack.uid.get())); - - record_update(&mut updates, container, 0, ItemStack::EMPTY); - assert_eq!(updates[0].count, 0); - assert_eq!(updates[0].stack_id, VarInt(0)); - } -} diff --git a/crates/pumpkin/src/net/bedrock/play/animate.rs b/crates/pumpkin/src/net/bedrock/play/animate.rs new file mode 100644 index 000000000..039e5c725 --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/animate.rs @@ -0,0 +1,32 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + pub async fn handle_animate(&self, player: &Arc, _server: &Server, packet: &SAnimate) { + if !player.has_client_loaded() { + return; + } + + let entity = &player.get_entity(); + let world = entity.world.load(); + + let java_animation = match packet.action { + AnimateAction::NoAction => None, + AnimateAction::SwingArm => Some(Animation::SwingMainArm), + AnimateAction::WakeUp => Some(Animation::LeaveBed), + AnimateAction::CriticalHit => Some(Animation::CriticalEffect), + AnimateAction::MagicCriticalHit => Some(Animation::MagicCriticaleffect), + }; + + if let Some(animation) = java_animation { + let je_packet = CEntityAnimation::new(VarInt(entity.entity_id), animation); + let be_packet = SAnimate { + action: packet.action, + runtime_entity_id: VarULong(entity.entity_id as u64), + data: 0.0, + swing_source: None, + }; + world.broadcast_editioned(&je_packet, &be_packet).await; + } + } +} diff --git a/crates/pumpkin/src/net/bedrock/play/block_pick_request.rs b/crates/pumpkin/src/net/bedrock/play/block_pick_request.rs new file mode 100644 index 000000000..ef9dca8dc --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/block_pick_request.rs @@ -0,0 +1,111 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + #[allow(clippy::too_many_lines)] + pub async fn handle_block_pick_request(&self, player: &Arc, packet: SBlockPickRequest) { + if !player.can_interact_with_block_at(&packet.block_pos, 1.0) { + return; + } + + let world = player.world(); + let block = world.get_block(&packet.block_pos); + + if block.item_id == 0 { + return; + } + + let Some(item) = pumpkin_data::item::Item::from_id(block.item_id) else { + return; + }; + let stack = ItemStack::new(1, item); + + let target_hotbar_slot = packet.hotbar_slot as usize; + if target_hotbar_slot >= 9 { + return; + } + + let slot_with_stack = player.inventory().get_slot_with_stack(&stack).await; + + if slot_with_stack != -1 { + if pumpkin_inventory::player::player_inventory::PlayerInventory::is_valid_hotbar_index( + slot_with_stack as usize, + ) { + if slot_with_stack as usize != target_hotbar_slot { + let target_stack = player.inventory().get_stack(target_hotbar_slot).await; + let source_stack = player.inventory().get_stack(slot_with_stack as usize).await; + player + .inventory() + .set_stack(target_hotbar_slot, source_stack) + .await; + player + .inventory() + .set_stack(slot_with_stack as usize, target_stack) + .await; + } + } else { + let target_stack = player.inventory().get_stack(target_hotbar_slot).await; + let source_stack = player.inventory().get_stack(slot_with_stack as usize).await; + player + .inventory() + .set_stack(target_hotbar_slot, source_stack) + .await; + player + .inventory + .set_stack(slot_with_stack as usize, target_stack) + .await; + } + } else if player.gamemode.load() == GameMode::Creative { + player.inventory.set_stack(target_hotbar_slot, stack).await; + } else { + return; + } + + player.inventory.set_selected_slot(target_hotbar_slot as u8); + + // Send hotbar updates + player + .client + .enqueue_packet_editioned( + &CSetSelectedSlot::new(player.inventory.get_selected_slot() as i8), + &CPlayerHotbar { + selected_slot: VarUInt(player.inventory.get_selected_slot() as u32), + container_id: 0, + should_select_block: true, + }, + ) + .await; + + // Send screen handler / Java inventory updates + player + .player_screen_handler + .lock() + .await + .send_content_updates() + .await; + + // Sync main hand equipment to other players + let stack_in_hand = player.inventory().held_item().await; + let equipment = &[(EquipmentSlot::MAIN_HAND, stack_in_hand)]; + player.living_entity.send_equipment_changes(equipment); + + // Sync bedrock inventory updates + self.enqueue_packet(&CInventoryContent { + container_id: VarUInt(0), + slots: player + .inventory() + .main_inventory + .read() + .await + .iter() + .map(NetworkItemStackDescriptor::from) + .collect(), + full_container_name: FullContainerName { + container_name: ContainerName::Inventory, + dynamic_id: None, + }, + storage_item: NetworkItemStackDescriptor::default(), + }) + .await; + } +} diff --git a/crates/pumpkin/src/net/bedrock/play/chat_command.rs b/crates/pumpkin/src/net/bedrock/play/chat_command.rs new file mode 100644 index 000000000..961927cae --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/chat_command.rs @@ -0,0 +1,47 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + pub async fn handle_chat_command( + &self, + player: &Arc, + server: &Arc, + packet: SCommandRequest<'_>, + ) { + let player_clone = player.clone(); + let server_clone = server.clone(); + let command = packet.command.strip_prefix('/').unwrap_or(&packet.command); + + send_cancellable! {{ + server; + PlayerCommandSendEvent { + player: player.clone(), + command: command.to_string(), + cancelled: false + }; + + 'after: { + let command = event.command; + let command_clone = command.clone(); + + // Some commands can take a long time to execute. If they do, they block packet processing for the player. + // That's why we will spawn a task instead. + server.spawn_task(async move { + let dispatcher = server_clone.command_dispatcher.read().await; + dispatcher.handle_command( + &player_clone.get_command_source(&server_clone).await, + &command_clone + ).await; + }); + + if server.advanced_config.commands.log_console { + info!( + "Player ({}): executed command /{}", + player.gameprofile.name, + command + ); + } + } + }} + } +} diff --git a/crates/pumpkin/src/net/bedrock/play/chat_message.rs b/crates/pumpkin/src/net/bedrock/play/chat_message.rs new file mode 100644 index 000000000..f209ca581 --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/chat_message.rs @@ -0,0 +1,55 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + pub async fn handle_chat_message( + &self, + server: &Arc, + player: &Arc, + packet: SText<'_>, + ) { + let gameprofile = &player.gameprofile; + + send_cancellable! {{ + server; + PlayerChatEvent::new(player.clone(), packet.message.into_owned(), vec![]); + + 'after: { + info!(" {}: {}", gameprofile.name, event.message); + + let config = &server.advanced_config; + + let message = match seasonal_events::modify_chat_message(&event.message, config) { + Some(m) => m, + None => event.message.clone(), + }; + + let decorated_message = TextComponent::chat_decorated( + &config.chat.format, + &gameprofile.name, + &message, + ); + + let entity = &player.get_entity(); + if server.basic_config.allow_chat_reports { + //TODO Alex help, what is this? + //world.broadcast_secure_player_chat(player, &message, decorated_message).await; + } else { + let je_packet = CSystemChatMessage::new( + &decorated_message, + false, + ); + + let be_packet = SText::chat_with_xuid( + message, + gameprofile.name.clone(), + packet.xuid.into_owned(), + packet.filtered_message.map(std::borrow::Cow::into_owned), + ); + + entity.world.load().broadcast_editioned(&je_packet, &be_packet).await; + } + } + }} + } +} diff --git a/crates/pumpkin/src/net/bedrock/play/container_close.rs b/crates/pumpkin/src/net/bedrock/play/container_close.rs new file mode 100644 index 000000000..dece7395c --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/container_close.rs @@ -0,0 +1,49 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + pub async fn handle_container_close(&self, player: &Arc, packet: SContainerClose) { + if packet.container_id == 0 || packet.container_id == 0xff { + self.inventory_opened.store(false, Ordering::Relaxed); + } + player.on_handled_screen_closed().await; + + self.enqueue_packet(&SContainerClose { + container_id: packet.container_id, + container_type: packet.container_type, + server_initiated: false, + }) + .await; + + // Sync the cursor (make it empty) to Bedrock client + self.enqueue_packet(&CInventoryContent { + container_id: VarUInt(59), // Cursor container ID + slots: vec![NetworkItemStackDescriptor::default()], + full_container_name: FullContainerName { + container_name: ContainerName::Cursor, + dynamic_id: None, + }, + storage_item: NetworkItemStackDescriptor::default(), + }) + .await; + + // Sync the inventory content to Bedrock client + self.enqueue_packet(&CInventoryContent { + container_id: VarUInt(0), // player inventory + slots: player + .inventory() + .main_inventory + .read() + .await + .iter() + .map(NetworkItemStackDescriptor::from) + .collect(), + full_container_name: FullContainerName { + container_name: ContainerName::Inventory, + dynamic_id: None, + }, + storage_item: NetworkItemStackDescriptor::default(), + }) + .await; + } +} diff --git a/crates/pumpkin/src/net/bedrock/play/emote.rs b/crates/pumpkin/src/net/bedrock/play/emote.rs new file mode 100644 index 000000000..351e322cb --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/emote.rs @@ -0,0 +1,34 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + pub async fn handle_emote(&self, player: &Arc, _server: &Server, packet: SEmote<'_>) { + if !player.has_client_loaded() { + return; + } + + tracing::info!( + "handle_emote: player={} packet={:?}", + player.gameprofile.name, + packet + ); + + let entity = &player.living_entity.entity; + let world = entity.world.load(); + + let mut broadcast_packet = packet; + broadcast_packet.runtime_entity_id = VarULong(entity.entity_id as u64); + broadcast_packet.flags |= pumpkin_protocol::bedrock::server::emote::EMOTE_FLAG_SERVER_SIDE; + + world + .broadcast_packet_except_editioned( + &[player.gameprofile.id], + &CEntityAnimation::new( + VarInt(entity.entity_id), + Animation::SwingMainArm, // Fallback for Java? Or just ignore + ), + &broadcast_packet, + ) + .await; + } +} diff --git a/crates/pumpkin/src/net/bedrock/play/emote_list.rs b/crates/pumpkin/src/net/bedrock/play/emote_list.rs new file mode 100644 index 000000000..b304567b5 --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/emote_list.rs @@ -0,0 +1,12 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + pub fn handle_emote_list(&self, player: &Arc, _server: &Server, packet: &SEmoteList) { + tracing::info!( + "handle_emote_list: player={} packet={:?}", + player.gameprofile.name, + packet + ); + } +} diff --git a/crates/pumpkin/src/net/bedrock/play/interaction.rs b/crates/pumpkin/src/net/bedrock/play/interaction.rs new file mode 100644 index 000000000..b4b808ceb --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/interaction.rs @@ -0,0 +1,31 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + pub async fn handle_interaction(&self, player: &Arc, packet: SInteraction) { + match packet.action { + Action::OpenInventory => { + if self.inventory_opened.load(Ordering::Relaxed) { + return; + } + self.inventory_opened.store(true, Ordering::Relaxed); + self.enqueue_packet(&CContainerOpen { + container_id: 0, + container_type: 0xff, + position: BlockPos::ZERO, + target_entity_id: VarLong(-1), + }) + .await; + } + // No longer used in newer versions + Action::Attack => { + let target_runtime_id = packet.target_runtime_id.0 as i32; + let world = player.world(); + if let Some(target) = world.get_entity_by_id(target_runtime_id) { + player.attack(target).await; + } + } + _ => {} + } + } +} diff --git a/crates/pumpkin/src/net/bedrock/play/inventory_action.rs b/crates/pumpkin/src/net/bedrock/play/inventory_action.rs new file mode 100644 index 000000000..fc119d0b4 --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/inventory_action.rs @@ -0,0 +1,457 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + #[allow(clippy::too_many_lines, clippy::collapsible_if, clippy::unreachable)] + pub async fn handle_inventory_action( + &self, + player: &Arc, + packet: SInventoryTransaction, + ) { + tracing::debug!("handle_inventory_action: packet={:?}", packet); + let mut inventory_updated = false; + let mut updates = Vec::new(); + let result = 0u8; + + if packet.actions.is_empty() && packet.legacy_request_id.0 != 0 { + let mut player_screen_handler = player.player_screen_handler.lock().await; + for legacy_slot in &packet.legacy_set_item_slots { + let mapped_window_id = match legacy_slot.container_id { + 28 | 29 => 0, // HotBar or Inventory + 6 | 120 => 120, // Armor + 34 | 119 => 119, // Offhand + other => other as i32, + }; + for &slot_id in &legacy_slot.slots { + if let Some(screen_slot) = + map_bedrock_slot_to_screen_handler(mapped_window_id, slot_id as u32) + { + let current_stack = player_screen_handler + .get_slot(screen_slot) + .get_cloned_stack() + .await; + if !current_stack.is_empty() { + player.drop_item(current_stack.clone()).await; + + player_screen_handler + .get_slot(screen_slot) + .set_stack(ItemStack::EMPTY.clone()) + .await; + player_screen_handler + .set_received_stack(screen_slot, ItemStack::EMPTY.clone()); + + record_update( + &mut updates, + FullContainerName { + container_name: match legacy_slot.container_id { + 28 => ContainerName::HotBar, + _ => ContainerName::Inventory, + }, + dynamic_id: None, + }, + slot_id, + ItemStack::EMPTY, + ); + inventory_updated = true; + } + } + } + } + player_screen_handler.send_content_updates().await; + } + + for action in &packet.actions { + use pumpkin_protocol::bedrock::server::inventory_transaction::InventoryActionSource; + let source_type = InventoryActionSource::from(action.source_type); + if source_type == InventoryActionSource::World { + let old_stack = descriptor_to_stack(&action.old_item); + let new_stack = descriptor_to_stack(&action.new_item); + if old_stack.is_empty() && !new_stack.is_empty() { + player.drop_item(new_stack).await; + } + } else if let Some(window_id) = action.window_id { + if let Some(screen_slot) = + map_bedrock_slot_to_screen_handler(window_id, action.inventory_slot) + { + let item_stack = descriptor_to_stack(&action.new_item); + + let mut player_screen_handler = player.player_screen_handler.lock().await; + + let is_armor_equipped = player_screen_handler + .get_slot(screen_slot) + .get_stack() + .await + .are_equal(&item_stack); + + if !is_armor_equipped { + if (5..9).contains(&screen_slot) { + player + .enqueue_equipment_change( + &match screen_slot { + 5 => EquipmentSlot::HEAD, + 6 => EquipmentSlot::CHEST, + 7 => EquipmentSlot::LEGS, + 8 => EquipmentSlot::FEET, + _ => unreachable!(), + }, + &item_stack, + ) + .await; + } else if (36..45).contains(&screen_slot) { + let hotbar_slot = screen_slot - 36; + if player.inventory().get_selected_slot() == hotbar_slot as u8 { + let equipment = &[(EquipmentSlot::MAIN_HAND, item_stack.clone())]; + player.living_entity.send_equipment_changes(equipment); + } + } + } + + player_screen_handler + .get_slot(screen_slot) + .set_stack(item_stack.clone()) + .await; + player_screen_handler.set_received_stack(screen_slot, item_stack); + player_screen_handler.send_content_updates().await; + + inventory_updated = true; + } + } + } + + if inventory_updated { + self.enqueue_packet(&CInventoryContent { + container_id: VarUInt(0), + slots: player + .inventory() + .main_inventory + .read() + .await + .iter() + .map(NetworkItemStackDescriptor::from) + .collect(), + full_container_name: FullContainerName { + container_name: ContainerName::Inventory, + dynamic_id: None, + }, + storage_item: NetworkItemStackDescriptor::default(), + }) + .await; + } + + match packet.transaction_data { + TransactionData::Normal(_data) => { + // Actions are already applied to the inventory screen handler above. + } + TransactionData::Mismatch(_data) => { + // Actions are already applied to the inventory screen handler above. + } + TransactionData::UseItem(data) => { + let face = match data.block_face { + 0 => BlockDirection::Down, + 2 => BlockDirection::North, + 3 => BlockDirection::South, + 4 => BlockDirection::West, + 5 => BlockDirection::East, + _ => BlockDirection::Up, + }; + let world = player.world(); + let block = world.get_block(&data.block_position); + let Some(server) = world.server.upgrade() else { + return; + }; + + if player.gamemode.load() == GameMode::Spectator { + // TODO: openMenu ? + return; + } + + if data.action_type.0 == 0 { + // Click block + let client_stack = descriptor_to_stack(&data.item_in_hand); + + let mut held_item = player.inventory().held_item().await; + if !client_stack.is_empty() { + if held_item.is_empty() || held_item.item.id != client_stack.item.id { + held_item = client_stack.clone(); + } + } + + let result = server + .block_registry + .use_with_item( + block, + player, + &data.block_position, + &BlockHitResult { + face: &face, + cursor_pos: &data.click_position, + }, + &mut held_item, + &EquipmentSlot::MAIN_HAND, + &server, + &world, + ) + .await; + + if result.consumes_action() { + return; + } + + if matches!(result, BlockActionResult::PassToDefaultBlockAction) { + server + .block_registry + .on_use( + block, + player, + &data.block_position, + &BlockHitResult { + face: &face, + cursor_pos: &data.click_position, + }, + &server, + &world, + ) + .await; + } + + let mut stack = held_item; + if !stack.is_empty() { + server + .item_registry + .use_on_block( + &mut stack, + player, + data.block_position, + face, + data.click_position, + block, + &server, + ) + .await; + + let item_id = stack.item.id; + if let Some(placed_block) = pumpkin_data::Block::from_item_id(item_id) { + let dummy_use_item_on = + pumpkin_protocol::java::server::play::SUseItemOn { + hand: VarInt(0), + position: data.block_position, + face: VarInt(data.block_face), + cursor_pos: data.click_position, + inside_block: false, + is_against_world_border: false, + sequence: VarInt(0), + }; + + if let Ok(Some(_)) = server + .block_registry + .place_block( + player, + placed_block, + &server, + &dummy_use_item_on, + data.block_position, + face, + ) + .await + { + if player.gamemode.load() != GameMode::Creative { + stack.decrement(1); + } + } + } + player.inventory().set_held_item(stack).await; + } + } else if data.action_type.0 == 1 { + // Click air / Use item + let client_stack = descriptor_to_stack(&data.item_in_hand); + + let mut held = player.inventory.held_item().await; + if !client_stack.is_empty() + && (held.is_empty() || held.item.id != client_stack.item.id) + { + held = client_stack.clone(); + player.inventory.set_held_item(held.clone()).await; + } + + let event = PlayerInteractEvent::new( + player, + InteractAction::RightClickAir, + &pumpkin_data::Block::AIR, + None, + ); + + let stack_for_use = held.clone(); + + { + let mut cooldown_active = false; + if let Some(cooldown) = held.get_use_cooldown() { + let group = cooldown + .cooldown_group + .clone() + .unwrap_or_else(|| held.item.registry_key.to_string()); + if player.is_on_cooldown(&group).await { + cooldown_active = true; + } + } + + if !cooldown_active { + if held.get_data_component::().is_some() + || held.get_data_component::().is_some() + { + if let Some(food) = held.get_data_component::() { + if player.abilities.lock().await.invulnerable + || food.can_always_eat + || player.hunger_manager.level.load() < 20 + { + player + .living_entity + .set_active_hand( + Hand::Left, + held.clone(), + held.get_max_use_time(), + ) + .await; + } + } else { + player + .living_entity + .set_active_hand( + Hand::Left, + held.clone(), + held.get_max_use_time(), + ) + .await; + } + } + if let Some(equippable) = held.get_data_component::() { + let inventory = player.inventory(); + let mut equipment_guard = inventory.entity_equipment.lock().await; + let current_equipped = equipment_guard.get(equippable.slot); + if !current_equipped.are_items_and_components_equal(&held) { + player + .enqueue_equipment_change(equippable.slot, &held) + .await; + + let equip_item = equipment_guard + .equipment + .entry(equippable.slot.clone()) + .or_insert_with(|| ItemStack::EMPTY.clone()); + if equip_item.is_empty() { + *equip_item = held.clone(); + held.decrement_unless_creative(player.gamemode.load(), 1); + } else { + let old_held = held.clone(); + held = equip_item.clone(); + *equip_item = old_held; + } + player.inventory().set_held_item(held.clone()).await; + } + } + } + } + + send_cancellable! {{ + &server; + event; + 'after: { + server.item_registry.on_use(&stack_for_use, player).await; + } + }} + } + } + TransactionData::UseItemOnEntity(data) => { + let target_runtime_id = data.target_entity_runtime_id.0 as i32; + // TODO: replace with consts, i'm too lazy + match data.action_type.0 { + // Interact / Item Interact + 0 | 2 => { + let world = player.world(); + if let Some(target) = world.get_entity_by_id(target_runtime_id) { + let mut stack = player.inventory().held_item().await; + if !target.interact(player, &mut stack).await { + let Some(server) = world.server.upgrade() else { + return; + }; + server + .item_registry + .use_on_entity(&mut stack, player, target) + .await; + player.inventory().set_held_item(stack).await; + } + } + } + // Attack + 1 => { + let world = player.world(); + if let Some(target) = world.get_entity_by_id(target_runtime_id) { + player.attack(target).await; + } + } + _ => { + tracing::warn!( + "invalid UseItemOnEntity action type {}", + data.action_type.0 + ); + // Kick? + } + } + } + TransactionData::ReleaseItem(_data) => { + let item_in_use = player.living_entity.item_in_use.lock().await.clone(); + if let Some(stack) = item_in_use { + let Some(server) = player.world().server.upgrade() else { + return; + }; + server.item_registry.on_stopped_using(&stack, player).await; + } + player.living_entity.clear_active_hand().await; + } + } + + if packet.legacy_request_id.0 != 0 { + use pumpkin_protocol::bedrock::client::item_stack_response::{ + CItemStackResponse, ItemStackResponse, ItemStackResponseContainerInfo, + ItemStackResponseSlotInfo, + }; + + let mut container_infos = Vec::new(); + if result == 0 { + for update in updates { + let container_info = container_infos.iter_mut().find( + |info: &&mut ItemStackResponseContainerInfo| { + info.container_name == update.container_name + }, + ); + + let slot_info = ItemStackResponseSlotInfo { + slot: update.slot_id, + hotbar_slot: update.slot_id, + count: update.count, + item_stack_id: update.stack_id, + custom_name: String::new(), + filtered_custom_name: String::new(), + durability_correction: VarInt(0), + }; + + if let Some(info) = container_info { + info.slots.push(slot_info); + } else { + container_infos.push(ItemStackResponseContainerInfo { + container_name: update.container_name.clone(), + slots: vec![slot_info], + }); + } + } + } + + self.enqueue_packet(&CItemStackResponse { + responses: vec![ItemStackResponse { + result, + request_id: packet.legacy_request_id, + container_infos, + }], + }) + .await; + } + } +} diff --git a/crates/pumpkin/src/net/bedrock/play/item_stack_request.rs b/crates/pumpkin/src/net/bedrock/play/item_stack_request.rs new file mode 100644 index 000000000..57eaf8109 --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/item_stack_request.rs @@ -0,0 +1,856 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + #[allow(clippy::too_many_lines)] + pub async fn handle_item_stack_request( + &self, + player: &Arc, + packet: pumpkin_protocol::bedrock::server::item_stack_request::SItemStackRequest, + ) { + use pumpkin_protocol::bedrock::client::item_stack_response::{ + CItemStackResponse, ItemStackResponse, ItemStackResponseContainerInfo, + ItemStackResponseSlotInfo, + }; + use pumpkin_protocol::bedrock::server::item_stack_request::ItemStackRequestAction; + + let current_screen_handler = player.current_screen_handler.lock().await.clone(); + let mut screen_handler = current_screen_handler.lock().await; + + let mut responses = Vec::with_capacity(packet.requests.len()); + + for request in packet.requests { + let mut created_item: Option = None; + let mut crafting_inputs_consumed = false; + let mut updates = Vec::new(); + let mut result = 0u8; // 0 = Success, 1 = Error + + for action in request.actions { + match action { + ItemStackRequestAction::CraftCreative { + creative_item_id, + repetitions, + } => { + let index = (creative_item_id.0.saturating_sub(1)) as usize; + if index < pumpkin_data::bedrock_creative::CREATIVE_ENTRIES.len() { + let entry = pumpkin_data::bedrock_creative::CREATIVE_ENTRIES[index]; + if let Some(mapping) = + pumpkin_data::item::JavaToBedrockItemMapping::from_bedrock( + entry.item_id, + entry.item_aux_value, + ) + { + // Bedrock `repetitions` represents how many stacks to create; use the item's max stack size + let max_stack = ItemStack::static_new_java(1, mapping.java_item) + .get_max_stack_size(); + let count = ((max_stack as u16) * (repetitions as u16)) + .min(u8::MAX as u16) + as u8; + created_item = Some(ItemStack::new(count, mapping.java_item)); + } else { + tracing::warn!( + "Failed to map bedrock item id {} and data {} to Java item", + entry.item_id, + entry.item_aux_value + ); + result = 1; + break; + } + } else { + tracing::warn!( + "Creative item index {} out of bounds (len: {})", + index, + pumpkin_data::bedrock_creative::CREATIVE_ENTRIES.len() + ); + result = 1; + break; + } + } + ItemStackRequestAction::Take { + count, + source, + destination, + } + | ItemStackRequestAction::Place { + count, + source, + destination, + } => { + let mut source_stack = + get_slot_stack(&*screen_handler, &source, created_item.as_ref()).await; + if source_stack.is_empty() && created_item.is_none() { + tracing::debug!("Source stack is empty in Take/Place"); + result = 1; + break; + } + let count = count.min(source_stack.item_count); + if count > 0 { + let mut dest_stack = get_slot_stack( + &*screen_handler, + &destination, + created_item.as_ref(), + ) + .await; + if dest_stack.is_empty() { + dest_stack = source_stack.copy_with_count(count); + } else if dest_stack.are_items_and_components_equal(&source_stack) { + dest_stack.item_count = dest_stack.item_count.saturating_add(count); + } else { + tracing::debug!( + "Destination stack is not compatible with source stack" + ); + result = 1; + break; + } + + source_stack.decrement(count); + if source.container_name.container_name == ContainerName::CreatedOutput + && let Some(ref mut stack) = created_item + { + stack.decrement(count); + if stack.is_empty() { + created_item = None; + } + } + let source_stack = if source_stack.is_empty() { + ItemStack::EMPTY.clone() + } else { + source_stack + }; + + update_slot_stack( + player, + &mut *screen_handler, + &source, + source_stack.clone(), + ) + .await; + update_slot_stack( + player, + &mut *screen_handler, + &destination, + dest_stack.clone(), + ) + .await; + + record_update( + &mut updates, + source.container_name.clone(), + source.slot_id, + &source_stack, + ); + record_update( + &mut updates, + destination.container_name.clone(), + destination.slot_id, + &dest_stack, + ); + } + } + ItemStackRequestAction::Swap { slot1, slot2 } => { + let stack1 = + get_slot_stack(&*screen_handler, &slot1, created_item.as_ref()).await; + let stack2 = + get_slot_stack(&*screen_handler, &slot2, created_item.as_ref()).await; + + update_slot_stack(player, &mut *screen_handler, &slot1, stack2.clone()) + .await; + update_slot_stack(player, &mut *screen_handler, &slot2, stack1.clone()) + .await; + + record_update( + &mut updates, + slot1.container_name.clone(), + slot1.slot_id, + &stack2, + ); + record_update( + &mut updates, + slot2.container_name.clone(), + slot2.slot_id, + &stack1, + ); + } + ItemStackRequestAction::Drop { + count, + source, + randomly: _, + } => { + let mut source_stack = + get_slot_stack(&*screen_handler, &source, created_item.as_ref()).await; + if source_stack.is_empty() { + result = 1; + break; + } + let count = count.min(source_stack.item_count); + if count > 0 { + let dropped_stack = source_stack.copy_with_count(count); + player.drop_item(dropped_stack).await; + + source_stack.decrement(count); + let source_stack = if source_stack.is_empty() { + ItemStack::EMPTY.clone() + } else { + source_stack + }; + + update_slot_stack( + player, + &mut *screen_handler, + &source, + source_stack.clone(), + ) + .await; + + record_update( + &mut updates, + source.container_name.clone(), + source.slot_id, + &source_stack, + ); + } + } + ItemStackRequestAction::Destroy { count, source } + | ItemStackRequestAction::Consume { count, source } => { + if crafting_inputs_consumed + && source.container_name.container_name == ContainerName::CraftingInput + { + let source_stack = + get_slot_stack(&*screen_handler, &source, created_item.as_ref()) + .await; + record_update( + &mut updates, + source.container_name.clone(), + source.slot_id, + &source_stack, + ); + continue; + } + + let mut source_stack = + get_slot_stack(&*screen_handler, &source, created_item.as_ref()).await; + if source_stack.is_empty() { + result = 1; + break; + } + let count = count.min(source_stack.item_count); + if count > 0 { + source_stack.decrement(count); + let source_stack = if source_stack.is_empty() { + ItemStack::EMPTY.clone() + } else { + source_stack + }; + + update_slot_stack( + player, + &mut *screen_handler, + &source, + source_stack.clone(), + ) + .await; + + record_update( + &mut updates, + source.container_name.clone(), + source.slot_id, + &source_stack, + ); + } + } + ItemStackRequestAction::CraftRecipe { + recipe_id: _, + repetitions, + } + | ItemStackRequestAction::CraftRecipeAuto { + recipe_id: _, + repetitions, + .. + } => { + if repetitions > 0 { + screen_handler.update_to_client().await; + + let is_player = screen_handler.window_type().is_none(); + let grid_size = if is_player { 4 } else { 9 }; + let bedrock_grid_start = if is_player { 28 } else { 32 }; + for i in 0..grid_size { + let grid_slot_index = 1 + i; + let grid_slot = + screen_handler.get_behaviour().slots[grid_slot_index].clone(); + let grid_stack = grid_slot.get_cloned_stack().await; + tracing::info!( + "Crafting Grid slot {i} (slot index {grid_slot_index}): Item ID: {}, Count: {}", + grid_stack.item.id, + grid_stack.item_count + ); + } + + let output_slot = screen_handler.get_behaviour().slots[0].clone(); + let output_stack = output_slot.get_cloned_stack().await; + + if output_stack.is_empty() + || repetitions > output_slot.get_max_item_count().await + { + tracing::warn!("Client sent an invalid crafting request"); + result = 1; + break; + } + + let mut total_crafted = output_stack.clone(); + total_crafted.item_count = + total_crafted.item_count.saturating_mul(repetitions); + created_item = Some(total_crafted); + + for _ in 0..repetitions { + output_slot + .on_take_item(player.as_ref(), &output_stack) + .await; + } + crafting_inputs_consumed = true; + + // Record updates for all grid slots so Bedrock client is notified of consumed ingredients! + let is_player = screen_handler.window_type().is_none(); + let grid_size = if is_player { 4 } else { 9 }; + for i in 0..grid_size { + let grid_slot_index = 1 + i; + let grid_slot = + screen_handler.get_behaviour().slots[grid_slot_index].clone(); + let grid_stack = grid_slot.get_cloned_stack().await; + record_update( + &mut updates, + FullContainerName { + container_name: ContainerName::CraftingInput, + dynamic_id: None, + }, + (bedrock_grid_start + i) as u8, + &grid_stack, + ); + } + } + } + ItemStackRequestAction::CraftResultsDeprecated { .. } + | ItemStackRequestAction::MineBlock { .. } + | ItemStackRequestAction::BeaconPayment { .. } + | ItemStackRequestAction::Create { .. } + | ItemStackRequestAction::LabTableCombine + | ItemStackRequestAction::Optional { .. } + | ItemStackRequestAction::Grindstone { .. } + | ItemStackRequestAction::Loom { .. } + | ItemStackRequestAction::CraftNonImplemented => { + // Successful no-ops to prevent client-side transaction rollbacks + } + } + } + + let mut container_infos = Vec::new(); + if result == 0 { + for update in updates { + let container_info = container_infos.iter_mut().find( + |info: &&mut ItemStackResponseContainerInfo| { + info.container_name == update.container_name + }, + ); + + let slot_info = ItemStackResponseSlotInfo { + slot: update.slot_id, + hotbar_slot: update.slot_id, + count: update.count, + item_stack_id: update.stack_id, + custom_name: String::new(), + filtered_custom_name: String::new(), + durability_correction: VarInt(0), + }; + + if let Some(info) = container_info { + info.slots.push(slot_info); + } else { + container_infos.push(ItemStackResponseContainerInfo { + container_name: update.container_name, + slots: vec![slot_info], + }); + } + } + } + + responses.push(ItemStackResponse { + result, + request_id: request.request_id, + container_infos, + }); + } + + // Send updates to Java client + screen_handler.send_content_updates().await; + + // Collect inventory updates if we modified player inventory + let mut inventory_updated = false; + for response in &responses { + if response.result == 0 { + for info in &response.container_infos { + if info.container_name.container_name == ContainerName::Inventory + || info.container_name.container_name + == ContainerName::CombinedHotBarAndInventory + || info.container_name.container_name == ContainerName::HotBar + { + inventory_updated = true; + } + } + } + } + + // Send Bedrock specific responses and updates + self.enqueue_packet(&CItemStackResponse { responses }).await; + + if inventory_updated { + self.enqueue_packet(&CInventoryContent { + container_id: VarUInt(0), + slots: player + .inventory() + .main_inventory + .read() + .await + .iter() + .map(NetworkItemStackDescriptor::from) + .collect(), + full_container_name: FullContainerName { + container_name: ContainerName::Inventory, + dynamic_id: None, + }, + storage_item: NetworkItemStackDescriptor::default(), + }) + .await; + } + } +} + +#[allow(clippy::too_many_lines)] +pub(crate) fn map_bedrock_container_slot( + screen_handler: &dyn ScreenHandler, + container_name: ContainerName, + slot_id: u8, +) -> Option { + let is_player_screen = screen_handler.window_type().is_none(); + let container_slots = screen_handler + .get_behaviour() + .slots + .len() + .saturating_sub(PlayerInventory::MAIN_SIZE); + + match container_name { + ContainerName::HotBar => { + if is_player_screen { + Some(36 + slot_id as usize) + } else { + Some(container_slots + 27 + slot_id as usize) + } + } + ContainerName::Inventory | ContainerName::CombinedHotBarAndInventory => { + if slot_id < 9 { + if is_player_screen { + Some(36 + slot_id as usize) + } else { + Some(container_slots + 27 + slot_id as usize) + } + } else if slot_id < 36 { + if is_player_screen { + Some(slot_id as usize) + } else { + Some(container_slots + (slot_id - 9) as usize) + } + } else { + None + } + } + ContainerName::Armor => (slot_id < 4).then(|| 5 + slot_id as usize), + ContainerName::Offhand => (slot_id == 0).then_some(45), + ContainerName::Cursor => None, + ContainerName::CraftingInput => { + if is_player_screen { + if slot_id < 4 { + Some(1 + slot_id as usize) + } else if (28..32).contains(&slot_id) { + Some(1 + (slot_id - 28) as usize) + } else { + None + } + } else if screen_handler.window_type() + == Some(pumpkin_data::screen::WindowType::Crafting) + { + if slot_id < 9 { + Some(1 + slot_id as usize) + } else if (32..41).contains(&slot_id) { + Some(1 + (slot_id - 32) as usize) + } else { + None + } + } else { + None + } + } + ContainerName::CraftingOutputPreview | ContainerName::CreatedOutput => { + if is_player_screen { + Some(0) + } else if let Some(window_type) = screen_handler.window_type() { + match window_type { + pumpkin_data::screen::WindowType::Crafting => Some(0), + pumpkin_data::screen::WindowType::Stonecutter => Some(1), + pumpkin_data::screen::WindowType::Anvil + | pumpkin_data::screen::WindowType::Furnace + | pumpkin_data::screen::WindowType::BlastFurnace + | pumpkin_data::screen::WindowType::Smoker + | pumpkin_data::screen::WindowType::Grindstone + | pumpkin_data::screen::WindowType::Merchant => Some(2), + pumpkin_data::screen::WindowType::Loom + | pumpkin_data::screen::WindowType::Smithing => Some(3), + _ => None, + } + } else { + None + } + } + ContainerName::AnvilInput => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Anvil) + ) + .then_some(0), + ContainerName::AnvilMaterial => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Anvil) + ) + .then_some(1), + ContainerName::AnvilResultPreview => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Anvil) + ) + .then_some(2), + ContainerName::BeaconPayment => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Beacon) + ) + .then_some(0), + ContainerName::BrewingStandResult => (matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::BrewingStand) + ) && slot_id < 3) + .then_some(slot_id as usize), + ContainerName::BrewingStandInput => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::BrewingStand) + ) + .then_some(3), + ContainerName::BrewingStandFuel => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::BrewingStand) + ) + .then_some(4), + ContainerName::FurnaceIngredient + | ContainerName::BlastFurnaceIngredient + | ContainerName::SmokerIngredient => matches!( + screen_handler.window_type(), + Some( + pumpkin_data::screen::WindowType::Furnace + | pumpkin_data::screen::WindowType::BlastFurnace + | pumpkin_data::screen::WindowType::Smoker + ) + ) + .then_some(0), + ContainerName::FurnaceFuel => matches!( + screen_handler.window_type(), + Some( + pumpkin_data::screen::WindowType::Furnace + | pumpkin_data::screen::WindowType::BlastFurnace + | pumpkin_data::screen::WindowType::Smoker + ) + ) + .then_some(1), + ContainerName::FurnaceResult => matches!( + screen_handler.window_type(), + Some( + pumpkin_data::screen::WindowType::Furnace + | pumpkin_data::screen::WindowType::BlastFurnace + | pumpkin_data::screen::WindowType::Smoker + ) + ) + .then_some(2), + ContainerName::EnchantingInput => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Enchantment) + ) + .then_some(0), + ContainerName::EnchantingMaterial => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Enchantment) + ) + .then_some(1), + ContainerName::GrindstoneInput => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Grindstone) + ) + .then_some(0), + ContainerName::GrindstoneAdditional => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Grindstone) + ) + .then_some(1), + ContainerName::GrindstoneResultPreview => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Grindstone) + ) + .then_some(2), + ContainerName::LoomInput => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Loom) + ) + .then_some(0), + ContainerName::LoomDye => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Loom) + ) + .then_some(1), + ContainerName::LoomMaterial => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Loom) + ) + .then_some(2), + ContainerName::LoomResultPreview => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Loom) + ) + .then_some(3), + ContainerName::StonecutterInput => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Stonecutter) + ) + .then_some(0), + ContainerName::StonecutterResultPreview => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Stonecutter) + ) + .then_some(1), + ContainerName::CartographyInput => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::CartographyTable) + ) + .then_some(0), + ContainerName::CartographyAdditional => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::CartographyTable) + ) + .then_some(1), + ContainerName::CartographyResultPreview => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::CartographyTable) + ) + .then_some(2), + ContainerName::SmithingTableTemplate => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Smithing) + ) + .then_some(0), + ContainerName::SmithingTableInput => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Smithing) + ) + .then_some(1), + ContainerName::SmithingTableMaterial => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Smithing) + ) + .then_some(2), + ContainerName::SmithingTableResultPreview => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Smithing) + ) + .then_some(3), + ContainerName::TradeIngredient1 | ContainerName::Trade2Ingredient1 => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Merchant) + ) + .then_some(0), + ContainerName::TradeIngredient2 | ContainerName::Trade2Ingredient2 => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Merchant) + ) + .then_some(1), + ContainerName::TradeResultPreview | ContainerName::Trade2ResultPreview => matches!( + screen_handler.window_type(), + Some(pumpkin_data::screen::WindowType::Merchant) + ) + .then_some(2), + _ => ((slot_id as usize) < container_slots).then_some(slot_id as usize), + } +} + +pub(crate) struct SlotUpdate { + pub(crate) container_name: FullContainerName, + pub(crate) slot_id: u8, + pub(crate) count: u8, + pub(crate) stack_id: VarInt, +} + +pub(crate) fn record_update( + updates: &mut Vec, + container_name: FullContainerName, + slot_id: u8, + stack: &ItemStack, +) { + let count = stack.item_count; + let stack_id = if stack.is_empty() { + VarInt(0) + } else { + VarInt(stack.uid.get()) + }; + if let Some(existing) = updates + .iter_mut() + .find(|u| u.container_name == container_name && u.slot_id == slot_id) + { + existing.count = count; + existing.stack_id = stack_id; + } else { + updates.push(SlotUpdate { + container_name, + slot_id, + count, + stack_id, + }); + } +} + +async fn get_slot_stack( + screen_handler: &dyn ScreenHandler, + slot_info: &pumpkin_protocol::bedrock::server::item_stack_request::ItemStackRequestSlotInfo, + created_item: Option<&ItemStack>, +) -> ItemStack { + if let (ContainerName::CreatedOutput, Some(stack)) = + (slot_info.container_name.container_name, created_item) + { + return stack.clone(); + } + if slot_info.container_name.container_name == ContainerName::Cursor { + return screen_handler + .get_behaviour() + .cursor_stack + .lock() + .await + .clone(); + } + if let Some(screen_slot) = map_bedrock_container_slot( + screen_handler, + slot_info.container_name.container_name, + slot_info.slot_id, + ) { + screen_handler.get_behaviour().slots[screen_slot] + .get_cloned_stack() + .await + } else { + ItemStack::EMPTY.clone() + } +} + +#[allow(clippy::unreachable)] +async fn update_slot_stack( + player: &Player, + screen_handler: &mut dyn ScreenHandler, + slot_info: &pumpkin_protocol::bedrock::server::item_stack_request::ItemStackRequestSlotInfo, + new_stack: ItemStack, +) { + if slot_info.container_name.container_name == ContainerName::Cursor { + let mut cursor_lock = screen_handler.get_behaviour().cursor_stack.lock().await; + *cursor_lock = new_stack; + return; + } + if let Some(screen_slot) = map_bedrock_container_slot( + screen_handler, + slot_info.container_name.container_name, + slot_info.slot_id, + ) { + let is_player_screen = screen_handler.window_type().is_none(); + if is_player_screen { + let current_stack = screen_handler.get_behaviour().slots[screen_slot] + .get_cloned_stack() + .await; + if !current_stack.are_items_and_components_equal(&new_stack) { + if (5..9).contains(&screen_slot) { + player + .enqueue_equipment_change( + &match screen_slot { + 5 => EquipmentSlot::HEAD, + 6 => EquipmentSlot::CHEST, + 7 => EquipmentSlot::LEGS, + 8 => EquipmentSlot::FEET, + _ => unreachable!(), + }, + &new_stack, + ) + .await; + } else if (36..45).contains(&screen_slot) { + let hotbar_slot = screen_slot - 36; + if player.inventory().get_selected_slot() == hotbar_slot as u8 { + let equipment = &[(EquipmentSlot::MAIN_HAND, new_stack.clone())]; + player.living_entity.send_equipment_changes(equipment); + } + } + } + } + + screen_handler.get_behaviour().slots[screen_slot] + .set_stack(new_stack.clone()) + .await; + screen_handler.set_received_stack(screen_slot, new_stack); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pumpkin_data::item::Item; + use pumpkin_inventory::{ + build_equipment_slots, crafting::crafting_screen_handler::CraftingTableScreenHandler, + entity_equipment::EntityEquipment, + }; + use tokio::sync::Mutex; + + #[tokio::test] + async fn crafting_table_maps_bedrock_player_inventory_after_its_ten_slots() { + let inventory = Arc::new(PlayerInventory::new( + Arc::new(Mutex::new(EntityEquipment::new())), + Arc::new(build_equipment_slots()), + )); + let handler = CraftingTableScreenHandler::new(1, &inventory, None).await; + + assert_eq!( + map_bedrock_container_slot(&handler, ContainerName::Inventory, 26), + Some(27) + ); + assert_eq!( + map_bedrock_container_slot(&handler, ContainerName::HotBar, 0), + Some(37) + ); + assert_eq!( + map_bedrock_container_slot(&handler, ContainerName::CraftingInput, 32), + Some(1) + ); + assert_eq!( + map_bedrock_container_slot(&handler, ContainerName::CraftingInput, 40), + Some(9) + ); + } + + #[test] + fn item_stack_response_uses_the_authoritative_stack_id() { + let stack = ItemStack::new(3, &Item::SPRUCE_DOOR); + let container = FullContainerName { + container_name: ContainerName::Cursor, + dynamic_id: None, + }; + let mut updates = Vec::new(); + + record_update(&mut updates, container.clone(), 0, &stack); + assert_eq!(updates[0].count, 3); + assert_eq!(updates[0].stack_id, VarInt(stack.uid.get())); + + record_update(&mut updates, container, 0, ItemStack::EMPTY); + assert_eq!(updates[0].count, 0); + assert_eq!(updates[0].stack_id, VarInt(0)); + } +} diff --git a/crates/pumpkin/src/net/bedrock/play/mob_equipment.rs b/crates/pumpkin/src/net/bedrock/play/mob_equipment.rs new file mode 100644 index 000000000..823dbf253 --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/mob_equipment.rs @@ -0,0 +1,42 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + pub async fn handle_mob_equipment( + &self, + _server: &Arc, + player: &Arc, + packet: SMobEquipment, + ) { + player.update_last_action_time(); + let slot = packet.hotbar_slot; + if slot >= 9 { + return; + } + let previous_slot = player.inventory.get_selected_slot(); + let server = player.world().server.upgrade(); + if let Some(server) = server { + let mut event = PlayerItemHeldEvent::new(player.clone(), previous_slot, slot); + server.plugin_manager.fire(&server, &mut event).await; + let is_cancelled = { + use crate::plugin::Cancellable; + event.cancelled() + }; + if is_cancelled { + self.enqueue_packet(&CPlayerHotbar { + selected_slot: VarUInt(previous_slot as u32), + container_id: 0, + should_select_block: true, + }) + .await; + return; + } + } + + let inv = player.inventory(); + inv.set_selected_slot(slot); + let stack = inv.held_item().await; + let equipment = &[(EquipmentSlot::MAIN_HAND, stack)]; + player.living_entity.send_equipment_changes(equipment); + } +} diff --git a/crates/pumpkin/src/net/bedrock/play/mod.rs b/crates/pumpkin/src/net/bedrock/play/mod.rs new file mode 100644 index 000000000..4ae0e7f02 --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/mod.rs @@ -0,0 +1,154 @@ +use std::{ + num::{NonZero, NonZeroI32}, + sync::{Arc, atomic::Ordering}, +}; + +use pumpkin_data::{ + data_component_impl::{ + BlocksAttacksImpl, ConsumableImpl, EquipmentSlot, EquippableImpl, FoodImpl, + }, + item_stack::ItemStack, +}; +use pumpkin_inventory::{ + player::player_inventory::PlayerInventory, + screen_handler::{InventoryPlayer, ScreenHandler}, +}; +use pumpkin_protocol::bedrock::{ + client::{inventory_content::CInventoryContent, respawn::CRespawn}, + network_item::{ + ContainerName, FullContainerName, NetworkItemDescriptor, NetworkItemStackDescriptor, + }, + respawn::RespawnState, +}; +use pumpkin_protocol::{ + bedrock::{ + client::{ + chunk_radius_update::CChunkRadiusUpdate, container_open::CContainerOpen, + player_hotbar::CPlayerHotbar, update_block::CUpdateBlock, + }, + server::{ + animate::{AnimateAction, SAnimate}, + block_pick_request::SBlockPickRequest, + command_request::SCommandRequest, + container_close::SContainerClose, + emote::SEmote, + emote_list::SEmoteList, + interaction::{Action, SInteraction}, + inventory_transaction::{SInventoryTransaction, TransactionData}, + mob_equipment::SMobEquipment, + player_action::{Action as PlayerAction, SPlayerAction}, + player_auth_input::{InputData, SPlayerAuthInput}, + request_chunk_radius::SRequestChunkRadius, + respawn::SRespawn, + set_local_player_as_initialized::SSetLocalPlayerAsInitialized, + text::SText, + }, + }, + codec::{var_int::VarInt, var_long::VarLong, var_uint::VarUInt, var_ulong::VarULong}, + java::client::play::{Animation, CEntityAnimation, CSetSelectedSlot, CSystemChatMessage}, +}; +use pumpkin_util::{GameMode, Hand, math::position::BlockPos, text::TextComponent}; + +use pumpkin_world::inventory::Inventory; +use pumpkin_world::world::BlockFlags; + +use crate::{ + block::{BlockHitResult, registry::BlockActionResult}, + entity::{ + EntityBase, + player::{MINE_BLOCK_EXHAUSTION, Player}, + }, + net::{DisconnectReason, bedrock::BedrockClient}, + plugin::player::{ + item_held::PlayerItemHeldEvent, + player_chat::PlayerChatEvent, + player_command_send::PlayerCommandSendEvent, + player_interact_event::{InteractAction, PlayerInteractEvent}, + player_toggle_flight_event::PlayerToggleFlightEvent, + }, + server::{Server, seasonal_events}, + world::{BlockBreakingProgress, chunker}, +}; +use pumpkin_data::BlockDirection; +use tracing::{debug, info}; + +const MIN_PREDICTED_BREAK_PROGRESS: f32 = 0.65; + +fn descriptor_to_stack(desc: &NetworkItemDescriptor) -> ItemStack { + if desc.id.0 == 0 || desc.stack_size == 0 { + ItemStack::EMPTY.clone() + } else { + pumpkin_data::item::JavaToBedrockItemMapping::from_bedrock( + desc.id.0 as i16, + desc.aux_value.0, + ) + .map_or_else( + || { + tracing::warn!( + "Failed to map bedrock item id {} and data {} to Java item", + desc.id.0, + desc.aux_value.0 + ); + ItemStack::EMPTY.clone() + }, + |mapping| ItemStack::new(desc.stack_size as u8, mapping.java_item), + ) + } +} + +const fn map_bedrock_slot_to_screen_handler(window_id: i32, slot: u32) -> Option { + match window_id { + 0 => { + // WINDOW_ID_INVENTORY + if slot < 9 { + // Hotbar: Bedrock 0-8 -> Screen Handler 36-44 + Some(slot as usize + 36) + } else if slot < 36 { + // Main Inventory: Bedrock 9-35 -> Screen Handler 9-35 + Some(slot as usize) + } else { + None + } + } + 120 => { + // WINDOW_ID_ARMOUR + if slot < 4 { + // Armor: Bedrock 0-3 -> Screen Handler 5-8 + Some(slot as usize + 5) + } else { + None + } + } + 119 => { + // WINDOW_ID_OFF_HAND + if slot == 0 { + // Offhand: Bedrock 0 -> Screen Handler 45 + Some(45) + } else { + None + } + } + _ => None, + } +} + +pub mod animate; +pub mod block_pick_request; +pub mod chat_command; +pub mod chat_message; +pub mod container_close; +pub mod emote; +pub mod emote_list; +pub mod interaction; +pub mod inventory_action; +pub mod item_stack_request; +pub(super) use item_stack_request::record_update; +pub mod mob_equipment; +pub mod modal_form_response; +pub mod player_action; +pub mod player_auth_input; +pub mod player_block_action; +pub mod request_ability; +pub mod request_chunk_radius; +pub mod respawn; +pub mod set_local_player_as_initialized; diff --git a/crates/pumpkin/src/net/bedrock/play/modal_form_response.rs b/crates/pumpkin/src/net/bedrock/play/modal_form_response.rs new file mode 100644 index 000000000..d1caf006d --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/modal_form_response.rs @@ -0,0 +1,22 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + pub async fn handle_modal_form_response( + &self, + player: &Arc, + server: &Arc, + packet: pumpkin_protocol::bedrock::server::modal_form_response::SModalFormResponse<'_>, + ) { + let mut event = + crate::plugin::api::events::player::bedrock_form_response::BedrockFormResponseEvent::new( + player.clone(), + packet.form_id.0, + packet + .form_data + .filter(|data| data != "null") + .map(std::borrow::Cow::into_owned), + ); + server.plugin_manager.fire(server, &mut event).await; + } +} diff --git a/crates/pumpkin/src/net/bedrock/play/player_action.rs b/crates/pumpkin/src/net/bedrock/play/player_action.rs new file mode 100644 index 000000000..06e3edc1e --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/player_action.rs @@ -0,0 +1,213 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + #[expect(clippy::match_same_arms)] + #[expect(clippy::too_many_lines)] + pub async fn handle_player_action( + &self, + player: &Arc, + server: &Server, + packet: SPlayerAction, + ) { + if !player.has_client_loaded() + || ((player.living_entity.dead.load(Ordering::Relaxed) + || player.living_entity.health.load() <= 0.0) + && !matches!(packet.action, PlayerAction::Respawn)) + { + return; + } + player.update_last_action_time(); + + match packet.action { + PlayerAction::StartBreak + | PlayerAction::CreativePlayerDestroyBlock + | PlayerAction::ContinueDestroyBlock => { + let location = packet.block_pos; + if !player.can_interact_with_block_at(&location, 1.0) { + return; + } + + let entity = &player.get_entity(); + let world = entity.world.load_full(); + let (block, state) = world.get_block_and_state(&location); + + if player.mining.load(Ordering::Relaxed) + && *player.mining_pos.lock().await != location + { + player.stop_mining().await; + } + + if player.gamemode.load() == GameMode::Creative { + let new_state = world + .break_block( + &location, + Some(player.clone()), + BlockFlags::NOTIFY_NEIGHBORS | BlockFlags::SKIP_DROPS, + ) + .await; + if new_state.is_some() { + server + .block_registry + .broken(&world, block, player, &location, server, state) + .await; + } + } else if !state.is_air() { + let speed = crate::block::calc_block_breaking(player, state, block).await; + if speed >= 1.0 { + player.stop_mining().await; + let broken_state = world.get_block_state(&location); + let can_harvest = player.can_harvest(broken_state, block).await; + let new_state = world + .break_block( + &location, + Some(player.clone()), + BlockFlags::NOTIFY_NEIGHBORS, + ) + .await; + if new_state.is_some() { + server + .block_registry + .broken(&world, block, player, &location, server, broken_state) + .await; + player.apply_tool_damage_for_block_break(broken_state).await; + if can_harvest { + player.add_exhaustion(MINE_BLOCK_EXHAUSTION).await; + } + } + } else { + let mut mining_pos = player.mining_pos.lock().await; + let starts_breaking = + !player.mining.load(Ordering::Relaxed) || *mining_pos != location; + let progress = if starts_breaking { + player.start_mining_time.store( + player.tick_counter.load(Ordering::Relaxed), + Ordering::Relaxed, + ); + player.mining.store(true, Ordering::Relaxed); + *mining_pos = location; + (speed * 10.0) as i32 + } else { + player.current_block_destroy_stage.load(Ordering::Relaxed) + }; + drop(mining_pos); + let old_speed = player + .current_block_breaking_speed + .swap(speed.to_bits(), Ordering::Relaxed); + if starts_breaking { + world + .set_block_breaking( + entity, + location, + BlockBreakingProgress::Start { + stage: progress, + speed, + }, + ) + .await; + player + .current_block_destroy_stage + .store(progress, Ordering::Relaxed); + } else if old_speed != speed.to_bits() { + world + .set_block_breaking( + entity, + location, + BlockBreakingProgress::Update { + stage: progress, + speed: Some(speed), + }, + ) + .await; + } + } + } + } + action @ (PlayerAction::PredictDestroyBlock | PlayerAction::StopBreak) => { + let location = packet.block_pos; + if !player.can_interact_with_block_at(&location, 1.0) { + return; + } + + let entity = &player.get_entity(); + let world = entity.world.load_full(); + + let (block, state) = world.get_block_and_state(&location); + if player.gamemode.load() != GameMode::Creative && !state.is_air() { + let speed = crate::block::calc_block_breaking(player, state, block).await; + let elapsed = player.tick_counter.load(Ordering::Relaxed) + - player.start_mining_time.load(Ordering::Relaxed) + + 1; + let same_block = *player.mining_pos.lock().await == location; + if player.mining.load(Ordering::Relaxed) + && same_block + && speed * elapsed as f32 >= MIN_PREDICTED_BREAK_PROGRESS + { + player.stop_mining().await; + + let can_harvest = player.can_harvest(state, block).await; + let flags = if can_harvest { + BlockFlags::NOTIFY_NEIGHBORS + } else { + BlockFlags::SKIP_DROPS | BlockFlags::NOTIFY_NEIGHBORS + }; + if world + .break_block(&location, Some(player.clone()), flags) + .await + .is_some() + { + server + .block_registry + .broken(&world, block, player, &location, server, state) + .await; + player.apply_tool_damage_for_block_break(state).await; + if can_harvest { + player.add_exhaustion(MINE_BLOCK_EXHAUSTION).await; + } + } + } else { + let runtime_id = pumpkin_data::BlockState::to_be_network_id(state.id); + self.enqueue_packet(&CUpdateBlock::new(location, runtime_id as u32)) + .await; + if matches!(action, PlayerAction::StopBreak) { + player.stop_mining().await; + } else { + world + .set_block_breaking( + entity, + location, + BlockBreakingProgress::Update { + stage: player + .current_block_destroy_stage + .load(Ordering::Relaxed), + speed: Some(speed), + }, + ) + .await; + } + } + } else if matches!(action, PlayerAction::StopBreak) { + player.stop_mining().await; + } + } + PlayerAction::CrackBreak => { + // Don't do anything for this action. It is no longer used. Block + // cracking is done fully server-side. + } + PlayerAction::AbortBreak => { + player.stop_mining().await; + } + PlayerAction::DropItem => { + player.drop_held_item(false).await; + } + PlayerAction::Respawn + if player.living_entity.dead.load(Ordering::Relaxed) + || player.living_entity.health.load() <= 0.0 => + { + player.world().respawn_player(player, false).await; + } + // TODO + _ => {} + } + } +} diff --git a/crates/pumpkin/src/net/bedrock/play/player_auth_input.rs b/crates/pumpkin/src/net/bedrock/play/player_auth_input.rs new file mode 100644 index 000000000..ac8854744 --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/player_auth_input.rs @@ -0,0 +1,208 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + #[expect(clippy::too_many_lines)] + pub async fn handle_player_auth_input( + &self, + player: &Arc, + packet: SPlayerAuthInput, + server: &Arc, + ) { + if !player.has_client_loaded() { + return; + } + if player.living_entity.dead.load(Ordering::Relaxed) + || player.living_entity.health.load() <= 0.0 + { + return; + } + let entity = player.get_entity(); + let on_ground = packet.input_data.get(InputData::VerticalCollision as usize) + && packet.delta.y < 0.0 + && !entity.has_vehicle().await; + entity.on_ground.store(on_ground, Ordering::Relaxed); + + let new_pos = packet + .position + .add_raw(0.0, -entity.entity_type.eye_height, 0.0) + .to_f64(); + let old_pos = player.position(); + + let new_pitch = packet.pitch; + let new_yaw = packet.yaw; + + let old_pitch = entity.pitch.load(); + let old_yaw = entity.yaw.load(); + + let pos_changed = new_pos != old_pos; + let rot_changed = new_pitch != old_pitch || new_yaw != old_yaw; + + if pos_changed || rot_changed { + let world = player.world(); + + if pos_changed { + player.get_entity().set_pos(new_pos); + } + if rot_changed { + entity.pitch.store(new_pitch); + entity.yaw.store(new_yaw); + } + + let je_yaw = (new_yaw * 256.0 / 360.0).rem_euclid(256.0); + let je_pitch = (new_pitch * 256.0 / 360.0).rem_euclid(256.0); + + let delta = pumpkin_util::math::vector3::Vector3::new( + new_pos.x - old_pos.x, + new_pos.y - old_pos.y, + new_pos.z - old_pos.z, + ); + + let bedrock_move_packet = pumpkin_protocol::bedrock::client::CMovePlayer::new( + pumpkin_protocol::codec::var_ulong::VarULong(player.entity_id() as u64), + pumpkin_util::math::vector3::Vector3::new( + new_pos.x as f32, + new_pos.y as f32 + entity.entity_type.eye_height, + new_pos.z as f32, + ), + new_pitch, + new_yaw, + new_yaw, // Head yaw + pumpkin_protocol::bedrock::client::CMovePlayer::MODE_NORMAL, + on_ground, + pumpkin_protocol::codec::var_ulong::VarULong(0), + 0, + 0, + pumpkin_protocol::codec::var_ulong::VarULong(0), + ); + + if pos_changed && delta.length_squared() >= 64.0 { + world.broadcast_packet_except( + &[player.gameprofile.id], + &pumpkin_protocol::java::client::play::CEntityPositionSync::new( + player.entity_id().into(), + new_pos, + pumpkin_util::math::vector3::Vector3::new(0.0, 0.0, 0.0), + je_yaw, + je_pitch, + on_ground, + ), + ); + } else if pos_changed && rot_changed { + world.broadcast_packet_except_editioned_sync( + &[player.gameprofile.id], + &pumpkin_protocol::java::client::play::CUpdateEntityPosRot::new( + player.entity_id().into(), + pumpkin_util::math::vector3::Vector3::new( + new_pos.x.mul_add(4096.0, -(old_pos.x * 4096.0)) as i16, + new_pos.y.mul_add(4096.0, -(old_pos.y * 4096.0)) as i16, + new_pos.z.mul_add(4096.0, -(old_pos.z * 4096.0)) as i16, + ), + je_yaw as u8, // Use converted Java byte + je_pitch as u8, // Use converted Java byte + on_ground, + ), + &bedrock_move_packet, + ); + } else if pos_changed { + world.broadcast_packet_except_editioned_sync( + &[player.gameprofile.id], + &pumpkin_protocol::java::client::play::CUpdateEntityPos::new( + player.entity_id().into(), + pumpkin_util::math::vector3::Vector3::new( + new_pos.x.mul_add(4096.0, -(old_pos.x * 4096.0)) as i16, + new_pos.y.mul_add(4096.0, -(old_pos.y * 4096.0)) as i16, + new_pos.z.mul_add(4096.0, -(old_pos.z * 4096.0)) as i16, + ), + on_ground, + ), + &bedrock_move_packet, + ); + } else if rot_changed { + world.broadcast_packet_except_editioned_sync( + &[player.gameprofile.id], + &pumpkin_protocol::java::client::play::CUpdateEntityRot::new( + player.entity_id().into(), + je_yaw as u8, // Use converted Java byte + je_pitch as u8, // Use converted Java byte + on_ground, + ), + &bedrock_move_packet, + ); + } + + if rot_changed { + world.broadcast_packet_except( + &[player.gameprofile.id], + // Adjust to `CHeadRot` if that is what your crate currently calls it + &pumpkin_protocol::java::client::play::CHeadRot::new( + player.entity_id().into(), + je_yaw as u8, + ), + ); + } + + if pos_changed { + chunker::update_position(player).await; + player.progress_motion(delta).await; + } + } + + let input_data = packet.input_data; + + if input_data.get(InputData::StartSprinting as usize) { + entity.set_sprinting(true).await; + } else if input_data.get(InputData::StopSprinting as usize) { + entity.set_sprinting(false).await; + } + + if input_data.get(InputData::StartSneaking as usize) { + entity.set_sneaking(true).await; + } else if input_data.get(InputData::StopSneaking as usize) { + entity.set_sneaking(false).await; + } + + if input_data.get(InputData::StartFlying as usize) { + let flying = { player.abilities.lock().await.flying }; + if !flying { + send_cancellable! {{ + server; + PlayerToggleFlightEvent::new(player.clone(), true); + 'after: { + { + player.abilities.lock().await.flying = true; + }; + player.send_abilities_update().await; + } + 'cancelled: { + player.send_abilities_update().await; + } + }} + } + } else if input_data.get(InputData::StopFlying as usize) { + let flying = { player.abilities.lock().await.flying }; + if flying { + send_cancellable! {{ + server; + PlayerToggleFlightEvent::new(player.clone(), false); + 'after: { + { + player.abilities.lock().await.flying = false; + }; + player.send_abilities_update().await; + } + 'cancelled: { + player.send_abilities_update().await; + } + }} + } + } + + if let Some(block_actions) = packet.block_actions { + for action in block_actions { + self.handle_player_block_action(player, server, action) + .await; + } + } + } +} diff --git a/crates/pumpkin/src/net/bedrock/play/player_block_action.rs b/crates/pumpkin/src/net/bedrock/play/player_block_action.rs new file mode 100644 index 000000000..a9cbcd5aa --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/player_block_action.rs @@ -0,0 +1,28 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + pub async fn handle_player_block_action( + &self, + player: &Arc, + server: &Server, + packet: pumpkin_protocol::bedrock::server::player_auth_input::PlayerBlockAction, + ) { + use pumpkin_protocol::bedrock::server::player_action::Action as PlayerAction; + let Ok(action) = PlayerAction::try_from(packet.action.0) else { + return; + }; + self.handle_player_action( + player, + server, + SPlayerAction { + runtime_id: VarULong(0), // Unused + action, + block_pos: packet.block_pos, + result_pos: BlockPos::ZERO, + face: packet.face, + }, + ) + .await; + } +} diff --git a/crates/pumpkin/src/net/bedrock/play/request_ability.rs b/crates/pumpkin/src/net/bedrock/play/request_ability.rs new file mode 100644 index 000000000..9fba99324 --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/request_ability.rs @@ -0,0 +1,34 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + pub async fn handle_request_ability( + &self, + player: &Arc, + packet: pumpkin_protocol::bedrock::server::request_ability::SRequestAbility, + ) { + player.update_last_action_time(); + let ability_id = packet.ability.0; + match ability_id { + 9 => { + // Flying + if let pumpkin_protocol::bedrock::server::request_ability::AbilityValue::Bool( + requested_flying, + ) = packet.value + { + let mut abilities = player.abilities.lock().await; + if abilities.allow_flying { + abilities.flying = requested_flying; + } else { + abilities.flying = false; + } + drop(abilities); + player.send_abilities_update().await; + } + } + _ => { + debug!("Received RequestAbility packet for unhandled ability {ability_id}"); + } + } + } +} diff --git a/crates/pumpkin/src/net/bedrock/play/request_chunk_radius.rs b/crates/pumpkin/src/net/bedrock/play/request_chunk_radius.rs new file mode 100644 index 000000000..2c72374b2 --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/request_chunk_radius.rs @@ -0,0 +1,51 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + pub async fn handle_request_chunk_radius( + &self, + player: &Arc, + packet: SRequestChunkRadius, + ) { + let chunk_radius = packet.chunk_radius; + if chunk_radius.0 < 1 { + self.kick( + DisconnectReason::Kicked, + "Cannot have zero or negative view distance!".to_string(), + ) + .await; + return; + } + let Some(server) = player.world().server.upgrade() else { + return; + }; + + let view_distance = chunk_radius.clamp( + 2, + NonZeroI32::from(server.advanced_config.networking.bedrock.view_distance).get(), + ); + + self.enqueue_packet(&CChunkRadiusUpdate { + chunk_radius: VarInt(view_distance), + }) + .await; + + let old_view_distance = { + let current_config = player.config.load(); + let old_vd = current_config.view_distance; + let mut new_config = (**current_config).clone(); + + new_config.view_distance = + NonZero::new(view_distance as u8).unwrap_or(NonZero::::MIN); + player.config.store(std::sync::Arc::new(new_config)); + + old_vd + }; + + debug!( + "Player {} updated their render distance: {} -> {}.", + player.gameprofile.name, old_view_distance, view_distance + ); + chunker::update_position(player).await; + } +} diff --git a/crates/pumpkin/src/net/bedrock/play/respawn.rs b/crates/pumpkin/src/net/bedrock/play/respawn.rs new file mode 100644 index 000000000..70c20b577 --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/respawn.rs @@ -0,0 +1,26 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + pub async fn handle_respawn(&self, player: &Arc, packet: SRespawn) { + if packet.state != RespawnState::ClientReadyToSpawn + || (!player.living_entity.dead.load(Ordering::Relaxed) + && player.living_entity.health.load() > 0.0) + { + return; + } + + let entity = player.get_entity(); + let position = entity.pos.load(); + self.enqueue_packet(&CRespawn::new( + pumpkin_util::math::vector3::Vector3::new( + position.x as f32, + position.y as f32 + entity.entity_type.eye_height, + position.z as f32, + ), + RespawnState::ReadyToSpawn, + VarULong(player.entity_id() as u64), + )) + .await; + } +} diff --git a/crates/pumpkin/src/net/bedrock/play/set_local_player_as_initialized.rs b/crates/pumpkin/src/net/bedrock/play/set_local_player_as_initialized.rs new file mode 100644 index 000000000..418668ae3 --- /dev/null +++ b/crates/pumpkin/src/net/bedrock/play/set_local_player_as_initialized.rs @@ -0,0 +1,17 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl BedrockClient { + pub fn handle_set_local_player_as_initialized( + &self, + player: &Arc, + packet: &SSetLocalPlayerAsInitialized, + ) { + debug!( + "Player {} initialized (Runtime ID: {})", + player.gameprofile.name, packet.runtime_entity_id.0 + ); + // This is sent when the client has finished loading and rendering the world. + player.set_client_loaded(true); + } +} diff --git a/crates/pumpkin/src/net/java/config.rs b/crates/pumpkin/src/net/java/config.rs deleted file mode 100644 index a56d9a486..000000000 --- a/crates/pumpkin/src/net/java/config.rs +++ /dev/null @@ -1,309 +0,0 @@ -use std::{num::NonZeroU8, sync::Arc, sync::atomic::Ordering}; - -use crate::{ - entity::player::ChatMode, - net::{ - PlayerConfig, can_not_join, - java::{JavaClient, PacketHandlerResult}, - }, - server::Server, -}; -use core::str; -use pumpkin_data::{registry::Registry, translation}; -use pumpkin_protocol::{ - ConnectionState, KnownPack, - java::{ - client::config::{CFeatureFlags, CFinishConfig, CKnownPacks, CRegistryData, CUpdateTags}, - server::config::{ - ResourcePackResponseResult, SClientInformationConfig, SConfigCookieResponse, - SConfigResourcePack, SKeepAlive, SKnownPacks, SPluginMessage, - }, - }, -}; -use pumpkin_util::{Hand, text::TextComponent, version::JavaMinecraftVersion}; -use tracing::{debug, trace, warn}; - -const BRAND_CHANNEL_PREFIX: &str = "minecraft:brand"; - -impl JavaClient { - pub async fn handle_client_information_config( - &self, - client_information: SClientInformationConfig<'_>, - ) { - debug!("Handling client settings"); - if client_information.view_distance <= 0 { - self.kick(TextComponent::text( - "Cannot have zero or negative view distance!", - )) - .await; - return; - } - - if let (Ok(main_hand), Ok(chat_mode)) = ( - Hand::try_from(client_information.main_hand.0), - ChatMode::try_from(client_information.chat_mode.0), - ) { - self.config.store(Arc::new(PlayerConfig { - locale: client_information.locale.to_string(), - // client_information.view_distance was checked above to be > 0 so compiler should optimize this out. - view_distance: NonZeroU8::new(client_information.view_distance as u8) - .unwrap_or(NonZeroU8::MIN), - chat_mode, - chat_colors: client_information.chat_colors, - skin_parts: client_information.skin_parts, - main_hand, - text_filtering: client_information.text_filtering, - server_listing: client_information.server_listing, - })); - } else { - self.kick(TextComponent::text("Invalid hand or chat type")) - .await; - } - } - - pub async fn handle_plugin_message(&self, plugin_message: SPluginMessage<'_>) { - debug!("Handling plugin message"); - if plugin_message.channel.starts_with(BRAND_CHANNEL_PREFIX) { - debug!("Got a client brand"); - match str::from_utf8(plugin_message.data) { - Ok(brand) => self.brand.store(Arc::new(Some(brand.to_string()))), - Err(e) => self.kick(TextComponent::text(e.to_string())).await, - } - } - } - - pub async fn handle_resource_pack_response( - &self, - server: &Server, - packet: SConfigResourcePack, - ) { - let resource_config = &server.advanced_config.resource_pack.java; - if resource_config.enabled { - let expected_uuid = - uuid::Uuid::new_v3(&uuid::Uuid::NAMESPACE_DNS, resource_config.url.as_bytes()); - - if packet.uuid == expected_uuid { - match packet.response_result() { - ResourcePackResponseResult::DownloadSuccess => { - trace!( - "Client {} successfully downloaded the resource pack", - self.id - ); - } - ResourcePackResponseResult::DownloadFail => { - warn!( - "Client {} failed to downloaded the resource pack. Is it available on the internet?", - self.id - ); - } - ResourcePackResponseResult::Downloaded => { - trace!("Client {} already has the resource pack", self.id); - } - ResourcePackResponseResult::Accepted => { - trace!("Client {} accepted the resource pack", self.id); - - // Return here to wait for the next response update - return; - } - ResourcePackResponseResult::Declined => { - trace!("Client {} declined the resource pack", self.id); - } - ResourcePackResponseResult::InvalidUrl => { - warn!( - "Client {} reported that the resource pack URL is invalid!", - self.id - ); - } - ResourcePackResponseResult::ReloadFailed => { - trace!("Client {} failed to reload the resource pack", self.id); - } - ResourcePackResponseResult::Discarded => { - trace!("Client {} discarded the resource pack", self.id); - } - ResourcePackResponseResult::Unknown(result) => { - warn!( - "Client {} responded with a bad result: {}!", - self.id, result - ); - } - } - } else { - warn!( - "Client {} returned a response for a resource pack we did not set!", - self.id - ); - } - } else { - warn!( - "Client {} returned a response for a resource pack that was not enabled!", - self.id - ); - } - self.send_known_packs().await; - } - - pub async fn send_known_packs(&self) { - let version_str = self.version.load().to_string(); - self.send_packet_now(&CKnownPacks::new(&[KnownPack { - namespace: "minecraft", - id: "core", - version: &version_str, - }])) - .await; - } - - pub fn handle_config_cookie_response(&self, packet: &SConfigCookieResponse<'_>) { - // TODO: allow plugins to access this - debug!( - "Received cookie_response[config]: key: \"{}\", has_payload: \"{}\", payload_length: \"{:?}\"", - packet.key, - packet.has_payload, - packet.payload.as_ref().map(|p| p.len()), - ); - } - - pub async fn handle_known_packs( - &self, - _config_acknowledged: SKnownPacks<'_>, - server: &Server, - ) -> Option { - debug!("Handling known packs"); - // let mut tags_to_send = Vec::new(); - let version = self.version.load(); - if version >= JavaMinecraftVersion::V_1_20_2 { - self.send_packet_now(&CFeatureFlags::new(&["minecraft:vanilla".to_string()])) - .await; - let registry = Registry::get_synced(version); - let mut sent_dimension_type = false; - for reg in ®istry { - if reg.registry_id == "minecraft:dimension_type" { - sent_dimension_type = true; - } - self.send_packet_now(&CRegistryData::new(®.registry_id, ®.registry_entries)) - .await; - } - if !sent_dimension_type { - let dims = [ - &pumpkin_data::dimension::Dimension::OVERWORLD, - &pumpkin_data::dimension::Dimension::OVERWORLD_CAVES, - &pumpkin_data::dimension::Dimension::THE_END, - &pumpkin_data::dimension::Dimension::THE_NETHER, - ]; - let dim_entries: Vec = dims - .iter() - .map(|dim| pumpkin_data::registry::RegistryEntryData { - entry_id: dim.minecraft_name.to_string(), - data: Some(build_dimension_nbt(dim).into_boxed_slice()), - }) - .collect(); - self.send_packet_now(&CRegistryData::new( - &"minecraft:dimension_type".to_string(), - &dim_entries, - )) - .await; - } - } - let all_keys = [ - pumpkin_data::tag::RegistryKey::BannerPattern, - pumpkin_data::tag::RegistryKey::Block, - pumpkin_data::tag::RegistryKey::CatVariant, - pumpkin_data::tag::RegistryKey::DamageType, - pumpkin_data::tag::RegistryKey::Dialog, - pumpkin_data::tag::RegistryKey::DimensionType, - pumpkin_data::tag::RegistryKey::Enchantment, - pumpkin_data::tag::RegistryKey::EntityType, - pumpkin_data::tag::RegistryKey::Fluid, - pumpkin_data::tag::RegistryKey::GameEvent, - pumpkin_data::tag::RegistryKey::Instrument, - pumpkin_data::tag::RegistryKey::Item, - pumpkin_data::tag::RegistryKey::PaintingVariant, - pumpkin_data::tag::RegistryKey::PointOfInterestType, - pumpkin_data::tag::RegistryKey::Potion, - pumpkin_data::tag::RegistryKey::Timeline, - pumpkin_data::tag::RegistryKey::WorldgenBiome, - ]; - - let mut tags = Vec::new(); - for key in all_keys { - if pumpkin_data::tag::get_registry_key_tags(version, key) - .is_some_and(|map| !map.is_empty()) - { - tags.push(key); - } - } - self.send_packet_now(&CUpdateTags::new(&tags)).await; - - // We are done with configuring - self.send_packet_now(&CFinishConfig).await; - - if version < JavaMinecraftVersion::V_1_20_2 { - return Some(self.handle_config_acknowledged(server).await); - } - - debug!("Finished config"); - None - } - - pub async fn handle_config_keep_alive(&self, keep_alive: SKeepAlive) { - if self.wait_for_keep_alive.load(Ordering::Relaxed) - && keep_alive.keep_alive_id == self.keep_alive_id.load() - { - self.wait_for_keep_alive.store(false, Ordering::Relaxed); - } else { - self.kick(pumpkin_macros::translate_cross!( - translation::java::DISCONNECT_TIMEOUT, - translation::bedrock::DISCONNECT_TIMEOUT - )) - .await; - } - } - - pub async fn handle_config_acknowledged(&self, server: &Server) -> PacketHandlerResult { - debug!("Handling config acknowledgement"); - self.connection_state.store(ConnectionState::Play); - - let profile = self.gameprofile.clone(); - let address = self.address; - - if let Some(reason) = can_not_join(&profile, &address, server).await { - self.kick(reason).await; - return PacketHandlerResult::Stop; - } - - let config = self.config.load(); - PacketHandlerResult::ReadyToPlay(profile, (**config).clone()) - } -} - -fn build_dimension_nbt(dim: &pumpkin_data::dimension::Dimension) -> Vec { - let mut compound = pumpkin_nbt::compound::NbtCompound::new(); - compound.put_float("ambient_light", dim.ambient_light); - compound.put_int("height", dim.height); - compound.put_int("logical_height", dim.logical_height); - compound.put_int("min_y", dim.min_y); - compound.put_string("infiniburn", dim.infiniburn.to_string()); - compound.put_int( - "monster_spawn_block_light_limit", - dim.monster_spawn_block_light_limit as i32, - ); - compound.put_double("coordinate_scale", dim.coordinate_scale); - compound.put_byte("has_skylight", i8::from(dim.has_skylight)); - compound.put_byte("has_ceiling", i8::from(dim.has_ceiling)); - compound.put_byte("ultrawarm", i8::from(dim.id == 3)); - compound.put_byte("natural", i8::from(dim.id == 0 || dim.id == 1)); - compound.put_byte("piglin_safe", i8::from(dim.id == 3)); - compound.put_byte("respawn_anchor_works", i8::from(dim.id == 3)); - compound.put_byte("bed_works", i8::from(dim.id == 0 || dim.id == 1)); - compound.put_byte("has_raids", i8::from(dim.id == 0 || dim.id == 1)); - compound.put_string("effects", dim.minecraft_name.to_string()); - - let mut monster_spawn = pumpkin_nbt::compound::NbtCompound::new(); - monster_spawn.put_string("type", "minecraft:uniform".to_string()); - let mut value = pumpkin_nbt::compound::NbtCompound::new(); - value.put_int("min_inclusive", 0); - value.put_int("max_inclusive", 7); - monster_spawn.put_compound("value", value); - compound.put_compound("monster_spawn_light_level", monster_spawn); - - pumpkin_nbt::Nbt::from(compound).write().to_vec() -} diff --git a/crates/pumpkin/src/net/java/config/client_information.rs b/crates/pumpkin/src/net/java/config/client_information.rs new file mode 100644 index 000000000..9bbe2713d --- /dev/null +++ b/crates/pumpkin/src/net/java/config/client_information.rs @@ -0,0 +1,39 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_client_information_config( + &self, + client_information: SClientInformationConfig<'_>, + ) { + debug!("Handling client settings"); + if client_information.view_distance <= 0 { + self.kick(TextComponent::text( + "Cannot have zero or negative view distance!", + )) + .await; + return; + } + + if let (Ok(main_hand), Ok(chat_mode)) = ( + Hand::try_from(client_information.main_hand.0), + ChatMode::try_from(client_information.chat_mode.0), + ) { + self.config.store(Arc::new(PlayerConfig { + locale: client_information.locale.to_string(), + // client_information.view_distance was checked above to be > 0 so compiler should optimize this out. + view_distance: NonZeroU8::new(client_information.view_distance as u8) + .unwrap_or(NonZeroU8::MIN), + chat_mode, + chat_colors: client_information.chat_colors, + skin_parts: client_information.skin_parts, + main_hand, + text_filtering: client_information.text_filtering, + server_listing: client_information.server_listing, + })); + } else { + self.kick(TextComponent::text("Invalid hand or chat type")) + .await; + } + } +} diff --git a/crates/pumpkin/src/net/java/config/config_acknowledged.rs b/crates/pumpkin/src/net/java/config/config_acknowledged.rs new file mode 100644 index 000000000..1adcab24f --- /dev/null +++ b/crates/pumpkin/src/net/java/config/config_acknowledged.rs @@ -0,0 +1,53 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_config_acknowledged(&self, server: &Server) -> PacketHandlerResult { + debug!("Handling config acknowledgement"); + self.connection_state.store(ConnectionState::Play); + + let profile = self.gameprofile.clone(); + let address = self.address; + + if let Some(reason) = can_not_join(&profile, &address, server).await { + self.kick(reason).await; + return PacketHandlerResult::Stop; + } + + let config = self.config.load(); + PacketHandlerResult::ReadyToPlay(profile, (**config).clone()) + } +} + +pub(crate) fn build_dimension_nbt(dim: &pumpkin_data::dimension::Dimension) -> Vec { + let mut compound = pumpkin_nbt::compound::NbtCompound::new(); + compound.put_float("ambient_light", dim.ambient_light); + compound.put_int("height", dim.height); + compound.put_int("logical_height", dim.logical_height); + compound.put_int("min_y", dim.min_y); + compound.put_string("infiniburn", dim.infiniburn.to_string()); + compound.put_int( + "monster_spawn_block_light_limit", + dim.monster_spawn_block_light_limit as i32, + ); + compound.put_double("coordinate_scale", dim.coordinate_scale); + compound.put_byte("has_skylight", i8::from(dim.has_skylight)); + compound.put_byte("has_ceiling", i8::from(dim.has_ceiling)); + compound.put_byte("ultrawarm", i8::from(dim.id == 3)); + compound.put_byte("natural", i8::from(dim.id == 0 || dim.id == 1)); + compound.put_byte("piglin_safe", i8::from(dim.id == 3)); + compound.put_byte("respawn_anchor_works", i8::from(dim.id == 3)); + compound.put_byte("bed_works", i8::from(dim.id == 0 || dim.id == 1)); + compound.put_byte("has_raids", i8::from(dim.id == 0 || dim.id == 1)); + compound.put_string("effects", dim.minecraft_name.to_string()); + + let mut monster_spawn = pumpkin_nbt::compound::NbtCompound::new(); + monster_spawn.put_string("type", "minecraft:uniform".to_string()); + let mut value = pumpkin_nbt::compound::NbtCompound::new(); + value.put_int("min_inclusive", 0); + value.put_int("max_inclusive", 7); + monster_spawn.put_compound("value", value); + compound.put_compound("monster_spawn_light_level", monster_spawn); + + pumpkin_nbt::Nbt::from(compound).write().to_vec() +} diff --git a/crates/pumpkin/src/net/java/config/cookie_response.rs b/crates/pumpkin/src/net/java/config/cookie_response.rs new file mode 100644 index 000000000..23748e9f8 --- /dev/null +++ b/crates/pumpkin/src/net/java/config/cookie_response.rs @@ -0,0 +1,14 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub fn handle_config_cookie_response(&self, packet: &SConfigCookieResponse<'_>) { + // TODO: allow plugins to access this + debug!( + "Received cookie_response[config]: key: \"{}\", has_payload: \"{}\", payload_length: \"{:?}\"", + packet.key, + packet.has_payload, + packet.payload.as_ref().map(|p| p.len()), + ); + } +} diff --git a/crates/pumpkin/src/net/java/config/keep_alive.rs b/crates/pumpkin/src/net/java/config/keep_alive.rs new file mode 100644 index 000000000..a471e42c7 --- /dev/null +++ b/crates/pumpkin/src/net/java/config/keep_alive.rs @@ -0,0 +1,18 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_config_keep_alive(&self, keep_alive: SKeepAlive) { + if self.wait_for_keep_alive.load(Ordering::Relaxed) + && keep_alive.keep_alive_id == self.keep_alive_id.load() + { + self.wait_for_keep_alive.store(false, Ordering::Relaxed); + } else { + self.kick(pumpkin_macros::translate_cross!( + translation::java::DISCONNECT_TIMEOUT, + translation::bedrock::DISCONNECT_TIMEOUT + )) + .await; + } + } +} diff --git a/crates/pumpkin/src/net/java/config/known_packs.rs b/crates/pumpkin/src/net/java/config/known_packs.rs new file mode 100644 index 000000000..d8441c696 --- /dev/null +++ b/crates/pumpkin/src/net/java/config/known_packs.rs @@ -0,0 +1,86 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_known_packs( + &self, + _config_acknowledged: SKnownPacks<'_>, + server: &Server, + ) -> Option { + debug!("Handling known packs"); + // let mut tags_to_send = Vec::new(); + let version = self.version.load(); + if version >= JavaMinecraftVersion::V_1_20_2 { + self.send_packet_now(&CFeatureFlags::new(&["minecraft:vanilla".to_string()])) + .await; + let registry = Registry::get_synced(version); + let mut sent_dimension_type = false; + for reg in ®istry { + if reg.registry_id == "minecraft:dimension_type" { + sent_dimension_type = true; + } + self.send_packet_now(&CRegistryData::new(®.registry_id, ®.registry_entries)) + .await; + } + if !sent_dimension_type { + let dims = [ + &pumpkin_data::dimension::Dimension::OVERWORLD, + &pumpkin_data::dimension::Dimension::OVERWORLD_CAVES, + &pumpkin_data::dimension::Dimension::THE_END, + &pumpkin_data::dimension::Dimension::THE_NETHER, + ]; + let dim_entries: Vec = dims + .iter() + .map(|dim| pumpkin_data::registry::RegistryEntryData { + entry_id: dim.minecraft_name.to_string(), + data: Some(build_dimension_nbt(dim).into_boxed_slice()), + }) + .collect(); + self.send_packet_now(&CRegistryData::new( + &"minecraft:dimension_type".to_string(), + &dim_entries, + )) + .await; + } + } + let all_keys = [ + pumpkin_data::tag::RegistryKey::BannerPattern, + pumpkin_data::tag::RegistryKey::Block, + pumpkin_data::tag::RegistryKey::CatVariant, + pumpkin_data::tag::RegistryKey::DamageType, + pumpkin_data::tag::RegistryKey::Dialog, + pumpkin_data::tag::RegistryKey::DimensionType, + pumpkin_data::tag::RegistryKey::Enchantment, + pumpkin_data::tag::RegistryKey::EntityType, + pumpkin_data::tag::RegistryKey::Fluid, + pumpkin_data::tag::RegistryKey::GameEvent, + pumpkin_data::tag::RegistryKey::Instrument, + pumpkin_data::tag::RegistryKey::Item, + pumpkin_data::tag::RegistryKey::PaintingVariant, + pumpkin_data::tag::RegistryKey::PointOfInterestType, + pumpkin_data::tag::RegistryKey::Potion, + pumpkin_data::tag::RegistryKey::Timeline, + pumpkin_data::tag::RegistryKey::WorldgenBiome, + ]; + + let mut tags = Vec::new(); + for key in all_keys { + if pumpkin_data::tag::get_registry_key_tags(version, key) + .is_some_and(|map| !map.is_empty()) + { + tags.push(key); + } + } + self.send_packet_now(&CUpdateTags::new(&tags)).await; + + // We are done with configuring + self.send_packet_now(&CFinishConfig).await; + + if version < JavaMinecraftVersion::V_1_20_2 { + return Some(self.handle_config_acknowledged(server).await); + } + + debug!("Finished config"); + None + } +} diff --git a/crates/pumpkin/src/net/java/config/mod.rs b/crates/pumpkin/src/net/java/config/mod.rs new file mode 100644 index 000000000..0fe2f965e --- /dev/null +++ b/crates/pumpkin/src/net/java/config/mod.rs @@ -0,0 +1,35 @@ +use std::{num::NonZeroU8, sync::Arc, sync::atomic::Ordering}; + +use crate::{ + entity::player::ChatMode, + net::{ + PlayerConfig, can_not_join, + java::{JavaClient, PacketHandlerResult}, + }, + server::Server, +}; +use core::str; +use pumpkin_data::{registry::Registry, translation}; +use pumpkin_protocol::{ + ConnectionState, KnownPack, + java::{ + client::config::{CFeatureFlags, CFinishConfig, CKnownPacks, CRegistryData, CUpdateTags}, + server::config::{ + ResourcePackResponseResult, SClientInformationConfig, SConfigCookieResponse, + SConfigResourcePack, SKeepAlive, SKnownPacks, SPluginMessage, + }, + }, +}; +use pumpkin_util::{Hand, text::TextComponent, version::JavaMinecraftVersion}; +use tracing::{debug, trace, warn}; + +const BRAND_CHANNEL_PREFIX: &str = "minecraft:brand"; + +pub mod client_information; +pub mod config_acknowledged; +pub(super) use config_acknowledged::build_dimension_nbt; +pub mod cookie_response; +pub mod keep_alive; +pub mod known_packs; +pub mod plugin_message; +pub mod resource_pack; diff --git a/crates/pumpkin/src/net/java/config/plugin_message.rs b/crates/pumpkin/src/net/java/config/plugin_message.rs new file mode 100644 index 000000000..0ccc6a076 --- /dev/null +++ b/crates/pumpkin/src/net/java/config/plugin_message.rs @@ -0,0 +1,15 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_plugin_message(&self, plugin_message: SPluginMessage<'_>) { + debug!("Handling plugin message"); + if plugin_message.channel.starts_with(BRAND_CHANNEL_PREFIX) { + debug!("Got a client brand"); + match str::from_utf8(plugin_message.data) { + Ok(brand) => self.brand.store(Arc::new(Some(brand.to_string()))), + Err(e) => self.kick(TextComponent::text(e.to_string())).await, + } + } + } +} diff --git a/crates/pumpkin/src/net/java/config/resource_pack.rs b/crates/pumpkin/src/net/java/config/resource_pack.rs new file mode 100644 index 000000000..dcc2308b4 --- /dev/null +++ b/crates/pumpkin/src/net/java/config/resource_pack.rs @@ -0,0 +1,84 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_resource_pack_response( + &self, + server: &Server, + packet: SConfigResourcePack, + ) { + let resource_config = &server.advanced_config.resource_pack.java; + if resource_config.enabled { + let expected_uuid = + uuid::Uuid::new_v3(&uuid::Uuid::NAMESPACE_DNS, resource_config.url.as_bytes()); + + if packet.uuid == expected_uuid { + match packet.response_result() { + ResourcePackResponseResult::DownloadSuccess => { + trace!( + "Client {} successfully downloaded the resource pack", + self.id + ); + } + ResourcePackResponseResult::DownloadFail => { + warn!( + "Client {} failed to downloaded the resource pack. Is it available on the internet?", + self.id + ); + } + ResourcePackResponseResult::Downloaded => { + trace!("Client {} already has the resource pack", self.id); + } + ResourcePackResponseResult::Accepted => { + trace!("Client {} accepted the resource pack", self.id); + + // Return here to wait for the next response update + return; + } + ResourcePackResponseResult::Declined => { + trace!("Client {} declined the resource pack", self.id); + } + ResourcePackResponseResult::InvalidUrl => { + warn!( + "Client {} reported that the resource pack URL is invalid!", + self.id + ); + } + ResourcePackResponseResult::ReloadFailed => { + trace!("Client {} failed to reload the resource pack", self.id); + } + ResourcePackResponseResult::Discarded => { + trace!("Client {} discarded the resource pack", self.id); + } + ResourcePackResponseResult::Unknown(result) => { + warn!( + "Client {} responded with a bad result: {}!", + self.id, result + ); + } + } + } else { + warn!( + "Client {} returned a response for a resource pack we did not set!", + self.id + ); + } + } else { + warn!( + "Client {} returned a response for a resource pack that was not enabled!", + self.id + ); + } + self.send_known_packs().await; + } + + pub async fn send_known_packs(&self) { + let version_str = self.version.load().to_string(); + self.send_packet_now(&CKnownPacks::new(&[KnownPack { + namespace: "minecraft", + id: "core", + version: &version_str, + }])) + .await; + } +} diff --git a/crates/pumpkin/src/net/java/login.rs b/crates/pumpkin/src/net/java/login.rs deleted file mode 100644 index 0e05d4aef..000000000 --- a/crates/pumpkin/src/net/java/login.rs +++ /dev/null @@ -1,467 +0,0 @@ -use arc_swap::ArcSwap; -use pumpkin_data::translation; -use pumpkin_protocol::{ - ConnectionState, KnownPack, Label, Link, LinkType, - java::client::{ - config::{ - CConfigAddResourcePack, CConfigServerLinks, CFeatureFlags, CFinishConfig, CKnownPacks, - CRegistryData, CUpdateTags, - }, - login::{CLoginSuccess, CSetCompression}, - }, - java::server::config::SKnownPacks, - java::server::login::{ - SEncryptionResponse, SLoginCookieResponse, SLoginPluginResponse, SLoginStart, - }, -}; -use pumpkin_util::{text::TextComponent, version::JavaMinecraftVersion}; -use std::sync::Arc; -use tracing::debug; -use uuid::Uuid; - -use crate::{ - net::{ - GameProfile, - authentication::{self, AuthError}, - is_valid_player_name, - java::pending::PendingConnection, - offline_uuid, - proxy::{bungeecord, velocity}, - }, - server::Server, -}; - -impl PendingConnection { - pub async fn handle_login_start(&mut self, server: &Server, login_start: SLoginStart) { - debug!("login start"); - - let max_players = server.advanced_config.networking.java.max_players; - if max_players > 0 && server.get_player_count() >= max_players as usize { - self.kick(TextComponent::translate_cross( - translation::java::MULTIPLAYER_DISCONNECT_SERVER_FULL, - translation::bedrock::DISCONNECTIONSCREEN_SERVERFULL, - [], - )) - .await; - return; - } - - if !is_valid_player_name(&login_start.name) { - self.kick(TextComponent::text("Invalid characters in username")) - .await; - return; - } - - let proxy = &server.advanced_config.networking.proxy; - if proxy.enabled { - if proxy.velocity.enabled { - velocity::velocity_login(self).await; - } else if proxy.bungeecord.enabled { - match bungeecord::bungeecord_login( - &self.address, - &self.server_address, - login_start.name.into_string(), - ) { - Ok((_ip, profile)) => { - self.finish_login(&profile).await; - self.gameprofile = Some(profile); - } - Err(error) => self.kick(TextComponent::text(error.to_string())).await, - } - } - } else { - let id = if server.advanced_config.networking.java.online_mode { - login_start.uuid - } else { - offline_uuid(&login_start.name).unwrap_or_else(|_| uuid::Uuid::nil()) - }; - - let profile = GameProfile { - id, - name: login_start.name.into_string(), - properties: ArcSwap::new(Arc::new(vec![])), - profile_actions: None, - }; - - if server.advanced_config.networking.java.compression.enabled { - self.enable_compression(server).await; - } - - if server.advanced_config.networking.java.encryption { - let verify_token: [u8; 4] = rand::random(); - self.send_packet_now( - &server - .encryption_request( - &verify_token, - server.advanced_config.networking.java.online_mode, - ) - .await, - ) - .await; - } else { - self.finish_login(&profile).await; - } - - self.gameprofile = Some(profile); - } - } - - pub async fn handle_encryption_response( - &mut self, - server: &Server, - encryption_response: SEncryptionResponse, - ) { - debug!("Handling encryption"); - let Ok(shared_secret) = server.decrypt(&encryption_response.shared_secret).await else { - self.kick(TextComponent::text("Failed to decrypt shared secret")) - .await; - return; - }; - - if let Err(error) = self.set_encryption(&shared_secret) { - self.kick(TextComponent::text(error.to_string())).await; - return; - } - - let profile_name = { - let Some(profile) = self.gameprofile.as_ref() else { - self.kick(TextComponent::text("No `GameProfile`")).await; - return; - }; - profile.name.clone() - }; - - if server.advanced_config.networking.java.online_mode { - match self - .authenticate(server, &shared_secret, &profile_name) - .await - { - Ok(new_profile) => self.gameprofile = Some(new_profile), - Err(error) => { - self.kick(match error { - AuthError::FailedResponse => TextComponent::translate_cross( - translation::java::MULTIPLAYER_DISCONNECT_AUTHSERVERS_DOWN, - translation::bedrock::DISCONNECT_LOGINFAILEDINFO_SERVERSUNAVAILABLE, - [], - ), - AuthError::UnverifiedUsername => TextComponent::translate_cross( - translation::java::MULTIPLAYER_DISCONNECT_UNVERIFIED_USERNAME, - translation::bedrock::DISCONNECT_LOGINFAILEDINFO_INVALIDSESSION, - [], - ), - e => TextComponent::text(e.to_string()), - }) - .await; - return; - } - } - } - - let Some(profile) = self.gameprofile.clone() else { - return; - }; - - if let Some(online_player) = &server.get_player_by_uuid(profile.id) { - debug!( - "Player (IP '{}', username '{}') tried to log in with the same UUID ('{}') as an online player (username '{}')", - &self.address, &profile.name, &profile.id, &online_player.gameprofile.name - ); - self.kick(TextComponent::translate_cross( - translation::java::MULTIPLAYER_DISCONNECT_DUPLICATE_LOGIN, - translation::bedrock::DISCONNECTIONSCREEN_LOGGEDINOTHERLOCATION, - [], - )) - .await; - return; - } - - if let Some(online_player) = &server.get_player_by_name(&profile.name) { - debug!( - "A player (IP '{}', attempted username '{}') tried to log in with the same username as an online player (UUID '{}', username '{}')", - &self.address, &profile.name, &profile.id, &online_player.gameprofile.name - ); - self.kick(TextComponent::translate_cross( - translation::java::MULTIPLAYER_DISCONNECT_DUPLICATE_LOGIN, - translation::bedrock::DISCONNECTIONSCREEN_LOGGEDINOTHERLOCATION, - [], - )) - .await; - return; - } - - self.finish_login(&profile).await; - } - - async fn enable_compression(&mut self, server: &Server) { - if self.version.load() < JavaMinecraftVersion::V_1_8 { - return; - } - let compression = server - .advanced_config - .networking - .java - .compression - .info - .clone(); - self.send_packet_now(&CSetCompression::new( - pumpkin_protocol::codec::var_int::VarInt(compression.threshold as i32), - )) - .await; - self.set_compression(&compression); - } - - async fn finish_login(&mut self, profile: &GameProfile) { - let props = profile.properties.load(); - let packet = CLoginSuccess::new( - &profile.id, - &profile.name, - &props, - false, - uuid::Uuid::new_v4(), - ); - self.send_packet_now(&packet).await; - if self.version.load() < JavaMinecraftVersion::V_1_20_2 { - self.connection_state.store(ConnectionState::Play); - } - } - - async fn authenticate( - &self, - server: &Server, - shared_secret: &[u8], - username: &str, - ) -> Result { - let hash = server.digest_secret(shared_secret).await; - let ip = self.address.ip(); - let profile = authentication::authenticate( - username, - &hash, - &ip, - &server.advanced_config.networking.java.authentication, - )?; - - if let Some(actions) = &profile.profile_actions { - if server - .advanced_config - .networking - .java - .authentication - .player_profile - .allow_banned_players - { - for allowed in &server - .advanced_config - .networking - .java - .authentication - .player_profile - .allowed_actions - { - if !actions.contains(allowed) { - return Err(AuthError::DisallowedAction); - } - } - if !actions.is_empty() { - return Err(AuthError::Banned); - } - } else if !actions.is_empty() { - return Err(AuthError::Banned); - } - } - for property in profile.properties.load().iter() { - authentication::validate_textures( - property, - &server - .advanced_config - .networking - .java - .authentication - .textures, - ) - .map_err(AuthError::TextureError)?; - } - Ok(profile) - } - - pub fn handle_login_cookie_response(&self, packet: &SLoginCookieResponse<'_>) { - debug!( - "Received cookie_response[login]: key: \"{}\", payload_length: \"{:?}\"", - packet.key, - packet.payload.as_ref().map(|p| p.len()) - ); - } - - pub async fn handle_plugin_response( - &mut self, - server: &Server, - plugin_response: SLoginPluginResponse, - ) { - debug!("Handling plugin"); - let velocity_config = &server.advanced_config.networking.proxy.velocity; - if velocity_config.enabled { - match velocity::receive_velocity_plugin_response( - self.address.port(), - velocity_config, - plugin_response, - ) { - Ok((profile, new_address)) => { - self.finish_login(&profile).await; - self.gameprofile = Some(profile); - self.address = new_address; - } - Err(error) => self.kick(TextComponent::text(error.to_string())).await, - } - } - } - - pub async fn handle_login_acknowledged(&mut self, server: &Server) { - debug!("Handling login acknowledgement"); - self.connection_state.store(ConnectionState::Config); - self.send_packet_now(&server.get_branding()).await; - - if server.advanced_config.server_links.enabled - && self.version.load() >= JavaMinecraftVersion::V_1_21 - { - let mut links: Vec = Vec::new(); - - let bug_report = &server.advanced_config.server_links.bug_report; - if !bug_report.is_empty() { - links.push(Link::new(Label::BuiltIn(LinkType::BugReport), bug_report)); - } - - let support = &server.advanced_config.server_links.support; - if !support.is_empty() { - links.push(Link::new(Label::BuiltIn(LinkType::Support), support)); - } - - let status = &server.advanced_config.server_links.status; - if !status.is_empty() { - links.push(Link::new(Label::BuiltIn(LinkType::Status), status)); - } - - let feedback = &server.advanced_config.server_links.feedback; - if !feedback.is_empty() { - links.push(Link::new(Label::BuiltIn(LinkType::Feedback), feedback)); - } - - let community = &server.advanced_config.server_links.community; - if !community.is_empty() { - links.push(Link::new(Label::BuiltIn(LinkType::Community), community)); - } - - let website = &server.advanced_config.server_links.website; - if !website.is_empty() { - links.push(Link::new(Label::BuiltIn(LinkType::Website), website)); - } - - let forums = &server.advanced_config.server_links.forums; - if !forums.is_empty() { - links.push(Link::new(Label::BuiltIn(LinkType::Forums), forums)); - } - - let news = &server.advanced_config.server_links.news; - if !news.is_empty() { - links.push(Link::new(Label::BuiltIn(LinkType::News), news)); - } - - let announcements = &server.advanced_config.server_links.announcements; - if !announcements.is_empty() { - links.push(Link::new( - Label::BuiltIn(LinkType::Announcements), - announcements, - )); - } - - for (key, value) in &server.advanced_config.server_links.custom { - links.push(Link::new( - Label::TextComponent(TextComponent::text(key.clone()).into()), - value, - )); - } - - self.send_packet_now(&CConfigServerLinks::new(&links)).await; - } - - let resource_config = &server.advanced_config.resource_pack.java; - if resource_config.enabled { - let uuid = Uuid::new_v3(&uuid::Uuid::NAMESPACE_DNS, resource_config.url.as_bytes()); - let resource_pack = CConfigAddResourcePack::new( - &uuid, - &resource_config.url, - &resource_config.sha1, - resource_config.force, - if resource_config.prompt_message.is_empty() { - None - } else { - Some(TextComponent::text(resource_config.prompt_message.clone())) - }, - ); - - self.send_packet_now(&resource_pack).await; - } else if self.version.load() >= JavaMinecraftVersion::V_1_20_5 { - self.send_known_packs().await; - } else { - self.handle_known_packs( - SKnownPacks { - known_packs: Vec::new(), - }, - server, - ) - .await; - } - debug!("login acknowledged"); - } - - pub async fn send_known_packs(&mut self) { - let version_str = self.version.load().to_string(); - self.send_packet_now(&CKnownPacks::new(&[KnownPack { - namespace: "minecraft", - id: "core", - version: &version_str, - }])) - .await; - } - - pub async fn handle_known_packs(&mut self, _packet: SKnownPacks<'_>, _server: &Server) { - let version = self.version.load(); - if version >= JavaMinecraftVersion::V_1_20_2 { - self.send_packet_now(&CFeatureFlags::new(&["minecraft:vanilla".to_string()])) - .await; - let registry = pumpkin_data::registry::Registry::get_synced(version); - for reg in ®istry { - self.send_packet_now(&CRegistryData::new(®.registry_id, ®.registry_entries)) - .await; - } - } - let all_keys = [ - pumpkin_data::tag::RegistryKey::BannerPattern, - pumpkin_data::tag::RegistryKey::Block, - pumpkin_data::tag::RegistryKey::CatVariant, - pumpkin_data::tag::RegistryKey::DamageType, - pumpkin_data::tag::RegistryKey::Dialog, - pumpkin_data::tag::RegistryKey::DimensionType, - pumpkin_data::tag::RegistryKey::Enchantment, - pumpkin_data::tag::RegistryKey::EntityType, - pumpkin_data::tag::RegistryKey::Fluid, - pumpkin_data::tag::RegistryKey::GameEvent, - pumpkin_data::tag::RegistryKey::Instrument, - pumpkin_data::tag::RegistryKey::Item, - pumpkin_data::tag::RegistryKey::PaintingVariant, - pumpkin_data::tag::RegistryKey::PointOfInterestType, - pumpkin_data::tag::RegistryKey::Potion, - pumpkin_data::tag::RegistryKey::Timeline, - pumpkin_data::tag::RegistryKey::WorldgenBiome, - ]; - - let mut tags = Vec::new(); - for key in all_keys { - if pumpkin_data::tag::get_registry_key_tags(version, key) - .is_some_and(|map| !map.is_empty()) - { - tags.push(key); - } - } - self.send_packet_now(&CUpdateTags::new(&tags)).await; - self.send_packet_now(&CFinishConfig).await; - } -} diff --git a/crates/pumpkin/src/net/java/login/cookie_response.rs b/crates/pumpkin/src/net/java/login/cookie_response.rs new file mode 100644 index 000000000..3b8b2bb44 --- /dev/null +++ b/crates/pumpkin/src/net/java/login/cookie_response.rs @@ -0,0 +1,12 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl PendingConnection { + pub fn handle_login_cookie_response(&self, packet: &SLoginCookieResponse<'_>) { + debug!( + "Received cookie_response[login]: key: \"{}\", payload_length: \"{:?}\"", + packet.key, + packet.payload.as_ref().map(|p| p.len()) + ); + } +} diff --git a/crates/pumpkin/src/net/java/login/encryption_response.rs b/crates/pumpkin/src/net/java/login/encryption_response.rs new file mode 100644 index 000000000..be5e76d72 --- /dev/null +++ b/crates/pumpkin/src/net/java/login/encryption_response.rs @@ -0,0 +1,181 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl PendingConnection { + pub async fn handle_encryption_response( + &mut self, + server: &Server, + encryption_response: SEncryptionResponse, + ) { + debug!("Handling encryption"); + let Ok(shared_secret) = server.decrypt(&encryption_response.shared_secret).await else { + self.kick(TextComponent::text("Failed to decrypt shared secret")) + .await; + return; + }; + + if let Err(error) = self.set_encryption(&shared_secret) { + self.kick(TextComponent::text(error.to_string())).await; + return; + } + + let profile_name = { + let Some(profile) = self.gameprofile.as_ref() else { + self.kick(TextComponent::text("No `GameProfile`")).await; + return; + }; + profile.name.clone() + }; + + if server.advanced_config.networking.java.online_mode { + match self + .authenticate(server, &shared_secret, &profile_name) + .await + { + Ok(new_profile) => self.gameprofile = Some(new_profile), + Err(error) => { + self.kick(match error { + AuthError::FailedResponse => TextComponent::translate_cross( + translation::java::MULTIPLAYER_DISCONNECT_AUTHSERVERS_DOWN, + translation::bedrock::DISCONNECT_LOGINFAILEDINFO_SERVERSUNAVAILABLE, + [], + ), + AuthError::UnverifiedUsername => TextComponent::translate_cross( + translation::java::MULTIPLAYER_DISCONNECT_UNVERIFIED_USERNAME, + translation::bedrock::DISCONNECT_LOGINFAILEDINFO_INVALIDSESSION, + [], + ), + e => TextComponent::text(e.to_string()), + }) + .await; + return; + } + } + } + + let Some(profile) = self.gameprofile.clone() else { + return; + }; + + if let Some(online_player) = &server.get_player_by_uuid(profile.id) { + debug!( + "Player (IP '{}', username '{}') tried to log in with the same UUID ('{}') as an online player (username '{}')", + &self.address, &profile.name, &profile.id, &online_player.gameprofile.name + ); + self.kick(TextComponent::translate_cross( + translation::java::MULTIPLAYER_DISCONNECT_DUPLICATE_LOGIN, + translation::bedrock::DISCONNECTIONSCREEN_LOGGEDINOTHERLOCATION, + [], + )) + .await; + return; + } + + if let Some(online_player) = &server.get_player_by_name(&profile.name) { + debug!( + "A player (IP '{}', attempted username '{}') tried to log in with the same username as an online player (UUID '{}', username '{}')", + &self.address, &profile.name, &profile.id, &online_player.gameprofile.name + ); + self.kick(TextComponent::translate_cross( + translation::java::MULTIPLAYER_DISCONNECT_DUPLICATE_LOGIN, + translation::bedrock::DISCONNECTIONSCREEN_LOGGEDINOTHERLOCATION, + [], + )) + .await; + return; + } + + self.finish_login(&profile).await; + } + + pub(super) async fn enable_compression(&mut self, server: &Server) { + if self.version.load() < JavaMinecraftVersion::V_1_8 { + return; + } + let compression = server + .advanced_config + .networking + .java + .compression + .info + .clone(); + self.send_packet_now(&CSetCompression::new( + pumpkin_protocol::codec::var_int::VarInt(compression.threshold as i32), + )) + .await; + self.set_compression(&compression); + } + + pub(super) async fn finish_login(&mut self, profile: &GameProfile) { + let props = profile.properties.load(); + let packet = CLoginSuccess::new( + &profile.id, + &profile.name, + &props, + false, + uuid::Uuid::new_v4(), + ); + self.send_packet_now(&packet).await; + if self.version.load() < JavaMinecraftVersion::V_1_20_2 { + self.connection_state.store(ConnectionState::Play); + } + } + + async fn authenticate( + &self, + server: &Server, + shared_secret: &[u8], + username: &str, + ) -> Result { + let hash = server.digest_secret(shared_secret).await; + let ip = self.address.ip(); + let profile = authentication::authenticate( + username, + &hash, + &ip, + &server.advanced_config.networking.java.authentication, + )?; + + if let Some(actions) = &profile.profile_actions { + if server + .advanced_config + .networking + .java + .authentication + .player_profile + .allow_banned_players + { + for allowed in &server + .advanced_config + .networking + .java + .authentication + .player_profile + .allowed_actions + { + if !actions.contains(allowed) { + return Err(AuthError::DisallowedAction); + } + } + if !actions.is_empty() { + return Err(AuthError::Banned); + } + } else if !actions.is_empty() { + return Err(AuthError::Banned); + } + } + for property in profile.properties.load().iter() { + authentication::validate_textures( + property, + &server + .advanced_config + .networking + .java + .authentication + .textures, + ) + .map_err(AuthError::TextureError)?; + } + Ok(profile) + } +} diff --git a/crates/pumpkin/src/net/java/login/known_packs.rs b/crates/pumpkin/src/net/java/login/known_packs.rs new file mode 100644 index 000000000..afeaefd6b --- /dev/null +++ b/crates/pumpkin/src/net/java/login/known_packs.rs @@ -0,0 +1,47 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl PendingConnection { + pub async fn handle_known_packs(&mut self, _packet: SKnownPacks<'_>, _server: &Server) { + let version = self.version.load(); + if version >= JavaMinecraftVersion::V_1_20_2 { + self.send_packet_now(&CFeatureFlags::new(&["minecraft:vanilla".to_string()])) + .await; + let registry = pumpkin_data::registry::Registry::get_synced(version); + for reg in ®istry { + self.send_packet_now(&CRegistryData::new(®.registry_id, ®.registry_entries)) + .await; + } + } + let all_keys = [ + pumpkin_data::tag::RegistryKey::BannerPattern, + pumpkin_data::tag::RegistryKey::Block, + pumpkin_data::tag::RegistryKey::CatVariant, + pumpkin_data::tag::RegistryKey::DamageType, + pumpkin_data::tag::RegistryKey::Dialog, + pumpkin_data::tag::RegistryKey::DimensionType, + pumpkin_data::tag::RegistryKey::Enchantment, + pumpkin_data::tag::RegistryKey::EntityType, + pumpkin_data::tag::RegistryKey::Fluid, + pumpkin_data::tag::RegistryKey::GameEvent, + pumpkin_data::tag::RegistryKey::Instrument, + pumpkin_data::tag::RegistryKey::Item, + pumpkin_data::tag::RegistryKey::PaintingVariant, + pumpkin_data::tag::RegistryKey::PointOfInterestType, + pumpkin_data::tag::RegistryKey::Potion, + pumpkin_data::tag::RegistryKey::Timeline, + pumpkin_data::tag::RegistryKey::WorldgenBiome, + ]; + + let mut tags = Vec::new(); + for key in all_keys { + if pumpkin_data::tag::get_registry_key_tags(version, key) + .is_some_and(|map| !map.is_empty()) + { + tags.push(key); + } + } + self.send_packet_now(&CUpdateTags::new(&tags)).await; + self.send_packet_now(&CFinishConfig).await; + } +} diff --git a/crates/pumpkin/src/net/java/login/login_acknowledged.rs b/crates/pumpkin/src/net/java/login/login_acknowledged.rs new file mode 100644 index 000000000..44529edf3 --- /dev/null +++ b/crates/pumpkin/src/net/java/login/login_acknowledged.rs @@ -0,0 +1,112 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl PendingConnection { + pub async fn handle_login_acknowledged(&mut self, server: &Server) { + debug!("Handling login acknowledgement"); + self.connection_state.store(ConnectionState::Config); + self.send_packet_now(&server.get_branding()).await; + + if server.advanced_config.server_links.enabled + && self.version.load() >= JavaMinecraftVersion::V_1_21 + { + let mut links: Vec = Vec::new(); + + let bug_report = &server.advanced_config.server_links.bug_report; + if !bug_report.is_empty() { + links.push(Link::new(Label::BuiltIn(LinkType::BugReport), bug_report)); + } + + let support = &server.advanced_config.server_links.support; + if !support.is_empty() { + links.push(Link::new(Label::BuiltIn(LinkType::Support), support)); + } + + let status = &server.advanced_config.server_links.status; + if !status.is_empty() { + links.push(Link::new(Label::BuiltIn(LinkType::Status), status)); + } + + let feedback = &server.advanced_config.server_links.feedback; + if !feedback.is_empty() { + links.push(Link::new(Label::BuiltIn(LinkType::Feedback), feedback)); + } + + let community = &server.advanced_config.server_links.community; + if !community.is_empty() { + links.push(Link::new(Label::BuiltIn(LinkType::Community), community)); + } + + let website = &server.advanced_config.server_links.website; + if !website.is_empty() { + links.push(Link::new(Label::BuiltIn(LinkType::Website), website)); + } + + let forums = &server.advanced_config.server_links.forums; + if !forums.is_empty() { + links.push(Link::new(Label::BuiltIn(LinkType::Forums), forums)); + } + + let news = &server.advanced_config.server_links.news; + if !news.is_empty() { + links.push(Link::new(Label::BuiltIn(LinkType::News), news)); + } + + let announcements = &server.advanced_config.server_links.announcements; + if !announcements.is_empty() { + links.push(Link::new( + Label::BuiltIn(LinkType::Announcements), + announcements, + )); + } + + for (key, value) in &server.advanced_config.server_links.custom { + links.push(Link::new( + Label::TextComponent(TextComponent::text(key.clone()).into()), + value, + )); + } + + self.send_packet_now(&CConfigServerLinks::new(&links)).await; + } + + let resource_config = &server.advanced_config.resource_pack.java; + if resource_config.enabled { + let uuid = Uuid::new_v3(&uuid::Uuid::NAMESPACE_DNS, resource_config.url.as_bytes()); + let resource_pack = CConfigAddResourcePack::new( + &uuid, + &resource_config.url, + &resource_config.sha1, + resource_config.force, + if resource_config.prompt_message.is_empty() { + None + } else { + Some(TextComponent::text(resource_config.prompt_message.clone())) + }, + ); + + self.send_packet_now(&resource_pack).await; + } else if self.version.load() >= JavaMinecraftVersion::V_1_20_5 { + self.send_known_packs().await; + } else { + self.handle_known_packs( + SKnownPacks { + known_packs: Vec::new(), + }, + server, + ) + .await; + } + debug!("login acknowledged"); + } + + pub async fn send_known_packs(&mut self) { + let version_str = self.version.load().to_string(); + self.send_packet_now(&CKnownPacks::new(&[KnownPack { + namespace: "minecraft", + id: "core", + version: &version_str, + }])) + .await; + } +} diff --git a/crates/pumpkin/src/net/java/login/login_start.rs b/crates/pumpkin/src/net/java/login/login_start.rs new file mode 100644 index 000000000..828ae6a13 --- /dev/null +++ b/crates/pumpkin/src/net/java/login/login_start.rs @@ -0,0 +1,78 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl PendingConnection { + pub async fn handle_login_start(&mut self, server: &Server, login_start: SLoginStart) { + debug!("login start"); + + let max_players = server.advanced_config.networking.java.max_players; + if max_players > 0 && server.get_player_count() >= max_players as usize { + self.kick(TextComponent::translate_cross( + translation::java::MULTIPLAYER_DISCONNECT_SERVER_FULL, + translation::bedrock::DISCONNECTIONSCREEN_SERVERFULL, + [], + )) + .await; + return; + } + + if !is_valid_player_name(&login_start.name) { + self.kick(TextComponent::text("Invalid characters in username")) + .await; + return; + } + + let proxy = &server.advanced_config.networking.proxy; + if proxy.enabled { + if proxy.velocity.enabled { + velocity::velocity_login(self).await; + } else if proxy.bungeecord.enabled { + match bungeecord::bungeecord_login( + &self.address, + &self.server_address, + login_start.name.into_string(), + ) { + Ok((_ip, profile)) => { + self.finish_login(&profile).await; + self.gameprofile = Some(profile); + } + Err(error) => self.kick(TextComponent::text(error.to_string())).await, + } + } + } else { + let id = if server.advanced_config.networking.java.online_mode { + login_start.uuid + } else { + offline_uuid(&login_start.name).unwrap_or_else(|_| uuid::Uuid::nil()) + }; + + let profile = GameProfile { + id, + name: login_start.name.into_string(), + properties: ArcSwap::new(Arc::new(vec![])), + profile_actions: None, + }; + + if server.advanced_config.networking.java.compression.enabled { + self.enable_compression(server).await; + } + + if server.advanced_config.networking.java.encryption { + let verify_token: [u8; 4] = rand::random(); + self.send_packet_now( + &server + .encryption_request( + &verify_token, + server.advanced_config.networking.java.online_mode, + ) + .await, + ) + .await; + } else { + self.finish_login(&profile).await; + } + + self.gameprofile = Some(profile); + } + } +} diff --git a/crates/pumpkin/src/net/java/login/mod.rs b/crates/pumpkin/src/net/java/login/mod.rs new file mode 100644 index 000000000..7f6cbe9f7 --- /dev/null +++ b/crates/pumpkin/src/net/java/login/mod.rs @@ -0,0 +1,39 @@ +use arc_swap::ArcSwap; +use pumpkin_data::translation; +use pumpkin_protocol::{ + ConnectionState, KnownPack, Label, Link, LinkType, + java::client::{ + config::{ + CConfigAddResourcePack, CConfigServerLinks, CFeatureFlags, CFinishConfig, CKnownPacks, + CRegistryData, CUpdateTags, + }, + login::{CLoginSuccess, CSetCompression}, + }, + java::server::config::SKnownPacks, + java::server::login::{ + SEncryptionResponse, SLoginCookieResponse, SLoginPluginResponse, SLoginStart, + }, +}; +use pumpkin_util::{text::TextComponent, version::JavaMinecraftVersion}; +use std::sync::Arc; +use tracing::debug; +use uuid::Uuid; + +use crate::{ + net::{ + GameProfile, + authentication::{self, AuthError}, + is_valid_player_name, + java::pending::PendingConnection, + offline_uuid, + proxy::{bungeecord, velocity}, + }, + server::Server, +}; + +pub mod cookie_response; +pub mod encryption_response; +pub mod known_packs; +pub mod login_acknowledged; +pub mod login_start; +pub mod plugin_response; diff --git a/crates/pumpkin/src/net/java/login/plugin_response.rs b/crates/pumpkin/src/net/java/login/plugin_response.rs new file mode 100644 index 000000000..9524c35e0 --- /dev/null +++ b/crates/pumpkin/src/net/java/login/plugin_response.rs @@ -0,0 +1,27 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl PendingConnection { + pub async fn handle_plugin_response( + &mut self, + server: &Server, + plugin_response: SLoginPluginResponse, + ) { + debug!("Handling plugin"); + let velocity_config = &server.advanced_config.networking.proxy.velocity; + if velocity_config.enabled { + match velocity::receive_velocity_plugin_response( + self.address.port(), + velocity_config, + plugin_response, + ) { + Ok((profile, new_address)) => { + self.finish_login(&profile).await; + self.gameprofile = Some(profile); + self.address = new_address; + } + Err(error) => self.kick(TextComponent::text(error.to_string())).await, + } + } + } +} diff --git a/crates/pumpkin/src/net/java/play.rs b/crates/pumpkin/src/net/java/play.rs deleted file mode 100644 index e5beac5ea..000000000 --- a/crates/pumpkin/src/net/java/play.rs +++ /dev/null @@ -1,3033 +0,0 @@ -use pumpkin_protocol::bedrock::server::text::SText; -use pumpkin_util::{Hand, PermissionLvl}; -use rsa::pkcs1v15::{Signature as RsaPkcs1v15Signature, VerifyingKey}; -use rsa::signature::Verifier; -use sha1::Sha1; -use std::num::NonZeroU8; -use std::sync::Arc; -use std::sync::atomic::{AtomicU32, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; -use thiserror::Error; -use tracing::{Level, debug, error, info, trace, warn}; - -use crate::block::BlockHitResult; -use crate::block::registry::BlockActionResult; -use crate::block::{self}; -use crate::entity::EntityBase; -use crate::entity::equipment_break_status; -use crate::entity::player::statistics::{CustomStatistic, StatisticCategory}; -use crate::entity::player::{ChatMode, ChatSession, MINE_BLOCK_EXHAUSTION, Player}; -use crate::error::PumpkinError; -use crate::log_at_level; -use crate::net::PlayerConfig; -use crate::net::java::JavaClient; -use crate::plugin::player::changed_main_hand::PlayerChangedMainHandEvent; -use crate::plugin::player::fish::{PlayerFishEvent, PlayerFishState}; -use crate::plugin::player::item_held::PlayerItemHeldEvent; -use crate::plugin::player::player_chat::PlayerChatEvent; -use crate::plugin::player::player_command_send::PlayerCommandSendEvent; -use crate::plugin::player::player_interact_entity_event::PlayerInteractEntityEvent; -use crate::plugin::player::player_interact_event::{InteractAction, PlayerInteractEvent}; -use crate::plugin::player::player_interact_unknown_entity_event::PlayerInteractUnknownEntityEvent; -use crate::plugin::player::player_move::PlayerMoveEvent; -use crate::plugin::player::player_toggle_flight_event::PlayerToggleFlightEvent; -use crate::plugin::player::player_toggle_sneak_event::PlayerToggleSneakEvent; - -use crate::block::entities::command_block::CommandBlockEntity; -use crate::block::entities::jigsaw_block::JigsawBlockEntity; -use crate::block::entities::sign::SignBlockEntity; -use crate::plugin::player::player_toggle_sprint_event::PlayerToggleSprintEvent; -use crate::server::{Server, seasonal_events}; -use crate::world::{BlockBreakingProgress, World, chunker}; -use pumpkin_data::block_properties::{BlockProperties, CommandBlockLikeProperties}; -use pumpkin_data::data_component::DataComponent; -use pumpkin_data::data_component_impl::{ - BlocksAttacksImpl, ConsumableImpl, DataComponentImpl, EquipmentSlot, EquippableImpl, FoodImpl, - WritableBookContentImpl, WrittenBookContentImpl, -}; -use pumpkin_data::item::Item; -use pumpkin_data::item_stack::ItemStack; -use pumpkin_data::sound::{Sound, SoundCategory}; -use pumpkin_data::{Advancement, Block, BlockDirection, translation}; -use pumpkin_inventory::InventoryError; -use pumpkin_inventory::merchant::merchant_screen_handler::MerchantScreenHandler; -use pumpkin_inventory::player::player_inventory::PlayerInventory; -use pumpkin_inventory::screen_handler::{InventoryPlayer, ScreenHandler}; -use pumpkin_protocol::bedrock::client::CMovePlayer; -use pumpkin_protocol::codec::var_int::VarInt; -use pumpkin_protocol::codec::var_ulong::VarULong; -use pumpkin_protocol::java::client::play::{ - CBlockUpdate, CCommandSuggestions, CEntityPositionSync, CHeadRot, COpenSignEditor, - CPingResponse, CPlayerInfoUpdate, CPlayerPosition, CSetCamera, CSetSelectedSlot, - CSystemChatMessage, CUpdateEntityPos, CUpdateEntityPosRot, CUpdateEntityRot, InitChat, - PlayerAction, -}; -use pumpkin_protocol::java::server::play::{ - Action, ActionType, CommandBlockMode, FLAG_ON_GROUND, SAttack, SBundleItemSelected, - SChangeGameMode, SChatCommand, SChatMessage, SChunkBatch, SClientCommand, - SClientInformationPlay, SCloseContainer, SCommandSuggestion, SConfirmTeleport, - SCookieResponse as SPCookieResponse, SEditBook, SInteract, SJigsawGenerate, SKeepAlive, - SMoveVehicle, SPaddleBoat, SPickItemFromBlock, SPickItemFromEntity, SPlaceRecipe, - SPlayPingRequest, SPlayerAbilities, SPlayerAction, SPlayerCommand, SPlayerInput, - SPlayerPosition, SPlayerPositionRotation, SPlayerRotation, SPlayerSession, - SRecipeBookChangeSettings, SRecipeBookSeenRecipe, SSeenAdvancement, SSelectTrade, - SSetCommandBlock, SSetCreativeSlot, SSetHeldItem, SSetJigsawBlock, SSetPlayerGround, - SSetTestBlock, SSwingArm, STeleportToEntity, STestInstanceBlockAction, SUpdateSign, SUseItem, - SUseItemOn, Status, -}; -use pumpkin_util::math::vector3::Vector3; -use pumpkin_util::math::{polynomial_rolling_hash, position::BlockPos, wrap_degrees}; -use pumpkin_util::{GameMode, text::TextComponent}; -use pumpkin_world::generation::structure::structures::jigsaw::JigsawJointType; -use pumpkin_world::world::BlockFlags; -use tokio::sync::Mutex; - -/// In secure chat mode, Player will be kicked if they send a chat message with a timestamp that is older than this (in ms) -/// Vanilla: 2 minutes -const CHAT_MESSAGE_MAX_AGE: i64 = 1000 * 60 * 2; - -#[derive(Debug, Error)] -pub enum BlockPlacingError { - BlockOutOfReach, - InvalidHand, - InvalidBlockFace, - BlockOutOfWorld, - InvalidGamemode, -} - -impl std::fmt::Display for BlockPlacingError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{self:?}") - } -} - -impl PumpkinError for BlockPlacingError { - fn is_kick(&self) -> bool { - match self { - Self::BlockOutOfReach | Self::BlockOutOfWorld | Self::InvalidGamemode => false, - Self::InvalidBlockFace | Self::InvalidHand => true, - } - } - - fn severity(&self) -> Level { - match self { - Self::BlockOutOfWorld | Self::InvalidGamemode => Level::TRACE, - Self::BlockOutOfReach | Self::InvalidBlockFace | Self::InvalidHand => Level::WARN, - } - } - - fn client_kick_reason(&self) -> Option { - match self { - Self::BlockOutOfReach | Self::BlockOutOfWorld | Self::InvalidGamemode => None, - Self::InvalidBlockFace => Some("Invalid block face".into()), - Self::InvalidHand => Some("Invalid hand".into()), - } - } -} - -#[derive(Debug, Error)] -pub enum ChatError { - #[error("sent an oversized message")] - OversizedMessage, - #[error("sent a message with illegal characters")] - IllegalCharacters, - #[error("sent a chat with invalid/no signature")] - UnsignedChat, - #[error("has too many unacknowledged chats queued")] - TooManyPendingChats, - #[error("sent a chat that couldn't be validated")] - ChatValidationFailed, - #[error("sent a chat with an out of order timestamp")] - OutOfOrderChat, - #[error("has an expired public key")] - ExpiredPublicKey, - #[error("attempted to initialize a session with an invalid public key")] - InvalidPublicKey, -} - -impl PumpkinError for ChatError { - fn is_kick(&self) -> bool { - true - } - - fn severity(&self) -> Level { - Level::WARN - } - - fn client_kick_reason(&self) -> Option { - match self { - Self::OversizedMessage => Some("Chat message too long".into()), - Self::IllegalCharacters => Some( - TextComponent::translate_cross( - translation::java::MULTIPLAYER_DISCONNECT_ILLEGAL_CHARACTERS, - translation::java::MULTIPLAYER_DISCONNECT_ILLEGAL_CHARACTERS, - [], - ) - .get_text(), - ), - Self::UnsignedChat => Some( - TextComponent::translate_cross( - translation::java::MULTIPLAYER_DISCONNECT_UNSIGNED_CHAT, - translation::java::MULTIPLAYER_DISCONNECT_UNSIGNED_CHAT, - [], - ) - .get_text(), - ), - Self::TooManyPendingChats => Some( - TextComponent::translate_cross( - translation::java::MULTIPLAYER_DISCONNECT_TOO_MANY_PENDING_CHATS, - translation::java::MULTIPLAYER_DISCONNECT_TOO_MANY_PENDING_CHATS, - [], - ) - .get_text(), - ), - Self::ChatValidationFailed => Some( - TextComponent::translate_cross( - translation::java::MULTIPLAYER_DISCONNECT_CHAT_VALIDATION_FAILED, - translation::java::MULTIPLAYER_DISCONNECT_CHAT_VALIDATION_FAILED, - [], - ) - .get_text(), - ), - Self::OutOfOrderChat => Some( - TextComponent::translate_cross( - translation::java::MULTIPLAYER_DISCONNECT_OUT_OF_ORDER_CHAT, - translation::java::MULTIPLAYER_DISCONNECT_OUT_OF_ORDER_CHAT, - [], - ) - .get_text(), - ), - Self::ExpiredPublicKey => Some( - TextComponent::translate_cross( - translation::java::MULTIPLAYER_DISCONNECT_EXPIRED_PUBLIC_KEY, - translation::java::MULTIPLAYER_DISCONNECT_EXPIRED_PUBLIC_KEY, - [], - ) - .get_text(), - ), - Self::InvalidPublicKey => Some( - TextComponent::translate_cross( - translation::java::MULTIPLAYER_DISCONNECT_INVALID_PUBLIC_KEY_SIGNATURE, - translation::java::MULTIPLAYER_DISCONNECT_INVALID_PUBLIC_KEY_SIGNATURE, - [], - ) - .get_text(), - ), - } - } -} - -/// Handles all Play packets sent by a real player. -/// NEVER TRUST THE CLIENT. HANDLE EVERY ERROR; UNWRAP/EXPECT ARE FORBIDDEN. -impl JavaClient { - pub async fn handle_confirm_teleport( - &self, - player: &Player, - confirm_teleport: SConfirmTeleport, - ) { - let mut awaiting_teleport = player.awaiting_teleport.lock().await; - if let Some((id, position)) = awaiting_teleport.as_ref() { - if id == &confirm_teleport.teleport_id { - // We should set the position now to what we requested in the teleport packet. - // This may fix issues when the client sends the position while being teleported. - player.get_entity().set_pos(*position); - - *awaiting_teleport = None; - drop(awaiting_teleport); - } else { - drop(awaiting_teleport); - self.kick(TextComponent::text("Wrong teleport id")).await; - } - } else { - drop(awaiting_teleport); - self.kick(TextComponent::text( - "Send Teleport confirm, but we did not teleport", - )) - .await; - } - } - - pub async fn handle_change_game_mode( - &self, - player: &Arc, - change_game_mode: SChangeGameMode, - ) { - if player.permission_lvl.load() >= PermissionLvl::Two { - player.set_gamemode(change_game_mode.game_mode).await; - let gamemode_string = format!("gameMode.{}", change_game_mode.game_mode.name()); - player - .send_system_message(&TextComponent::translate_cross( - translation::java::COMMANDS_GAMEMODE_SUCCESS_SELF, - translation::bedrock::COMMANDS_GAMEMODE_SUCCESS_SELF, - [TextComponent::translate_cross( - gamemode_string.clone(), - gamemode_string, - [], - )], - )) - .await; - } - } - - const fn clamp_horizontal(pos: f64) -> f64 { - pos.clamp(-3.0E7, 3.0E7) - } - - const fn clamp_vertical(pos: f64) -> f64 { - pos.clamp(-2.0E7, 2.0E7) - } - - pub fn handle_player_loaded(player: &Player) { - player.set_client_loaded(true); - } - - /// Returns whether syncing the position was needed - fn sync_position( - player: &Arc, - world: &World, - pos: Vector3, - last_pos: Vector3, - yaw: f32, - pitch: f32, - on_ground: bool, - ) -> bool { - let delta = Vector3::new(pos.x - last_pos.x, pos.y - last_pos.y, pos.z - last_pos.z); - let entity_id = player.entity_id(); - - // Teleport when more than 8 blocks (-8..=7.999755859375) - if delta.length_squared() < 64.0 { - return false; - } - // Sync position with all other players. - world.broadcast_packet_except( - &[player.gameprofile.id], - &CEntityPositionSync::new( - entity_id.into(), - pos, - Vector3::new(0.0, 0.0, 0.0), - yaw, - pitch, - on_ground, - ), - ); - true - } - - #[expect(clippy::too_many_lines)] - pub async fn handle_position( - &self, - player: &Arc, - server: &Arc, - packet: SPlayerPosition, - ) { - if !player.has_client_loaded() { - return; - } - if player.get_entity().has_vehicle().await { - return; - } - // Ignore movement packets while awaiting a teleport confirmation (vanilla behavior) - if player.awaiting_teleport.lock().await.is_some() { - return; - } - // y = feet Y - let position = packet.position; - if position.x.is_nan() || position.y.is_nan() || position.z.is_nan() { - self.kick(TextComponent::translate_cross( - translation::java::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT, - translation::java::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT, - [], - )) - .await; - return; - } - let position = Vector3::new( - Self::clamp_horizontal(position.x), - Self::clamp_vertical(position.y), - Self::clamp_horizontal(position.z), - ); - - send_cancellable! {{ - server; - PlayerMoveEvent { - player: player.clone(), - from: player.get_entity().pos.load(), - to: position, - cancelled: false, - }; - - 'after: { - let pos = event.to; - let entity = &player.get_entity(); - let last_pos = entity.pos.load(); - player.get_entity().set_pos(pos); - - let distance = last_pos.squared_distance_to_vec(&pos).sqrt(); - let cm = (distance * 100.0) as i32; - if cm > 0 { - let stat = player.get_movement_statistic().await; - player - .increment_stat(StatisticCategory::Custom, stat as i32, cm) - .await; - } - - let height_difference = pos.y - last_pos.y; - if entity.on_ground.load(Ordering::Relaxed) && packet.collision & FLAG_ON_GROUND == 0 && height_difference > 0.0 { - player.jump().await; - } - - let new_on_ground = packet.collision & FLAG_ON_GROUND != 0; - entity.on_ground.store(new_on_ground, Ordering::Relaxed); - if new_on_ground && entity.is_fall_flying() { - entity.set_fall_flying(false).await; - } - let world = &player.world(); - - // TODO: Warn when player moves to quickly - if !Self::sync_position(player, world, pos, last_pos, entity.yaw.load(), entity.pitch.load(), packet.collision & FLAG_ON_GROUND != 0) { - // Send the new position to all other players. - world.broadcast_packet_except_editioned_sync( - &[player.gameprofile.id], - &CUpdateEntityPos::new( - player.entity_id().into(), - Vector3::new( - pos.x.mul_add(4096.0, -(last_pos.x * 4096.0)) as i16, - pos.y.mul_add(4096.0, -(last_pos.y * 4096.0)) as i16, - pos.z.mul_add(4096.0, -(last_pos.z * 4096.0)) as i16, - ), - packet.collision & FLAG_ON_GROUND != 0, - ), - &CMovePlayer::new( - VarULong(player.entity_id() as u64), - Vector3::new(pos.x as f32, pos.y as f32 + player.get_entity().entity_type.eye_height, pos.z as f32), - entity.pitch.load(), - entity.yaw.load(), - entity.yaw.load(), - CMovePlayer::MODE_NORMAL, - (packet.collision & FLAG_ON_GROUND) != 0, - VarULong(0), - 0, - 0, - VarULong(0), - ), - ); - } - - // Only process fall damage if player is alive - if !player.abilities.lock().await.flying - && player.living_entity.health.load() > 0.0 - && !player.living_entity.dead.load(Ordering::Relaxed) - { - player.living_entity - .fall( - player.clone(), - height_difference, - packet.collision & FLAG_ON_GROUND != 0, - player.gamemode.load() == GameMode::Creative, - ) - .await; - } - chunker::update_position(player).await; - let delta = Vector3::new( - pos.x - last_pos.x, - pos.y - last_pos.y, - pos.z - last_pos.z, - ); - // Only update idle timeout if there's actual movement (vanilla threshold) - if delta.length_squared() > 1.0E-5 { - player.update_last_action_time(); - } - player.progress_motion(delta).await; - } - - 'cancelled: { - self.force_tp(player, player.get_entity().pos.load()).await; - } - }} - } - - #[expect(clippy::too_many_lines)] - pub async fn handle_position_rotation( - &self, - player: &Arc, - server: &Arc, - packet: SPlayerPositionRotation, - ) { - if !player.has_client_loaded() { - return; - } - if player.get_entity().has_vehicle().await { - return; - } - // Ignore movement packets while awaiting a teleport confirmation (vanilla behavior) - if player.awaiting_teleport.lock().await.is_some() { - return; - } - // y = feet Y - let position = packet.position; - if !position.x.is_finite() - || !position.y.is_finite() - || !position.z.is_finite() - || !packet.yaw.is_finite() - || !packet.pitch.is_finite() - { - self.kick(TextComponent::translate_cross( - translation::java::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT, - translation::java::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT, - [], - )) - .await; - return; - } - - let position = Vector3::new( - Self::clamp_horizontal(position.x), - Self::clamp_vertical(position.y), - Self::clamp_horizontal(position.z), - ); - - send_cancellable! {{ - server; - PlayerMoveEvent::new( - player.clone(), - player.get_entity().pos.load(), - position, - ); - - 'after: { - let pos = event.to; - let entity = &player.get_entity(); - let last_pos = entity.pos.load(); - player.get_entity().set_pos(pos); - - let distance = last_pos.squared_distance_to_vec(&pos).sqrt(); - let cm = (distance * 100.0) as i32; - if cm > 0 { - let stat = player.get_movement_statistic().await; - player - .increment_stat(StatisticCategory::Custom, stat as i32, cm) - .await; - } - - let height_difference = pos.y - last_pos.y; - if entity.on_ground.load(Ordering::Relaxed) - && (packet.collision & FLAG_ON_GROUND) != 0 - && height_difference > 0.0 - { - player.jump().await; - } - entity - .on_ground - .store((packet.collision & FLAG_ON_GROUND) != 0, Ordering::Relaxed); - - entity.set_rotation(wrap_degrees(packet.yaw) % 360.0, wrap_degrees(packet.pitch)); - - let entity_id = entity.entity_id; - - let yaw = (entity.yaw.load() * 256.0 / 360.0).rem_euclid(256.0); - let pitch = (entity.pitch.load() * 256.0 / 360.0).rem_euclid(256.0); - // let head_yaw = (entity.head_yaw * 256.0 / 360.0).floor(); - let world = entity.world.load_full(); - - // TODO: Warn when player moves to quickly - if !Self:: - sync_position(player, &world, pos, last_pos, yaw, pitch, (packet.collision & FLAG_ON_GROUND) != 0) - { - // Send the new position to all other players. - world.broadcast_packet_except_editioned_sync( - &[player.gameprofile.id], - &CUpdateEntityPosRot::new( - entity_id.into(), - Vector3::new( - pos.x.mul_add(4096.0, -(last_pos.x * 4096.0)) as i16, - pos.y.mul_add(4096.0, -(last_pos.y * 4096.0)) as i16, - pos.z.mul_add(4096.0, -(last_pos.z * 4096.0)) as i16, - ), - yaw as u8, - pitch as u8, - (packet.collision & FLAG_ON_GROUND) != 0, - ), - &CMovePlayer::new( - VarULong(entity_id as u64), - Vector3::new(pos.x as f32, pos.y as f32 + player.get_entity().entity_type.eye_height, pos.z as f32), - entity.pitch.load(), - entity.yaw.load(), - entity.yaw.load(), - CMovePlayer::MODE_NORMAL, - (packet.collision & FLAG_ON_GROUND) != 0, - VarULong(0), - 0, - 0, - VarULong(0), - ), - ); - } - - world - .broadcast_packet_except( - &[player.gameprofile.id], - &CHeadRot::new(entity_id.into(), yaw as u8), - ) - ; - // Only process fall damage if player is alive - if !player.abilities.lock().await.flying - && player.living_entity.health.load() > 0.0 - && !player.living_entity.dead.load(Ordering::Relaxed) - { - player.living_entity - .fall( - player.clone(), - height_difference, - (packet.collision & FLAG_ON_GROUND) != 0, - player.gamemode.load() == GameMode::Creative, - ) - .await; - } - chunker::update_position(player).await; - let delta = Vector3::new( - pos.x - last_pos.x, - pos.y - last_pos.y, - pos.z - last_pos.z, - ); - // Only update idle timeout if there's actual movement (vanilla threshold) - if delta.length_squared() > 1.0E-5 { - player.update_last_action_time(); - } - player.progress_motion(delta).await; - } - - 'cancelled: { - self.force_tp(player, position).await; - } - }} - } - - pub async fn force_tp(&self, player: &Arc, position: Vector3) { - let teleport_id = player.teleport_id_count.fetch_add(1, Ordering::Relaxed) + 1; - *player.awaiting_teleport.lock().await = Some((teleport_id.into(), position)); - self.enqueue_packet(&CPlayerPosition::new( - teleport_id.into(), - player.get_entity().pos.load(), - Vector3::new(0.0, 0.0, 0.0), - player.get_entity().yaw.load(), - player.get_entity().pitch.load(), - Vec::new(), - )) - .await; - } - - pub async fn handle_rotation(&self, player: &Player, rotation: SPlayerRotation) { - if !player.has_client_loaded() { - return; - } - if !rotation.yaw.is_finite() || !rotation.pitch.is_finite() { - self.kick(TextComponent::translate_cross( - translation::java::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT, - translation::java::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT, - [], - )) - .await; - return; - } - let entity = &player.get_entity(); - entity.on_ground.store(rotation.ground, Ordering::Relaxed); - entity.set_rotation( - wrap_degrees(rotation.yaw) % 360.0, - wrap_degrees(rotation.pitch), - ); - // Send the new position to all other players. - let entity_id = entity.entity_id; - let yaw = (entity.yaw.load() * 256.0 / 360.0).rem_euclid(256.0); - let pitch = (entity.pitch.load() * 256.0 / 360.0).rem_euclid(256.0); - // let head_yaw = modulus(entity.head_yaw * 256.0 / 360.0, 256.0); - - let world = entity.world.load_full(); - let je_packet = - CUpdateEntityRot::new(entity_id.into(), yaw as u8, pitch as u8, rotation.ground); - - let pos = entity.pos.load(); - - let be_packet = CMovePlayer::new( - VarULong(entity_id as u64), - Vector3::new( - pos.x as f32, - pos.y as f32 + player.get_entity().entity_type.eye_height, - pos.z as f32, - ), - entity.pitch.load(), - entity.yaw.load(), - entity.yaw.load(), - CMovePlayer::MODE_ROTATION, - rotation.ground, - VarULong(0), - 0, - 0, - VarULong(0), - ); - - world.broadcast_packet_except_editioned_sync( - &[player.gameprofile.id], - &je_packet, - &be_packet, - ); - - let je_packet = CHeadRot::new(entity_id.into(), yaw as u8); - world.broadcast_packet_except(&[player.gameprofile.id], &je_packet); - } - - pub async fn handle_chat_command( - &self, - player: &Arc, - server: &Arc, - command: &SChatCommand<'_>, - ) { - player.update_last_action_time(); - let player_clone = player.clone(); - let server_clone = server.clone(); - send_cancellable! {{ - server; - PlayerCommandSendEvent { - player: player.clone(), - command: command.command.to_string(), - cancelled: false - }; - - 'after: { - let command = event.command; - let command_clone = command.clone(); - // Some commands can take a long time to execute. If they do, they block packet processing for the player. - // That's why we will spawn a task instead. - server.spawn_task(async move { - let dispatcher = server_clone.command_dispatcher.read().await; - dispatcher.handle_command( - &player_clone.get_command_source(&server_clone).await, - &command_clone - ).await; - }); - - if server.advanced_config.commands.log_console { - info!( - "Player ({}): executed command /{}", - player.gameprofile.name, - command - ); - } - } - }} - } - - pub fn handle_player_ground(&self, player: &Player, ground: &SSetPlayerGround) { - player - .living_entity - .entity - .on_ground - .store(ground.on_ground, Ordering::Relaxed); - } - - pub async fn handle_pick_item_from_block( - &self, - player: &Arc, - pick_item: SPickItemFromBlock, - ) { - if !player.can_interact_with_block_at(&pick_item.pos, 1.0) { - return; - } - - let world = player.world(); - let block = world.get_block(&pick_item.pos); - - if block.item_id == 0 { - // Invalid block id (blocks such as tall seagrass) - return; - } - - let Some(item) = Item::from_id(block.item_id) else { - return; - }; - let stack = ItemStack::new(1, item); - - let slot_with_stack = player.inventory().get_slot_with_stack(&stack).await; - - if slot_with_stack != -1 { - if PlayerInventory::is_valid_hotbar_index(slot_with_stack as usize) { - player.inventory.set_selected_slot(slot_with_stack as u8); - } else { - player - .inventory - .swap_slot_with_hotbar(slot_with_stack as usize) - .await; - } - } else if player.gamemode.load() == GameMode::Creative { - player.inventory.swap_stack_with_hotbar(stack).await; - } - - player - .client - .enqueue_packet(&CSetSelectedSlot::new( - player.inventory.get_selected_slot() as i8 - )) - .await; - player - .player_screen_handler - .lock() - .await - .send_content_updates() - .await; - } - - pub async fn handle_pick_item_from_entity( - &self, - player: &Arc, - pick_item: SPickItemFromEntity, - ) { - use pumpkin_data::entity::{entity_from_egg, spawn_egg_ids}; - - let world = player.world(); - let Some(target) = world.get_entity_by_id(pick_item.id.0) else { - return; - }; - - let p_eye = player.get_entity().get_eye_pos(); - let t_eye = target.get_eye_pos(); - let dx = p_eye.x - t_eye.x; - let dy = p_eye.y - t_eye.y; - let dz = p_eye.z - t_eye.z; - if dx * dx + dy * dy + dz * dz > 64.0 { - return; - } - - let target_type_id = target.get_entity().entity_type.id; - let mut found_egg: Option = None; - for &egg_id in &spawn_egg_ids() { - if let Some(et) = entity_from_egg(egg_id) - && et.id == target_type_id - { - found_egg = Some(egg_id); - break; - } - } - - if let Some(item) = found_egg.and_then(Item::from_id) { - let stack = ItemStack::new(1, item); - - let slot_with_stack = player.inventory().get_slot_with_stack(&stack).await; - - if slot_with_stack != -1 { - if PlayerInventory::is_valid_hotbar_index(slot_with_stack as usize) { - player.inventory.set_selected_slot(slot_with_stack as u8); - } else { - player - .inventory - .swap_slot_with_hotbar(slot_with_stack as usize) - .await; - } - } else if player.gamemode.load() == GameMode::Creative { - player.inventory.swap_stack_with_hotbar(stack).await; - } - - player - .client - .enqueue_packet(&CSetSelectedSlot::new( - player.inventory.get_selected_slot() as i8 - )) - .await; - player - .player_screen_handler - .lock() - .await - .send_content_updates() - .await; - } - } - - pub async fn handle_set_command_block( - &self, - player: &Arc, - command: SSetCommandBlock<'_>, - ) { - if !player.is_creative() { - return; - } - if player.permission_lvl.load() < PermissionLvl::Two { - return; - } - let pos = command.pos; - let block_entity = player.world().get_block_entity(&pos); - if let Some(block_entity) = block_entity { - if block_entity.resource_location() != CommandBlockEntity::ID { - warn!("Client tried to change Command block but not Command block entity found"); - return; - } - - let Ok(command_block_mode) = CommandBlockMode::try_from(command.mode) else { - self.kick(TextComponent::text("Invalid Command block mode")) - .await; - return; - }; - - let block = player.world().get_block(&pos); - let old_state_id = player.world().get_block_state_id(&pos); - let mut props = CommandBlockLikeProperties::from_state_id(old_state_id, block); - - let block_type = match command_block_mode { - CommandBlockMode::Chain => Block::CHAIN_COMMAND_BLOCK, - CommandBlockMode::Repeating => Block::REPEATING_COMMAND_BLOCK, - CommandBlockMode::Impulse => Block::COMMAND_BLOCK, - }; - - let Some(old_command_block) = - block_entity.as_any().downcast_ref::() - else { - return; - }; - - props.conditional = command.flags & 0x2 != 0; - - let new_state_id = props.to_state_id(&block_type); - player - .world() - .set_block_state( - &command.pos, - new_state_id, - BlockFlags::SKIP_BLOCK_ADDED_CALLBACK, - ) - .await; - - let mut cmd = command.command; - if cmd.starts_with('/') { - cmd = &cmd[1..]; - } - - let command_block = CommandBlockEntity { - position: pos, - powered: old_command_block.powered.load(Ordering::SeqCst).into(), - condition_met: old_command_block - .condition_met - .load(Ordering::SeqCst) - .into(), - auto: (command.flags & 0x4 != 0).into(), - dirty: old_command_block.dirty.load(Ordering::SeqCst).into(), - command: Mutex::new(cmd.to_string()), - last_output: old_command_block.last_output.lock().await.clone().into(), - track_output: (command.flags & 0x1 != 0).into(), - success_count: AtomicU32::new(0), - }; - player.world().add_block_entity(Arc::new(command_block)); - - player - .send_system_message(&TextComponent::text(format!( - "Command set: {}", - command.command - ))) - .await; - - // The 0x4 flag means always active - if command.flags & 0x4 != 0 && block_type != Block::CHAIN_COMMAND_BLOCK { - player.world().schedule_block_tick( - &block_type, - pos, - 1, - pumpkin_world::tick::TickPriority::Normal, - ); - } - } - } - - pub async fn handle_set_jigsaw_block(&self, player: &Arc, jigsaw: SSetJigsawBlock<'_>) { - if !player.is_creative() { - return; - } - if player.permission_lvl.load() < PermissionLvl::Two { - return; - } - let pos = jigsaw.pos; - let block_entity = player.world().get_block_entity(&pos); - if let Some(block_entity) = block_entity { - if block_entity.resource_location() != JigsawBlockEntity::ID { - warn!("Client tried to change Jigsaw block but not Jigsaw block entity found"); - return; - } - - let Some(jigsaw_block) = block_entity.as_any().downcast_ref::() - else { - return; - }; - - *jigsaw_block.name.lock().await = jigsaw.name.to_string(); - *jigsaw_block.target.lock().await = jigsaw.target.to_string(); - *jigsaw_block.pool.lock().await = jigsaw.pool.to_string(); - *jigsaw_block.final_state.lock().await = jigsaw.final_state.to_string(); - *jigsaw_block.joint.lock().await = JigsawJointType::from_str(jigsaw.joint); - jigsaw_block - .selection_priority - .store(jigsaw.selection_priority.0, Ordering::SeqCst); - jigsaw_block - .placement_priority - .store(jigsaw.placement_priority.0, Ordering::SeqCst); - - player.world().update_block_entity(&block_entity); - } - } - - pub async fn handle_jigsaw_generate(&self, player: &Arc, generate: SJigsawGenerate) { - if !player.is_creative() { - return; - } - if player.permission_lvl.load() < PermissionLvl::Two { - return; - } - let pos = generate.pos; - if let Some(block_entity) = player.world().get_block_entity(&pos) - && let Some(jigsaw_block) = block_entity.as_any().downcast_ref::() - { - jigsaw_block - .generate(&player.world(), generate.levels.0, generate.keep_jigsaws) - .await; - } - } - - pub async fn handle_player_command( - &self, - player: &Arc, - command: SPlayerCommand, - server: &Arc, - ) { - if command.entity_id != player.entity_id().into() { - return; - } - if !player.has_client_loaded() { - return; - } - player.update_last_action_time(); - - let entity = &player.get_entity(); - match command.action { - Action::StartSprinting => { - if !entity.is_sprinting() { - send_cancellable! {{ - server; - PlayerToggleSprintEvent::new(player.clone(), true); - 'after: { - player.get_entity().set_sprinting(event.is_sprinting).await; - } - }} - } - } - Action::StopSprinting => { - if entity.is_sprinting() { - send_cancellable! {{ - server; - PlayerToggleSprintEvent::new(player.clone(), false); - 'after: { - player.get_entity().set_sprinting(event.is_sprinting).await; - } - }} - } - } - Action::LeaveBed => player.wake_up().await, - - Action::StartHorseJump | Action::StopHorseJump | Action::OpenVehicleInventory => { - debug!("todo"); - } - Action::StartFlyingElytra => { - let fall_flying = entity.check_fall_flying(); - if entity.is_fall_flying() != fall_flying { - entity.set_fall_flying(fall_flying).await; - } - } - // <= 1.21.5 - Action::StartSneaking | Action::StopSneaking => { - self.handle_player_input( - player, - SPlayerInput { - input: SPlayerInput::SNEAK, - }, - server, - ) - .await; - } - } - } - - pub async fn handle_player_input( - &self, - player: &Arc, - input: SPlayerInput, - server: &Arc, - ) { - player.last_input.store(input.input, Ordering::Relaxed); - - let sneak = input.input & SPlayerInput::SNEAK != 0; - if sneak - && player.gamemode.load() == GameMode::Spectator - && player.camera_target_id.load().is_some() - { - player.camera_target_id.store(None); - player - .client - .send_packet_now(&CSetCamera::new(player.entity_id().into())) - .await; - } - - if player.get_entity().is_sneaking() != sneak { - send_cancellable! {{ - server; - PlayerToggleSneakEvent::new(player.clone(), sneak); - 'after: { - player.get_entity().set_sneaking(event.is_sneaking).await; - if event.is_sneaking { - let vehicle = player.get_entity().vehicle.lock().await.clone(); - if let Some(vehicle) = vehicle { - vehicle - .get_entity() - .remove_passenger(player.entity_id()) - .await; - } - } - } - }} - } else if sneak { - let vehicle = player.get_entity().vehicle.lock().await.clone(); - if let Some(vehicle) = vehicle { - vehicle - .get_entity() - .remove_passenger(player.entity_id()) - .await; - } - } - } - - pub async fn handle_move_vehicle(&self, player: &Arc, packet: SMoveVehicle) { - let entity = player.get_entity(); - let pos = Vector3::new(packet.x, packet.y, packet.z); - let vehicle = entity.vehicle.lock().await; - if let Some(vehicle) = vehicle.as_ref() { - let vehicle_entity = vehicle.get_entity(); - vehicle_entity.set_pos(pos); - vehicle_entity.set_rotation(packet.yaw, packet.pitch); - } - drop(vehicle); - entity.set_pos(pos); - chunker::update_position(player).await; - } - - pub async fn handle_paddle_boat(&self, player: &Arc, packet: SPaddleBoat) { - let vehicle = player.get_entity().vehicle.lock().await.clone(); - if let Some(vehicle) = vehicle { - vehicle - .set_paddle_state(packet.left_paddle, packet.right_paddle) - .await; - } - } - - #[allow(clippy::unused_async)] - pub async fn handle_recipe_book_change_settings( - &self, - _player: &Arc, - _packet: SRecipeBookChangeSettings, - ) { - // Client is updating its recipe book filter/open state; no server action needed. - } - - #[allow(clippy::unused_async)] - pub async fn handle_recipe_book_seen_recipe( - &self, - _player: &Arc, - _packet: SRecipeBookSeenRecipe, - ) { - // Client acknowledged a recipe display; no server action needed. - } - - #[allow(clippy::too_many_lines)] - pub async fn handle_place_recipe( - &self, - server: &Arc, - player: &Arc, - packet: SPlaceRecipe, - ) { - use super::recipe_helper::{ - GenericIngredient, compute_biggest_craftable, take_n_ingredient, - }; - use crate::server::recipe::DynamicRecipe; - use pumpkin_data::recipes::{CraftingRecipeTypes, RECIPES_COOKING, RECIPES_CRAFTING}; - use pumpkin_data::screen::WindowType; - use pumpkin_inventory::crafting::recipe_provider::RecipeProvider; - - let target_id = packet.recipe_display_id.0 as usize; - let use_max = packet.use_max_items; - - // Count crafting display IDs. - let crafting_display_count = RECIPES_CRAFTING - .iter() - .filter(|r| { - !matches!( - r, - CraftingRecipeTypes::CraftingSpecial - | CraftingRecipeTypes::CraftingDecoratedPot { .. } - ) - }) - .count(); - let cooking_display_count = RECIPES_COOKING.len(); - let dynamic_recipes = server.recipe_manager.get_dynamic_recipes().await; - - let (grid_width, crafting_inv) = { - let screen_handler_arc = player.current_screen_handler.lock().await.clone(); - let handler = screen_handler_arc.lock().await; - let grid_width: usize = match handler.window_type() { - Some(WindowType::Crafting) => 3, - None => 2, // player inventory 2x2 - _ => return, - }; - (grid_width, handler.get_behaviour().slots[1].get_inventory()) - }; - - let grid_size = grid_width * grid_width; - let mut ingredient_slots: Vec>> = vec![None; grid_size]; - - if target_id < crafting_display_count { - // Crafting recipe - let mut counter = 0usize; - let recipe = RECIPES_CRAFTING.iter().find(|r| { - if matches!( - r, - CraftingRecipeTypes::CraftingSpecial - | CraftingRecipeTypes::CraftingDecoratedPot { .. } - ) { - return false; - } - let found = counter == target_id; - counter += 1; - found - }); - let Some(recipe) = recipe else { return }; - - match recipe { - CraftingRecipeTypes::CraftingShaped { pattern, key, .. } => { - for (row, row_str) in pattern.iter().enumerate() { - for (col, ch) in row_str.chars().enumerate() { - if ch != ' ' - && let Some(ing) = - key.iter().find_map(|(k, v)| (*k == ch).then_some(v)) - && row * grid_width + col < grid_size - { - ingredient_slots[row * grid_width + col] = - Some(GenericIngredient::Vanilla(ing)); - } - } - } - } - CraftingRecipeTypes::CraftingShapeless { ingredients, .. } => { - for (i, ing) in ingredients.iter().enumerate().take(grid_size) { - ingredient_slots[i] = Some(GenericIngredient::Vanilla(ing)); - } - } - CraftingRecipeTypes::CraftingTransmute { - input, material, .. - } => { - if grid_size >= 2 { - ingredient_slots[0] = Some(GenericIngredient::Vanilla(input)); - ingredient_slots[1] = Some(GenericIngredient::Vanilla(material)); - } - } - _ => return, - } - } else if target_id < crafting_display_count + cooking_display_count { - // TODO: cooking recipes - return; - } else { - let dynamic_id = target_id - crafting_display_count - cooking_display_count; - let Some(DynamicRecipe::Crafting(crafting)) = dynamic_recipes.get(dynamic_id) else { - return; - }; - - match crafting { - pumpkin_protocol::codec::recipe::OwnedCraftingRecipe::Shaped { - pattern, - key, - .. - } => { - for (row, row_str) in pattern.iter().enumerate() { - for (col, ch) in row_str.chars().enumerate() { - if ch != ' ' - && let Some((_, ing)) = key.iter().find(|(k, _)| *k == ch) - && row * grid_width + col < grid_size - { - ingredient_slots[row * grid_width + col] = - Some(GenericIngredient::Dynamic(ing)); - } - } - } - } - - pumpkin_protocol::codec::recipe::OwnedCraftingRecipe::Shapeless { - ingredients, - .. - } => { - for (i, ing) in ingredients.iter().enumerate().take(grid_size) { - ingredient_slots[i] = Some(GenericIngredient::Dynamic(ing)); - } - } - } - } - - // Check if this exact recipe is already placed (determines stacking vs fresh fill). - let recipe_matches = { - let mut ok = true; - for (idx, ing) in ingredient_slots.iter().enumerate() { - let stack = crafting_inv.get_stack(idx).await; - match ing { - None => { - if !stack.is_empty() { - ok = false; - break; - } - } - Some(ingredient) => { - if stack.is_empty() || !ingredient.match_item(stack.item) { - ok = false; - break; - } - } - } - } - ok - }; - - // Read minimum count from occupied slots before clearing (needed for stacking). - let current_min = if recipe_matches && !use_max { - let mut min = u8::MAX; - for (idx, ing) in ingredient_slots.iter().enumerate() { - if ing.is_some() { - let stack = crafting_inv.get_stack(idx).await; - if !stack.is_empty() { - min = min.min(stack.item_count); - } - } - } - if min == u8::MAX { 0 } else { min } - } else { - 0 - }; - - // Always clear the grid first, returning items to inventory. - for i in 0..grid_size { - let stack = crafting_inv.remove_stack(i).await; - if !stack.is_empty() { - player.inventory.offer(stack, false, player.as_ref()).await; - } - } - - // Determine how many of each ingredient to place per slot. - let active_ingredients: Vec> = - ingredient_slots.iter().flatten().copied().collect(); - let amount_to_craft = if use_max { - compute_biggest_craftable(&active_ingredients, &player.inventory).await - } else if recipe_matches { - current_min.saturating_add(1) - } else { - 1 - }; - - if amount_to_craft == 0 { - let screen_handler_arc = player.current_screen_handler.lock().await.clone(); - screen_handler_arc.lock().await.send_content_updates().await; - return; - } - - // Fill each grid slot with exactly `amount_to_craft` matching items. - for (idx, ing) in ingredient_slots.iter().enumerate() { - let Some(ingredient) = ing else { continue }; - let taken = take_n_ingredient(&player.inventory, ingredient, amount_to_craft).await; - if !taken.is_empty() { - crafting_inv.set_stack(idx, taken).await; - } - } - - let screen_handler_arc = player.current_screen_handler.lock().await.clone(); - screen_handler_arc.lock().await.send_content_updates().await; - } - - pub async fn handle_swing_arm( - &self, - server: &Arc, - player: &Arc, - swing_arm: SSwingArm, - ) { - player.update_last_action_time(); - let Ok(hand) = Hand::from_packet_id(swing_arm.hand.0) else { - self.kick(TextComponent::text("Invalid hand")).await; - return; - }; - - let mut anim_event = crate::plugin::api::events::player::player_animation::PlayerAnimationEvent::new( - player.clone(), - match hand { - Hand::Left => crate::plugin::api::events::player::player_animation::PlayerAnimationType::ArmSwingOff, - Hand::Right => crate::plugin::api::events::player::player_animation::PlayerAnimationType::ArmSwingMain, - }, - ); - server.plugin_manager.fire(server, &mut anim_event).await; - if anim_event.cancelled { - return; - } - - let (yaw, pitch) = player.rotation(); - let hit_result = player - .world() - .raycast( - player.eye_position(), - player - .eye_position() - .add(&(Vector3::rotation_vector(f64::from(pitch), f64::from(yaw)) * 4.5)), - async |pos, world| { - let block = world.get_block(pos); - block != &Block::AIR && block != &Block::WATER && block != &Block::LAVA - }, - ) - .await; - - let event = if let Some((hit_pos, _hit_dir)) = hit_result { - PlayerInteractEvent::new( - player, - InteractAction::LeftClickBlock, - player.world().get_block(&hit_pos), - Some(hit_pos), - ) - } else { - PlayerInteractEvent::new(player, InteractAction::LeftClickAir, &Block::AIR, None) - }; - - send_cancellable! {{ - &server; - event; - 'after: { - player.swing_hand(hand, false).await; - } - }} - } - - pub async fn handle_chat_message( - &self, - server: &Arc, - player: &Arc, - chat_message: SChatMessage<'_>, - ) { - player.update_last_action_time(); - let gameprofile = &player.gameprofile; - - if let Err(err) = self - .validate_chat_message(server, player, &chat_message) - .await - { - log_at_level!( - err.severity(), - "{} (uuid {}) {}", - gameprofile.name, - gameprofile.id, - err - ); - if err.is_kick() - && let Some(reason) = err.client_kick_reason() - { - self.kick(TextComponent::text(reason)).await; - } - return; - } - - send_cancellable! {{ - server; - PlayerChatEvent::new(player.clone(), chat_message.message.to_string(), vec![]); - - 'after: { - info!(" {}: {}", gameprofile.name, event.message); - - let config = &server.advanced_config; - - let message = match seasonal_events::modify_chat_message(&event.message, config) { - Some(m) => m, - None => event.message.clone(), - }; - - let decorated_message = TextComponent::chat_decorated( - &config.chat.format, - &gameprofile.name, - &message, - ); - - let entity = &player.get_entity(); - let world = entity.world.load_full(); - if server.basic_config.allow_chat_reports { - world.broadcast_secure_player_chat(player, &chat_message, &decorated_message).await; - } else { - let je_packet = CSystemChatMessage::new( - &decorated_message, - false, - ); - let be_packet = SText::new( - message, player.gameprofile.name.clone() - ); - - world.broadcast_editioned(&je_packet, &be_packet).await; - } - } - }} - } - - /// Runs all vanilla checks for a valid chat message - pub async fn validate_chat_message( - &self, - server: &Server, - player: &Arc, - chat_message: &SChatMessage<'_>, - ) -> Result<(), ChatError> { - // Check for oversized messages - // If we're able to find the 257th UTF-16 character, the message is too big. - if chat_message.message.encode_utf16().nth(256).is_some() { - return Err(ChatError::OversizedMessage); - } - // Check for illegal characters - if chat_message - .message - .chars() - .any(|c| c == '§' || c < ' ' || c == '\x7F') - { - return Err(ChatError::IllegalCharacters); - } - // These checks are only run in secure chat mode - if server.basic_config.allow_chat_reports { - // Check for unsigned chat - if let Some(signature) = &chat_message.signature { - if signature.len() != 256 { - return Err(ChatError::UnsignedChat); // Signature is the wrong length - } - } else { - return Err(ChatError::UnsignedChat); // There is no signature - } - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - - // Verify message timestamp - if chat_message.timestamp > now || chat_message.timestamp < (now - CHAT_MESSAGE_MAX_AGE) - { - return Err(ChatError::OutOfOrderChat); - } - - // Verify session expiry - if player.chat_session.lock().await.expires_at < now { - return Err(ChatError::ExpiredPublicKey); - } - - // Validate previous signature checksum (new in 1.21.5) - // The client can bypass this check by sending 0 - if chat_message.checksum != 0 { - let checksum = - polynomial_rolling_hash(player.signature_cache.lock().await.last_seen.as_ref()); - if checksum != chat_message.checksum { - return Err(ChatError::ChatValidationFailed); - } - } - } - Ok(()) - } - - pub async fn handle_chat_session_update( - &self, - player: &Arc, - server: &Server, - session: SPlayerSession, - ) { - // Keep the chat session default if we don't want reports - if !server.basic_config.allow_chat_reports { - return; - } - - if let Err(err) = self.validate_chat_session(player, server, &session) { - log_at_level!( - err.severity(), - "{} (uuid {}) {}", - player.gameprofile.name, - player.gameprofile.id, - err - ); - if err.is_kick() - && let Some(reason) = err.client_kick_reason() - { - self.kick(TextComponent::text(reason)).await; - } - return; - } - - // Update the chat session fields - *player.chat_session.lock().await = ChatSession::new( - session.session_id, - session.expires_at, - session.public_key.clone(), - session.key_signature.clone(), - ); - - server.broadcast_packet_all(&CPlayerInfoUpdate::new( - 0x02, - &[pumpkin_protocol::java::client::play::Player { - uuid: player.gameprofile.id, - actions: &[PlayerAction::InitializeChat(Some(InitChat { - session_id: session.session_id, - expires_at: session.expires_at, - public_key: session.public_key.clone(), - signature: session.key_signature.clone(), - }))], - }], - )); - } - - /// Runs vanilla checks for a valid player session - pub fn validate_chat_session( - &self, - player: &Player, - server: &Server, - session: &SPlayerSession, - ) -> Result<(), ChatError> { - // Verify session expiry - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - if session.expires_at < now { - return Err(ChatError::InvalidPublicKey); - } - - let key_signature = RsaPkcs1v15Signature::try_from(session.key_signature.as_ref()) - .map_err(|_| ChatError::InvalidPublicKey)?; - - let mut signable = Vec::new(); - signable.extend_from_slice(player.gameprofile.id.as_bytes()); - signable.extend_from_slice(&session.expires_at.to_be_bytes()); - signable.extend_from_slice(&session.public_key); - - let public_keys_guard = server.mojang_public_keys.load(); - - // Verify signature with RSA-SHA1 - let is_valid = public_keys_guard.iter().any(|key| { - let verifying_key = VerifyingKey::::new(key.clone()); - verifying_key.verify(&signable, &key_signature).is_ok() - }); - - // Verify that the signable is valid for any one of Mojang's public keys - if !is_valid { - return Err(ChatError::InvalidPublicKey); - } - - Ok(()) - } - - pub async fn handle_client_information( - &self, - server: &Arc, - player: &Arc, - client_information: SClientInformationPlay<'_>, - ) { - if let (Ok(main_hand), Ok(chat_mode)) = ( - Hand::try_from(client_information.main_hand.0), - ChatMode::try_from(client_information.chat_mode.0), - ) { - if client_information.view_distance <= 0 { - self.kick(TextComponent::text( - "Cannot have zero or negative view distance!", - )) - .await; - return; - } - - let (update_settings, update_watched, main_hand_changed) = { - // 1. Load current snapshot - let current_config = player.config.load(); - - // 2. Calculate if settings changed before we overwrite - let main_hand_changed = current_config.main_hand != main_hand; - let update_settings = - main_hand_changed || current_config.skin_parts != client_information.skin_parts; - - let old_view_distance = current_config.view_distance; - let new_view_distance_raw = client_information.view_distance as u8; - - let update_watched = if old_view_distance.get() == new_view_distance_raw { - false - } else { - debug!( - "Player {} ({}) updated their render distance: {} -> {}.", - player.gameprofile.name, self.id, old_view_distance, new_view_distance_raw - ); - true - }; - - // 3. Construct the new config - // If view_distance is 0, we exit early (safe guard) - let Some(new_view_distance) = NonZeroU8::new(new_view_distance_raw) else { - return; - }; - - let new_config = PlayerConfig { - locale: client_information.locale.to_string(), - view_distance: new_view_distance, - chat_mode, - chat_colors: client_information.chat_colors, - skin_parts: client_information.skin_parts, - main_hand, - text_filtering: client_information.text_filtering, - server_listing: client_information.server_listing, - }; - - // 4. Atomically swap the new config into the player - player.config.store(std::sync::Arc::new(new_config)); - - (update_settings, update_watched, main_hand_changed) - }; - - if update_watched { - chunker::update_position(player).await; - } - - if main_hand_changed { - let mut event = PlayerChangedMainHandEvent::new(player.clone(), main_hand); - server.plugin_manager.fire(server, &mut event).await; - } - - if update_settings { - debug!( - "Player {} ({}) updated their skin.", - player.gameprofile.name, self.id, - ); - player.send_client_information(); - } - } else { - self.kick(TextComponent::text("Invalid hand or chat type")) - .await; - } - } - - pub async fn handle_client_status(&self, player: &Arc, client_status: SClientCommand) { - player.update_last_action_time(); - match client_status.action_id.0 { - 0 => { - // Perform respawn - if player.living_entity.health.load() > 0.0 { - return; - } - player.world().clone().respawn_player(player, false).await; - - { - let screen_handler = player.current_screen_handler.lock().await; - let mut screen_handler = screen_handler.lock().await; - screen_handler.sync_state().await; - }; - - // Restore abilities based on gamemode after respawn - { - let mut abilities = player.abilities.lock().await; - abilities.set_for_gamemode(player.gamemode.load()); - }; - player.send_abilities_update().await; - } - 1 => { - // Request stats - player.send_stats().await; - } - _ => { - self.kick(TextComponent::text("Invalid client status")) - .await; - } - } - } - - pub async fn handle_attack(&self, player: &Arc, attack: SAttack, server: &Arc) { - if !player.has_client_loaded() { - return; - } - player.update_last_action_time(); - let entity_id = attack.entity_id; - let player_entity = &player.get_entity(); - let world = player_entity.world.load_full(); - - let config = &server.advanced_config.pvp; - if !config.enabled { - return; - } - - if entity_id.0 == player.entity_id() { - self.kick(TextComponent::translate_cross( - translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, - translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, - [], - )) - .await; - return; - } - - let player_target = world.get_player_by_id(entity_id.0); - let target: Option> = player_target - .as_ref() - .map(|p| Arc::clone(p) as Arc) - .or_else(|| world.get_entity_by_id(entity_id.0)); - let Some(target) = target else { - self.kick(TextComponent::translate_cross( - translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, - translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, - [], - )) - .await; - return; - }; - if let Some(player_victim) = &player_target { - if player_victim.living_entity.health.load() <= 0.0 { - return; - } - if config.protect_creative && player_victim.gamemode.load() == GameMode::Creative { - world.play_sound( - Sound::EntityPlayerAttackNodamage, - SoundCategory::Players, - &player_victim.position(), - ); - return; - } - } - player.attack(target).await; - } - - #[expect(clippy::too_many_lines)] - pub async fn handle_interact( - &self, - player: &Arc, - interact: SInteract, - server: &Arc, - ) { - if !player.has_client_loaded() { - return; - } - player.update_last_action_time(); - let entity_id = interact.entity_id; - - let sneaking = interact.sneaking; - let player_entity = &player.get_entity(); - if player_entity.is_sneaking() != sneaking { - player_entity.set_sneaking(sneaking).await; - } - let Ok(action) = ActionType::try_from(interact.r#type.0) else { - self.kick(TextComponent::text("Invalid action type")).await; - return; - }; - - // Resolve the target entity for the event - let world = player_entity.world.load_full(); - let player_target = world.get_player_by_id(entity_id.0); - let target: Option> = player_target - .as_ref() - .map(|p| Arc::clone(p) as Arc) - .or_else(|| world.get_entity_by_id(entity_id.0)); - - if let Some(target) = target { - if player.gamemode.load() == GameMode::Spectator { - player.camera_target_id.store(Some(entity_id.0)); - player - .client - .send_packet_now(&CSetCamera::new(entity_id)) - .await; - return; - } - send_cancellable! {{ - server; - PlayerInteractEntityEvent::new( - player, - Arc::clone(&target), - action.clone(), - interact.target_position, - sneaking, - ); - - 'after: { - match event.action { - ActionType::Attack => { - let config = &server.advanced_config.pvp; - if !config.enabled { - return; - } - - if entity_id.0 == player.entity_id() { - self.kick(TextComponent::translate_cross(translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, [],)) - .await; - return; - } - - if let Some(player_victim) = &player_target { - if player_victim.living_entity.health.load() <= 0.0 { - return; - } - if config.protect_creative - && player_victim.gamemode.load() == GameMode::Creative - { - world - .play_sound( - Sound::EntityPlayerAttackNodamage, - SoundCategory::Players, - &player_victim.position(), - ) - ; - return; - } - } - player.attack(event.target).await; - } - ActionType::Interact | ActionType::InteractAt => { - let mut stack = player.inventory().held_item().await; - let target_entity = event.target.get_entity(); - if target_entity.entity_type.resource_name == "zombie_villager" - && stack.item.registry_key == "golden_apple" - { - player.trigger_advancement(crate::entity::player::advancement::trigger::AdvancementTrigger::CuredZombieVillager).await; - } - - let interacted = event.target.interact(player, &mut stack).await; - if !interacted { - server - .item_registry - .use_on_entity(&mut stack, player, event.target) - .await; - } - player.inventory().set_held_item(stack).await; - } - } - } - }} - } else { - // Entity not found - send_cancellable! {{ - server; - PlayerInteractUnknownEntityEvent::new(player, entity_id.0, action); - - 'after: { - if event.action == ActionType::Attack { - error!( - "Player id {} interacted with entity id {}, which was not found.", - player.entity_id(), - event.entity_id - ); - self.kick(TextComponent::translate_cross(translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, [],)) - .await; - } - } - }} - } - } - - #[expect(clippy::too_many_lines)] - pub async fn handle_player_action( - &self, - player: &Arc, - player_action: SPlayerAction, - server: &Server, - ) { - if !player.has_client_loaded() { - return; - } - player.update_last_action_time(); - match Status::try_from(player_action.status.0) { - Ok(status) => match status { - Status::StartedDigging => { - if !player.can_interact_with_block_at(&player_action.position, 1.0) { - warn!( - "Player {0} tried to interact with block out of reach at {1}", - player.gameprofile.name, player_action.position - ); - self.update_sequence(player, player_action.sequence.0); - return; - } - let position = player_action.position; - let entity = &player.get_entity(); - let world = entity.world.load_full(); - let (block, state) = world.get_block_and_state(&position); - - if let Some(server_arc) = world.server.upgrade() { - let mut event = - crate::plugin::api::events::block::block_damage::BlockDamageEvent::new( - player.clone(), - block, - position, - false, - ); - server_arc - .plugin_manager - .fire(&server_arc, &mut event) - .await; - if event.cancelled { - self.update_sequence(player, player_action.sequence.0); - return; - } - } - - if block == &pumpkin_data::Block::NOTE_BLOCK { - let props = - pumpkin_data::block_properties::NoteBlockLikeProperties::from_state_id( - state.id, block, - ); - crate::block::blocks::note::NoteBlock::play_note(&props, &world, &position) - .await; - player - .increment_stat( - StatisticCategory::Custom, - CustomStatistic::PlayNoteblock as i32, - 1, - ) - .await; - } - - let inventory = player.inventory(); - let held = inventory.held_item().await; - if !server.item_registry.can_mine(held.item, player) { - self.enqueue_packet(&CBlockUpdate::new( - position, - VarInt(i32::from(state.id.as_u16())), - )) - .await; - self.update_sequence(player, player_action.sequence.0); - return; - } - - // TODO: do validation - // TODO: Config - if player.gamemode.load() == GameMode::Creative { - // Block break & play sound - let new_state = world - .break_block( - &position, - Some(player.clone()), - BlockFlags::NOTIFY_NEIGHBORS | BlockFlags::SKIP_DROPS, - ) - .await; - if new_state.is_some() { - server - .block_registry - .broken(&world, block, player, &position, server, state) - .await; - } - self.sync_block_state_to_client(&world, position).await; - self.update_sequence(player, player_action.sequence.0); - return; - } - player.start_mining_time.store( - player.tick_counter.load(Ordering::Relaxed), - Ordering::Relaxed, - ); - if !state.is_air() { - let speed = block::calc_block_breaking(player, state, block).await; - // Instant break - if speed >= 1.0 { - let broken_state = world.get_block_state(&position); - let can_harvest = player.can_harvest(broken_state, block).await; - let new_state = world - .break_block( - &position, - Some(player.clone()), - BlockFlags::NOTIFY_NEIGHBORS, - ) - .await; - if new_state.is_some() { - server - .block_registry - .broken(&world, block, player, &position, server, broken_state) - .await; - player.apply_tool_damage_for_block_break(broken_state).await; - if can_harvest { - player.add_exhaustion(MINE_BLOCK_EXHAUSTION).await; - } - let item_id = player.inventory().held_item().await.item.id; - player - .increment_stat(StatisticCategory::Used, item_id as i32, 1) - .await; - player - .increment_stat( - StatisticCategory::Mined, - broken_state.id.as_u16() as i32, - 1, - ) - .await; - } - self.sync_block_state_to_client(&world, position).await; - } else { - player.mining.store(true, Ordering::Relaxed); - *player.mining_pos.lock().await = position; - let progress = (speed * 10.0) as i32; - player - .current_block_breaking_speed - .store(speed.to_bits(), Ordering::Relaxed); - world - .set_block_breaking( - entity, - position, - BlockBreakingProgress::Start { - stage: progress, - speed, - }, - ) - .await; - player - .current_block_destroy_stage - .store(progress, Ordering::Relaxed); - } - } - self.update_sequence(player, player_action.sequence.0); - } - Status::CancelledDigging => { - if !player.can_interact_with_block_at(&player_action.position, 1.0) { - warn!( - "Player {0} tried to interact with block out of reach at {1}", - player.gameprofile.name, player_action.position - ); - self.update_sequence(player, player_action.sequence.0); - return; - } - player.mining.store(false, Ordering::Relaxed); - let entity = &player.get_entity(); - entity - .world - .load() - .set_block_breaking( - entity, - player_action.position, - BlockBreakingProgress::Stop, - ) - .await; - self.update_sequence(player, player_action.sequence.0); - } - Status::FinishedDigging => { - // TODO: do validation - let location = player_action.position; - if !player.can_interact_with_block_at(&location, 1.0) { - warn!( - "Player {0} tried to interact with block out of reach at {1}", - player.gameprofile.name, player_action.position - ); - self.update_sequence(player, player_action.sequence.0); - return; - } - - // Block break & play sound - let entity = &player.get_entity(); - let world = entity.world.load_full(); - - player.mining.store(false, Ordering::Relaxed); - world - .set_block_breaking(entity, location, BlockBreakingProgress::Stop) - .await; - - let (block, state) = world.get_block_and_state(&location); - let block_drop = player.gamemode.load() != GameMode::Creative - && player.can_harvest(state, block).await; - - let new_state = world - .break_block( - &location, - Some(player.clone()), - if block_drop { - BlockFlags::NOTIFY_NEIGHBORS - } else { - BlockFlags::SKIP_DROPS | BlockFlags::NOTIFY_NEIGHBORS - }, - ) - .await; - if new_state.is_some() { - server - .block_registry - .broken(&world, block, player, &location, server, state) - .await; - - player.apply_tool_damage_for_block_break(state).await; - if block_drop { - player.add_exhaustion(MINE_BLOCK_EXHAUSTION).await; - } - let item_id = player.inventory().held_item().await.item.id; - player - .increment_stat(StatisticCategory::Used, item_id as i32, 1) - .await; - player - .increment_stat(StatisticCategory::Mined, state.id.as_u16() as i32, 1) - .await; - } - - self.sync_block_state_to_client(&world, location).await; - - self.update_sequence(player, player_action.sequence.0); - } - Status::DropItem => { - player.drop_held_item(false).await; - } - Status::DropItemStack => { - player.drop_held_item(true).await; - } - Status::ReleaseItemInUse => { - let item_in_use = player.living_entity.item_in_use.lock().await.clone(); - if let Some(stack) = item_in_use { - server.item_registry.on_stopped_using(&stack, player).await; - } - - player.living_entity.clear_active_hand().await; - } - Status::SwapItem => { - player.swap_item().await; - } - Status::SpearJab => { - debug!("todo"); - } - }, - Err(_) => self.kick(TextComponent::text("Invalid status")).await, - } - } - - pub async fn handle_keep_alive(&self, player: &Player, keep_alive: SKeepAlive) { - if self.wait_for_keep_alive.load(Ordering::Relaxed) - && keep_alive.keep_alive_id == self.keep_alive_id.load() - { - let ping = self.last_keep_alive_time.load().elapsed(); - // Vanilla logic - player.ping.store( - (player.ping.load(Ordering::Relaxed) * 3 + ping.as_millis() as u32) / 4, - Ordering::Relaxed, - ); - self.wait_for_keep_alive.store(false, Ordering::Relaxed); - } else { - self.kick(pumpkin_macros::translate_cross!( - translation::java::DISCONNECT_TIMEOUT, - translation::bedrock::DISCONNECT_TIMEOUT - )) - .await; - } - } - - pub fn update_sequence(&self, _player: &Player, sequence: i32) { - if sequence < 0 { - error!("Expected packet sequence >= 0"); - } - self.packet_sequence.store( - self.packet_sequence.load(Ordering::Relaxed).max(sequence), - Ordering::Relaxed, - ); - } - - async fn sync_block_state_to_client(&self, world: &World, position: BlockPos) { - let synced_state_id = world.get_block_state_id(&position); - self.send_packet_now(&CBlockUpdate::new( - position, - VarInt(i32::from(synced_state_id.as_u16())), - )) - .await; - } - - pub async fn handle_player_abilities( - &self, - player: &Arc, - player_abilities: SPlayerAbilities, - server: &Arc, - ) { - let (flying, allow_flying) = { - let abilities = player.abilities.lock().await; - (abilities.flying, abilities.allow_flying) - }; - - // Set the flying ability - let new_flying = player_abilities.flags & 0x02 != 0 && allow_flying; - if flying != new_flying { - send_cancellable! {{ - server; - PlayerToggleFlightEvent::new(player.clone(), new_flying); - 'after: { - if event.is_flying { - player.living_entity.fall_distance.store(0.0); - } - player.abilities.lock().await.flying = event.is_flying; - } - 'cancelled: { - player.send_abilities_update().await; - } - }} - } - } - - pub async fn handle_play_ping_request(&self, request: SPlayPingRequest) { - self.enqueue_packet(&CPingResponse::new(request.payload)) - .await; - } - - #[allow(clippy::too_many_lines)] - pub async fn handle_use_item_on( - &self, - player: &Arc, - use_item_on: SUseItemOn, - server: &Arc, - ) -> Result<(), BlockPlacingError> { - if !player.has_client_loaded() { - return Ok(()); - } - player.update_last_action_time(); - self.update_sequence(player, use_item_on.sequence.0); - - let position = use_item_on.position; - let cursor_pos = use_item_on.cursor_pos; - - let mut should_try_decrement = false; - - if !player.can_interact_with_block_at(&position, 1.0) { - // TODO: maybe log? - return Err(BlockPlacingError::BlockOutOfReach); - } - - let Ok(face) = BlockDirection::try_from(use_item_on.face.0) else { - return Err(BlockPlacingError::InvalidBlockFace); - }; - - let Ok(hand) = Hand::from_packet_id(use_item_on.hand.0) else { - return Err(BlockPlacingError::InvalidHand); - }; - - if player.gamemode.load() == GameMode::Spectator { - // TODO: openMenu - return Ok(()); - } - - let inventory = player.inventory(); - let held_item = inventory.held_item().await; - let off_hand_item = inventory.off_hand_item().await; - let held_item_empty = held_item.is_empty(); - let off_hand_item_empty = off_hand_item.is_empty(); - - let mut item = inventory.get_stack_in_hand(hand).await; - let item_id = item.item.id; - player - .increment_stat(StatisticCategory::Used, item_id as i32, 1) - .await; - - let entity = &player.get_entity(); - let world = entity.world.load_full(); - let block = world.get_block(&position); - - let event = PlayerInteractEvent::new( - player, - InteractAction::RightClickBlock, - block, - Some(position), - ); - - send_cancellable! {{ - server; - event; - 'cancelled: { - let state_id = world.get_block_state_id(&position); - self.enqueue_packet(&CBlockUpdate::new( - position, - VarInt(i32::from(state_id.as_u16())), - )) - .await; - return Ok(()); - } - }} - - let equipment_slot = if matches!(hand, Hand::Right) { - EquipmentSlot::MAIN_HAND - } else { - EquipmentSlot::OFF_HAND - }; - - let sneaking = player.get_entity().is_sneaking(); - - // Code based on the java class ServerPlayerInteractionManager - if !(sneaking && (!held_item_empty || !off_hand_item_empty)) { - let result = self - .call_use_item_on( - player, - &position, - &cursor_pos, - &face, - &mut item, - &equipment_slot, - &world, - block, - server, - ) - .await; - if result.consumes_action() { - // TODO: Trigger ANY_BLOCK_USE Criteria - - if matches!(result, BlockActionResult::SuccessServer) { - player.swing_hand(hand, true).await; - } - return Ok(()); - } - } - - let slot_index = if matches!(hand, Hand::Right) { - inventory.get_selected_slot() as usize - } else { - PlayerInventory::OFF_HAND_SLOT - }; - - if item.is_empty() { - // TODO item cool down - // If the hand is empty we stop here - return Ok(()); - } - - let before = item.clone(); - - server - .item_registry - .use_on_block(&mut item, player, position, face, cursor_pos, block, server) - .await; - - // Check if the item is a block, because not every item can be placed :D - let item_id = item.item.id; - if let Some(block) = Block::from_item_id(item_id) { - should_try_decrement = self - .run_is_block_place(player, block, server, use_item_on, position, face) - .await?; - } - - if should_try_decrement { - // TODO: Config - // Decrease block count - if player.gamemode.load() != GameMode::Creative { - item.decrement(1); - } - } - - let after = item.clone(); - - // Broadcast the break entity status before the slot sync; the client - // needs the old item texture in the slot for break particles. - if !before.is_empty() && after.is_empty() { - let slot = if slot_index == player.inventory.get_selected_slot() as usize { - &EquipmentSlot::MAIN_HAND - } else { - &EquipmentSlot::OFF_HAND - }; - player.world().send_entity_status( - player.get_entity(), - equipment_break_status(slot), - None, - ); - } - - if !after.are_equal(&before) { - player.sync_hand_slot(slot_index, after.clone()).await; - inventory.set_stack_in_hand(hand, after).await; - } - - Ok(()) - } - - #[expect(clippy::too_many_arguments)] - async fn call_use_item_on( - &self, - player: &Arc, - position: &BlockPos, - cursor_pos: &Vector3, - face: &BlockDirection, - held_item: &mut ItemStack, - equipment_slot: &EquipmentSlot, - world: &Arc, - block: &Block, - server: &Arc, - ) -> BlockActionResult { - let result = server - .block_registry - .use_with_item( - block, - player, - position, - &BlockHitResult { face, cursor_pos }, - held_item, - equipment_slot, - server, - world, - ) - .await; - - if result.consumes_action() { - // TODO: Trigger ITEM_USED_ON_BLOCK Criteria - return result; - } - - if matches!(result, BlockActionResult::PassToDefaultBlockAction) { - let result = server - .block_registry - .on_use( - block, - player, - position, - &BlockHitResult { face, cursor_pos }, - server, - world, - ) - .await; - - if result.consumes_action() { - // TODO: Trigger DEFAULT_BLOCK_USE Criteria - return result; - } - } - - BlockActionResult::Pass - } - - pub async fn handle_sign_update(&self, player: &Player, sign_data: SUpdateSign<'_>) { - let world = player.get_entity().world.load_full(); - let Some(block_entity) = world.get_block_entity(&sign_data.location) else { - return; - }; - let Some(sign_entity) = block_entity.as_any().downcast_ref::() else { - return; - }; - if sign_entity.is_waxed.load(Ordering::Relaxed) { - return; - } - - let text = if sign_data.is_front_text { - &sign_entity.front_text - } else { - &sign_entity.back_text - }; - - *text - .messages - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = [ - sign_data.line_1.into(), - sign_data.line_2.into(), - sign_data.line_3.into(), - sign_data.line_4.into(), - ]; - *sign_entity.currently_editing_player.lock().await = None; - world.update_block_entity(&block_entity); - } - - pub async fn handle_edit_book(&self, player: &Player, packet: SEditBook<'_>) { - let held_stack = player.inventory().held_item().await; - if held_stack.item.id != Item::WRITABLE_BOOK.id { - return; - } - - let pages: Vec = packet.pages.iter().map(|p| (*p).to_string()).collect(); - - if let Some(title) = packet.title { - let mut written_book = ItemStack::new(1, &Item::WRITTEN_BOOK); - let content = WrittenBookContentImpl { - title: title.to_string(), - author: player.gameprofile.name.clone(), - pages, - }; - written_book - .patch - .push((DataComponent::WrittenBookContent, Some(content.to_dyn()))); - player.inventory().set_held_item(written_book).await; - } else { - let mut writable_book = held_stack; - let content = WritableBookContentImpl { pages }; - writable_book - .patch - .retain(|(component, _)| *component != DataComponent::WritableBookContent); - writable_book - .patch - .push((DataComponent::WritableBookContent, Some(content.to_dyn()))); - player.inventory().set_held_item(writable_book).await; - } - } - - pub async fn handle_use_item( - &self, - player: &Arc, - use_item: &SUseItem, - server: &Arc, - ) { - if !player.has_client_loaded() { - return; - } - player.update_last_action_time(); - - let inventory = player.inventory(); - let Ok(hand) = Hand::from_packet_id(use_item.hand.0) else { - self.kick(TextComponent::text("InvalidHand")).await; - return; - }; - self.update_sequence(player, use_item.sequence.0); - - let mut item_in_hand = inventory.get_stack_in_hand(hand).await; - - let (item_id, _item) = (item_in_hand.item.id, item_in_hand.item); - player - .increment_stat(StatisticCategory::Used, item_id as i32, 1) - .await; - - let hit_result = player - .world() - .raycast( - player.eye_position(), - player.eye_position().add( - &(Vector3::rotation_vector(f64::from(use_item.pitch), f64::from(use_item.yaw)) - * 4.5), - ), - async |pos, world| { - let block = world.get_block(pos); - block != &Block::AIR && block != &Block::WATER && block != &Block::LAVA - }, - ) - .await; - - let event = if let Some((hit_pos, _hit_dir)) = hit_result { - PlayerInteractEvent::new( - player, - InteractAction::RightClickBlock, - player.world().get_block(&hit_pos), - Some(hit_pos), - ) - } else { - PlayerInteractEvent::new(player, InteractAction::RightClickAir, &Block::AIR, None) - }; - let (item_for_use, stack_for_use) = (item_in_hand.item, item_in_hand.clone()); - self.prepare_hand_item_for_use(player, hand, &mut item_in_hand) - .await; - - if !self - .should_continue_use_after_fish_event(server, player, hand, item_for_use) - .await - { - return; - } - - send_cancellable! {{ - server; - event; - 'after: { - server.item_registry.on_use(&stack_for_use, player).await; - } - }} - } - - async fn prepare_hand_item_for_use( - &self, - player: &Arc, - hand: Hand, - held: &mut ItemStack, - ) { - let inventory = player.inventory(); - - if let Some(cooldown) = held.get_use_cooldown() { - let group = cooldown - .cooldown_group - .clone() - .unwrap_or_else(|| held.item.registry_key.to_string()); - if player.is_on_cooldown(&group).await { - return; - } - } - - if held.get_data_component::().is_some() - || held.get_data_component::().is_some() - { - // If its food we want to make sure we can actually consume it - if let Some(food) = held.get_data_component::() { - if player.abilities.lock().await.invulnerable - || food.can_always_eat - || player.hunger_manager.level.load() < 20 - { - player - .living_entity - .set_active_hand(hand, held.clone(), held.get_max_use_time()) - .await; - } - } else { - player - .living_entity - .set_active_hand(hand, held.clone(), held.get_max_use_time()) - .await; - } - } - let equipment_slot = held - .get_data_component::() - .map(|equippable| equippable.slot.clone()); - if let Some(slot) = equipment_slot { - // The equipment lock has to be released before touching the hand again: - // the off hand lives in the same map, so holding it here would deadlock. - let current_equipped = inventory.entity_equipment.lock().await.get(&slot); - if current_equipped.are_items_and_components_equal(held) { - return; - } - - player.enqueue_equipment_change(&slot, held).await; - - let equipped = if current_equipped.is_empty() { - let equipped = held.clone(); - held.decrement_unless_creative(player.gamemode.load(), 1); - equipped - } else { - std::mem::replace(held, current_equipped) - }; - inventory.entity_equipment.lock().await.put(&slot, equipped); - inventory.set_stack_in_hand(hand, held.clone()).await; - } - } - - async fn should_continue_use_after_fish_event( - &self, - server: &Arc, - player: &Arc, - hand: Hand, - item_for_use: &Item, - ) -> bool { - if item_for_use.id != Item::FISHING_ROD.id { - return true; - } - - // TODO: Apply fishing rod durability on retrieval based on catch type. - let mut fish_event = PlayerFishEvent::new( - player.clone(), - None, - uuid::Uuid::nil(), - String::new(), - PlayerFishState::Fishing, - hand, - 0, - ); - server.plugin_manager.fire(server, &mut fish_event).await; - !fish_event.cancelled - } - - pub async fn handle_set_held_item( - &self, - server: &Arc, - - player: &Player, - held: SSetHeldItem, - ) { - player.update_last_action_time(); - let slot = held.slot; - if !(0..=8).contains(&slot) { - self.kick(TextComponent::text("Invalid held slot")).await; - return; - } - let slot = slot as u8; - let previous_slot = player.inventory.get_selected_slot(); - let Some(player_arc) = player.world().get_player_by_uuid(player.gameprofile.id) else { - return; - }; - let mut event = PlayerItemHeldEvent::new(player_arc, previous_slot, slot); - server.plugin_manager.fire(server, &mut event).await; - if event.cancelled { - player - .client - .enqueue_packet(&CSetSelectedSlot::new(previous_slot as i8)) - .await; - return; - } - - let inv = player.inventory(); - inv.set_selected_slot(slot); - let stack = inv.held_item().await; - let equipment = &[(EquipmentSlot::MAIN_HAND, stack)]; - player.living_entity.send_equipment_changes(equipment); - } - - pub async fn handle_set_creative_slot( - &self, - player: &Player, - packet: SSetCreativeSlot, - ) -> Result<(), InventoryError> { - if player.gamemode.load() != GameMode::Creative { - return Err(InventoryError::PermissionError); - } - let is_negative = packet.slot < 0; - let valid_slot = packet.slot >= 1 && packet.slot as usize <= 45; - let item_stack = packet - .clicked_item - .to_stack_for_version(&self.version.load()); - let is_legal = - item_stack.is_empty() || item_stack.item_count <= item_stack.get_max_stack_size(); - - if valid_slot && is_legal { - let mut player_screen_handler = player.player_screen_handler.lock().await; - - let is_armor_equipped = player_screen_handler - .get_slot(packet.slot as usize) - .get_stack() - .await - .are_equal(&item_stack); - if !is_armor_equipped { - if (5..9).contains(&packet.slot) { - player - .enqueue_equipment_change( - &match packet.slot { - 5 => EquipmentSlot::HEAD, - 6 => EquipmentSlot::CHEST, - 7 => EquipmentSlot::LEGS, - 8 => EquipmentSlot::FEET, - _ => { - tracing::error!("Invalid armor slot: {}", packet.slot); - EquipmentSlot::HEAD - } - }, - &item_stack, - ) - .await; - } else if (36..45).contains(&packet.slot) { - let slot = packet.slot - 36; - if player.inventory().get_selected_slot() == slot as u8 { - let equipment = &[(EquipmentSlot::MAIN_HAND, item_stack.clone())]; - player.living_entity.send_equipment_changes(equipment); - } - } - } - - player_screen_handler - .get_slot(packet.slot as usize) - .set_stack(item_stack.clone()) - .await; - player_screen_handler.set_received_stack(packet.slot as usize, item_stack); - player_screen_handler.send_content_updates().await; - drop(player_screen_handler); - } else if is_negative && is_legal { - // Item drop - player.drop_item(item_stack).await; - } - Ok(()) - } - - pub async fn handle_chunk_batch(&self, player: &Player, packet: SChunkBatch) { - player - .chunk_manager - .lock() - .await - .handle_acknowledge(packet.chunks_per_tick); - trace!( - "Client requested {} chunks per tick", - packet.chunks_per_tick - ); - } - - pub async fn handle_close_container( - &self, - player: &Arc, - _server: &Server, - _packet: SCloseContainer, - ) { - player.on_handled_screen_closed().await; - } - - pub async fn handle_command_suggestion( - &self, - player: &Arc, - packet: SCommandSuggestion<'_>, - server: &Arc, - ) { - let Some(cmd) = &packet.command.get(1..) else { - return; - }; - - let Some((last_word_start, _)) = cmd.char_indices().rfind(|(_, c)| c.is_whitespace()) - else { - return; - }; - - let suggestions = server - .command_dispatcher - .read() - .await - .suggest(cmd, &player.get_command_source(server).await) - .await; - - let response = CCommandSuggestions::new( - packet.id, - ((last_word_start + 2) as i32).into(), - ((cmd.len() - last_word_start - 1) as i32).into(), - suggestions.into(), - ); - - self.enqueue_packet(&response).await; - } - - pub fn handle_cookie_response(&self, packet: &SPCookieResponse<'_>) { - // TODO: allow plugins to access this - debug!( - "Received cookie_response[play]: key: \"{}\", payload_length: \"{:?}\"", - packet.key, - packet.payload.as_ref().map(|p| p.len()) - ); - } - - async fn run_is_block_place( - &self, - player: &Arc, - block: &'static Block, - server: &Arc, - use_item_on: SUseItemOn, - location: BlockPos, - face: BlockDirection, - ) -> Result { - match server - .block_registry - .place_block(player, block, server, &use_item_on, location, face) - .await - { - Ok(Some((final_block_pos, new_state))) => { - self.send_packet_now(&CBlockUpdate::new( - final_block_pos, - VarInt(i32::from(new_state.as_u16())), - )) - .await; - Ok(true) - } - Ok(None) => Ok(false), - Err(crate::block::registry::BlockPlacingError::InvalidGamemode) => { - Err(BlockPlacingError::InvalidGamemode) - } - Err(crate::block::registry::BlockPlacingError::BlockOutOfWorld) => { - Err(BlockPlacingError::BlockOutOfWorld) - } - } - } - - /// Checks if the block placed was a sign, then opens a dialog. - pub async fn send_sign_packet(&self, block_position: BlockPos, is_front_text: bool) { - self.enqueue_packet(&COpenSignEditor::new(block_position, is_front_text)) - .await; - } - - pub async fn handle_select_trade(&self, player: &Arc, packet: SSelectTrade) { - let screen_handler = player.current_screen_handler.lock().await; - let mut screen_handler = screen_handler.lock().await; - if let Some(merchant) = screen_handler - .as_any_mut() - .downcast_mut::() - { - merchant - .set_selected_offer(packet.selected_slot.0 as usize) - .await; - } - } - - pub async fn handle_seen_advancement(&self, player: &Arc, packet: SSeenAdvancement) { - if let SSeenAdvancement::OpenTab(tab) = packet { - let advancement = Advancement::from_minecraft_name(&tab.to_string()); - if advancement.is_some() { - player - .advancements - .lock() - .await - .set_selected_tab(advancement) - .await; - } - } - } - - pub async fn handle_bundle_item_selected( - &self, - player: &Arc, - 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 - ); - } - - pub async fn handle_teleport_to_entity( - &self, - player: &Arc, - packet: STeleportToEntity, - server: &Server, - ) { - if !player.has_client_loaded() { - return; - } - player.update_last_action_time(); - - if player.gamemode.load() != GameMode::Spectator { - return; - } - - if let Some(target_player) = server.get_player_by_uuid(packet.target) { - let target_pos = target_player.living_entity.entity.pos.load(); - let target_yaw = target_player.living_entity.entity.yaw.load(); - let target_pitch = target_player.living_entity.entity.pitch.load(); - - let target_id = target_player.living_entity.entity.entity_id; - player.camera_target_id.store(Some(target_id)); - player - .client - .send_packet_now(&CSetCamera::new(target_id.into())) - .await; - - player - .request_teleport(target_pos, target_yaw, target_pitch) - .await; - } - } - - pub fn handle_test_instance_block_action( - &self, - player: &Arc, - packet: &STestInstanceBlockAction<'_>, - ) { - if !player.has_client_loaded() { - return; - } - player.update_last_action_time(); - debug!( - "Test instance block action at {:?}: action={:?}", - packet.pos, packet.action - ); - } - - pub fn handle_set_test_block(&self, player: &Arc, packet: &SSetTestBlock<'_>) { - if !player.has_client_loaded() { - return; - } - player.update_last_action_time(); - debug!( - "Set test block at {:?}: mode={:?}, message={}", - packet.position, packet.mode, packet.message - ); - } - - pub fn handle_debug_subscription_request( - &self, - player: &Arc, - packet: &pumpkin_protocol::java::server::play::SDebugSubscriptionRequest, - ) { - if player.permission_lvl.load() >= PermissionLvl::Two && packet.sample_type.0 == 0 { - player - .subscribed_debug_sample - .store(true, Ordering::Relaxed); - } - } - - pub fn handle_debug_sample_subscription( - &self, - player: &Arc, - packet: &pumpkin_protocol::java::server::play::SDebugSampleSubscription, - ) { - if player.permission_lvl.load() >= PermissionLvl::Two && packet.sample_type.0 == 0 { - player - .subscribed_debug_sample - .store(true, Ordering::Relaxed); - } - } -} diff --git a/crates/pumpkin/src/net/java/play/attack.rs b/crates/pumpkin/src/net/java/play/attack.rs new file mode 100644 index 000000000..88f84fbc7 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/attack.rs @@ -0,0 +1,58 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_attack(&self, player: &Arc, attack: SAttack, server: &Arc) { + if !player.has_client_loaded() { + return; + } + player.update_last_action_time(); + let entity_id = attack.entity_id; + let player_entity = &player.get_entity(); + let world = player_entity.world.load_full(); + + let config = &server.advanced_config.pvp; + if !config.enabled { + return; + } + + if entity_id.0 == player.entity_id() { + self.kick(TextComponent::translate_cross( + translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, + translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, + [], + )) + .await; + return; + } + + let player_target = world.get_player_by_id(entity_id.0); + let target: Option> = player_target + .as_ref() + .map(|p| Arc::clone(p) as Arc) + .or_else(|| world.get_entity_by_id(entity_id.0)); + let Some(target) = target else { + self.kick(TextComponent::translate_cross( + translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, + translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, + [], + )) + .await; + return; + }; + if let Some(player_victim) = &player_target { + if player_victim.living_entity.health.load() <= 0.0 { + return; + } + if config.protect_creative && player_victim.gamemode.load() == GameMode::Creative { + world.play_sound( + Sound::EntityPlayerAttackNodamage, + SoundCategory::Players, + &player_victim.position(), + ); + return; + } + } + player.attack(target).await; + } +} diff --git a/crates/pumpkin/src/net/java/play/bundle_item_selected.rs b/crates/pumpkin/src/net/java/play/bundle_item_selected.rs new file mode 100644 index 000000000..7e1a62b87 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/bundle_item_selected.rs @@ -0,0 +1,27 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_bundle_item_selected( + &self, + player: &Arc, + 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 + ); + } +} diff --git a/crates/pumpkin/src/net/java/play/change_game_mode.rs b/crates/pumpkin/src/net/java/play/change_game_mode.rs new file mode 100644 index 000000000..128d8ca93 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/change_game_mode.rs @@ -0,0 +1,26 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_change_game_mode( + &self, + player: &Arc, + change_game_mode: SChangeGameMode, + ) { + if player.permission_lvl.load() >= PermissionLvl::Two { + player.set_gamemode(change_game_mode.game_mode).await; + let gamemode_string = format!("gameMode.{}", change_game_mode.game_mode.name()); + player + .send_system_message(&TextComponent::translate_cross( + translation::java::COMMANDS_GAMEMODE_SUCCESS_SELF, + translation::bedrock::COMMANDS_GAMEMODE_SUCCESS_SELF, + [TextComponent::translate_cross( + gamemode_string.clone(), + gamemode_string, + [], + )], + )) + .await; + } + } +} diff --git a/crates/pumpkin/src/net/java/play/chat_command.rs b/crates/pumpkin/src/net/java/play/chat_command.rs new file mode 100644 index 000000000..166894f9e --- /dev/null +++ b/crates/pumpkin/src/net/java/play/chat_command.rs @@ -0,0 +1,45 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_chat_command( + &self, + player: &Arc, + server: &Arc, + command: &SChatCommand<'_>, + ) { + player.update_last_action_time(); + let player_clone = player.clone(); + let server_clone = server.clone(); + send_cancellable! {{ + server; + PlayerCommandSendEvent { + player: player.clone(), + command: command.command.to_string(), + cancelled: false + }; + + 'after: { + let command = event.command; + let command_clone = command.clone(); + // Some commands can take a long time to execute. If they do, they block packet processing for the player. + // That's why we will spawn a task instead. + server.spawn_task(async move { + let dispatcher = server_clone.command_dispatcher.read().await; + dispatcher.handle_command( + &player_clone.get_command_source(&server_clone).await, + &command_clone + ).await; + }); + + if server.advanced_config.commands.log_console { + info!( + "Player ({}): executed command /{}", + player.gameprofile.name, + command + ); + } + } + }} + } +} diff --git a/crates/pumpkin/src/net/java/play/chat_message.rs b/crates/pumpkin/src/net/java/play/chat_message.rs new file mode 100644 index 000000000..c9aa75452 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/chat_message.rs @@ -0,0 +1,220 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_chat_message( + &self, + server: &Arc, + player: &Arc, + chat_message: SChatMessage<'_>, + ) { + player.update_last_action_time(); + let gameprofile = &player.gameprofile; + + if let Err(err) = self + .validate_chat_message(server, player, &chat_message) + .await + { + log_at_level!( + err.severity(), + "{} (uuid {}) {}", + gameprofile.name, + gameprofile.id, + err + ); + if err.is_kick() + && let Some(reason) = err.client_kick_reason() + { + self.kick(TextComponent::text(reason)).await; + } + return; + } + + send_cancellable! {{ + server; + PlayerChatEvent::new(player.clone(), chat_message.message.to_string(), vec![]); + + 'after: { + info!(" {}: {}", gameprofile.name, event.message); + + let config = &server.advanced_config; + + let message = match seasonal_events::modify_chat_message(&event.message, config) { + Some(m) => m, + None => event.message.clone(), + }; + + let decorated_message = TextComponent::chat_decorated( + &config.chat.format, + &gameprofile.name, + &message, + ); + + let entity = &player.get_entity(); + let world = entity.world.load_full(); + if server.basic_config.allow_chat_reports { + world.broadcast_secure_player_chat(player, &chat_message, &decorated_message).await; + } else { + let je_packet = CSystemChatMessage::new( + &decorated_message, + false, + ); + let be_packet = SText::new( + message, player.gameprofile.name.clone() + ); + + world.broadcast_editioned(&je_packet, &be_packet).await; + } + } + }} + } + + /// Runs all vanilla checks for a valid chat message + pub async fn validate_chat_message( + &self, + server: &Server, + player: &Arc, + chat_message: &SChatMessage<'_>, + ) -> Result<(), ChatError> { + // Check for oversized messages + // If we're able to find the 257th UTF-16 character, the message is too big. + if chat_message.message.encode_utf16().nth(256).is_some() { + return Err(ChatError::OversizedMessage); + } + // Check for illegal characters + if chat_message + .message + .chars() + .any(|c| c == '§' || c < ' ' || c == '\x7F') + { + return Err(ChatError::IllegalCharacters); + } + // These checks are only run in secure chat mode + if server.basic_config.allow_chat_reports { + // Check for unsigned chat + if let Some(signature) = &chat_message.signature { + if signature.len() != 256 { + return Err(ChatError::UnsignedChat); // Signature is the wrong length + } + } else { + return Err(ChatError::UnsignedChat); // There is no signature + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + + // Verify message timestamp + if chat_message.timestamp > now || chat_message.timestamp < (now - CHAT_MESSAGE_MAX_AGE) + { + return Err(ChatError::OutOfOrderChat); + } + + // Verify session expiry + if player.chat_session.lock().await.expires_at < now { + return Err(ChatError::ExpiredPublicKey); + } + + // Validate previous signature checksum (new in 1.21.5) + // The client can bypass this check by sending 0 + if chat_message.checksum != 0 { + let checksum = + polynomial_rolling_hash(player.signature_cache.lock().await.last_seen.as_ref()); + if checksum != chat_message.checksum { + return Err(ChatError::ChatValidationFailed); + } + } + } + Ok(()) + } + + pub async fn handle_chat_session_update( + &self, + player: &Arc, + server: &Server, + session: SPlayerSession, + ) { + // Keep the chat session default if we don't want reports + if !server.basic_config.allow_chat_reports { + return; + } + + if let Err(err) = self.validate_chat_session(player, server, &session) { + log_at_level!( + err.severity(), + "{} (uuid {}) {}", + player.gameprofile.name, + player.gameprofile.id, + err + ); + if err.is_kick() + && let Some(reason) = err.client_kick_reason() + { + self.kick(TextComponent::text(reason)).await; + } + return; + } + + // Update the chat session fields + *player.chat_session.lock().await = ChatSession::new( + session.session_id, + session.expires_at, + session.public_key.clone(), + session.key_signature.clone(), + ); + + server.broadcast_packet_all(&CPlayerInfoUpdate::new( + 0x02, + &[pumpkin_protocol::java::client::play::Player { + uuid: player.gameprofile.id, + actions: &[PlayerAction::InitializeChat(Some(InitChat { + session_id: session.session_id, + expires_at: session.expires_at, + public_key: session.public_key.clone(), + signature: session.key_signature.clone(), + }))], + }], + )); + } + + /// Runs vanilla checks for a valid player session + pub fn validate_chat_session( + &self, + player: &Player, + server: &Server, + session: &SPlayerSession, + ) -> Result<(), ChatError> { + // Verify session expiry + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + if session.expires_at < now { + return Err(ChatError::InvalidPublicKey); + } + + let key_signature = RsaPkcs1v15Signature::try_from(session.key_signature.as_ref()) + .map_err(|_| ChatError::InvalidPublicKey)?; + + let mut signable = Vec::new(); + signable.extend_from_slice(player.gameprofile.id.as_bytes()); + signable.extend_from_slice(&session.expires_at.to_be_bytes()); + signable.extend_from_slice(&session.public_key); + + let public_keys_guard = server.mojang_public_keys.load(); + + // Verify signature with RSA-SHA1 + let is_valid = public_keys_guard.iter().any(|key| { + let verifying_key = VerifyingKey::::new(key.clone()); + verifying_key.verify(&signable, &key_signature).is_ok() + }); + + // Verify that the signable is valid for any one of Mojang's public keys + if !is_valid { + return Err(ChatError::InvalidPublicKey); + } + + Ok(()) + } +} diff --git a/crates/pumpkin/src/net/java/play/chunk_batch.rs b/crates/pumpkin/src/net/java/play/chunk_batch.rs new file mode 100644 index 000000000..56caad8bb --- /dev/null +++ b/crates/pumpkin/src/net/java/play/chunk_batch.rs @@ -0,0 +1,16 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_chunk_batch(&self, player: &Player, packet: SChunkBatch) { + player + .chunk_manager + .lock() + .await + .handle_acknowledge(packet.chunks_per_tick); + trace!( + "Client requested {} chunks per tick", + packet.chunks_per_tick + ); + } +} diff --git a/crates/pumpkin/src/net/java/play/client_command.rs b/crates/pumpkin/src/net/java/play/client_command.rs new file mode 100644 index 000000000..f785a63a1 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/client_command.rs @@ -0,0 +1,38 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_client_status(&self, player: &Arc, client_status: SClientCommand) { + player.update_last_action_time(); + match client_status.action_id.0 { + 0 => { + // Perform respawn + if player.living_entity.health.load() > 0.0 { + return; + } + player.world().clone().respawn_player(player, false).await; + + { + let screen_handler = player.current_screen_handler.lock().await; + let mut screen_handler = screen_handler.lock().await; + screen_handler.sync_state().await; + }; + + // Restore abilities based on gamemode after respawn + { + let mut abilities = player.abilities.lock().await; + abilities.set_for_gamemode(player.gamemode.load()); + }; + player.send_abilities_update().await; + } + 1 => { + // Request stats + player.send_stats().await; + } + _ => { + self.kick(TextComponent::text("Invalid client status")) + .await; + } + } + } +} diff --git a/crates/pumpkin/src/net/java/play/client_information.rs b/crates/pumpkin/src/net/java/play/client_information.rs new file mode 100644 index 000000000..374e61ecc --- /dev/null +++ b/crates/pumpkin/src/net/java/play/client_information.rs @@ -0,0 +1,89 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_client_information( + &self, + server: &Arc, + player: &Arc, + client_information: SClientInformationPlay<'_>, + ) { + if let (Ok(main_hand), Ok(chat_mode)) = ( + Hand::try_from(client_information.main_hand.0), + ChatMode::try_from(client_information.chat_mode.0), + ) { + if client_information.view_distance <= 0 { + self.kick(TextComponent::text( + "Cannot have zero or negative view distance!", + )) + .await; + return; + } + + let (update_settings, update_watched, main_hand_changed) = { + // 1. Load current snapshot + let current_config = player.config.load(); + + // 2. Calculate if settings changed before we overwrite + let main_hand_changed = current_config.main_hand != main_hand; + let update_settings = + main_hand_changed || current_config.skin_parts != client_information.skin_parts; + + let old_view_distance = current_config.view_distance; + let new_view_distance_raw = client_information.view_distance as u8; + + let update_watched = if old_view_distance.get() == new_view_distance_raw { + false + } else { + debug!( + "Player {} ({}) updated their render distance: {} -> {}.", + player.gameprofile.name, self.id, old_view_distance, new_view_distance_raw + ); + true + }; + + // 3. Construct the new config + // If view_distance is 0, we exit early (safe guard) + let Some(new_view_distance) = NonZeroU8::new(new_view_distance_raw) else { + return; + }; + + let new_config = PlayerConfig { + locale: client_information.locale.to_string(), + view_distance: new_view_distance, + chat_mode, + chat_colors: client_information.chat_colors, + skin_parts: client_information.skin_parts, + main_hand, + text_filtering: client_information.text_filtering, + server_listing: client_information.server_listing, + }; + + // 4. Atomically swap the new config into the player + player.config.store(std::sync::Arc::new(new_config)); + + (update_settings, update_watched, main_hand_changed) + }; + + if update_watched { + chunker::update_position(player).await; + } + + if main_hand_changed { + let mut event = PlayerChangedMainHandEvent::new(player.clone(), main_hand); + server.plugin_manager.fire(server, &mut event).await; + } + + if update_settings { + debug!( + "Player {} ({}) updated their skin.", + player.gameprofile.name, self.id, + ); + player.send_client_information(); + } + } else { + self.kick(TextComponent::text("Invalid hand or chat type")) + .await; + } + } +} diff --git a/crates/pumpkin/src/net/java/play/close_container.rs b/crates/pumpkin/src/net/java/play/close_container.rs new file mode 100644 index 000000000..b661ab792 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/close_container.rs @@ -0,0 +1,13 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_close_container( + &self, + player: &Arc, + _server: &Server, + _packet: SCloseContainer, + ) { + player.on_handled_screen_closed().await; + } +} diff --git a/crates/pumpkin/src/net/java/play/command_suggestion.rs b/crates/pumpkin/src/net/java/play/command_suggestion.rs new file mode 100644 index 000000000..476602d80 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/command_suggestion.rs @@ -0,0 +1,36 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_command_suggestion( + &self, + player: &Arc, + packet: SCommandSuggestion<'_>, + server: &Arc, + ) { + let Some(cmd) = &packet.command.get(1..) else { + return; + }; + + let Some((last_word_start, _)) = cmd.char_indices().rfind(|(_, c)| c.is_whitespace()) + else { + return; + }; + + let suggestions = server + .command_dispatcher + .read() + .await + .suggest(cmd, &player.get_command_source(server).await) + .await; + + let response = CCommandSuggestions::new( + packet.id, + ((last_word_start + 2) as i32).into(), + ((cmd.len() - last_word_start - 1) as i32).into(), + suggestions.into(), + ); + + self.enqueue_packet(&response).await; + } +} diff --git a/crates/pumpkin/src/net/java/play/confirm_teleport.rs b/crates/pumpkin/src/net/java/play/confirm_teleport.rs new file mode 100644 index 000000000..6f043375c --- /dev/null +++ b/crates/pumpkin/src/net/java/play/confirm_teleport.rs @@ -0,0 +1,31 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_confirm_teleport( + &self, + player: &Player, + confirm_teleport: SConfirmTeleport, + ) { + let mut awaiting_teleport = player.awaiting_teleport.lock().await; + if let Some((id, position)) = awaiting_teleport.as_ref() { + if id == &confirm_teleport.teleport_id { + // We should set the position now to what we requested in the teleport packet. + // This may fix issues when the client sends the position while being teleported. + player.get_entity().set_pos(*position); + + *awaiting_teleport = None; + drop(awaiting_teleport); + } else { + drop(awaiting_teleport); + self.kick(TextComponent::text("Wrong teleport id")).await; + } + } else { + drop(awaiting_teleport); + self.kick(TextComponent::text( + "Send Teleport confirm, but we did not teleport", + )) + .await; + } + } +} diff --git a/crates/pumpkin/src/net/java/play/cookie_response.rs b/crates/pumpkin/src/net/java/play/cookie_response.rs new file mode 100644 index 000000000..4cf3d8ac0 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/cookie_response.rs @@ -0,0 +1,13 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub fn handle_cookie_response(&self, packet: &SPCookieResponse<'_>) { + // TODO: allow plugins to access this + debug!( + "Received cookie_response[play]: key: \"{}\", payload_length: \"{:?}\"", + packet.key, + packet.payload.as_ref().map(|p| p.len()) + ); + } +} diff --git a/crates/pumpkin/src/net/java/play/debug_sample_subscription.rs b/crates/pumpkin/src/net/java/play/debug_sample_subscription.rs new file mode 100644 index 000000000..98dd5e724 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/debug_sample_subscription.rs @@ -0,0 +1,16 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub fn handle_debug_sample_subscription( + &self, + player: &Arc, + packet: &pumpkin_protocol::java::server::play::SDebugSampleSubscription, + ) { + if player.permission_lvl.load() >= PermissionLvl::Two && packet.sample_type.0 == 0 { + player + .subscribed_debug_sample + .store(true, Ordering::Relaxed); + } + } +} diff --git a/crates/pumpkin/src/net/java/play/debug_subscription_request.rs b/crates/pumpkin/src/net/java/play/debug_subscription_request.rs new file mode 100644 index 000000000..d316ccd37 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/debug_subscription_request.rs @@ -0,0 +1,16 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub fn handle_debug_subscription_request( + &self, + player: &Arc, + packet: &pumpkin_protocol::java::server::play::SDebugSubscriptionRequest, + ) { + if player.permission_lvl.load() >= PermissionLvl::Two && packet.sample_type.0 == 0 { + player + .subscribed_debug_sample + .store(true, Ordering::Relaxed); + } + } +} diff --git a/crates/pumpkin/src/net/java/play/edit_book.rs b/crates/pumpkin/src/net/java/play/edit_book.rs new file mode 100644 index 000000000..bf8bf1223 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/edit_book.rs @@ -0,0 +1,36 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_edit_book(&self, player: &Player, packet: SEditBook<'_>) { + let held_stack = player.inventory().held_item().await; + if held_stack.item.id != Item::WRITABLE_BOOK.id { + return; + } + + let pages: Vec = packet.pages.iter().map(|p| (*p).to_string()).collect(); + + if let Some(title) = packet.title { + let mut written_book = ItemStack::new(1, &Item::WRITTEN_BOOK); + let content = WrittenBookContentImpl { + title: title.to_string(), + author: player.gameprofile.name.clone(), + pages, + }; + written_book + .patch + .push((DataComponent::WrittenBookContent, Some(content.to_dyn()))); + player.inventory().set_held_item(written_book).await; + } else { + let mut writable_book = held_stack; + let content = WritableBookContentImpl { pages }; + writable_book + .patch + .retain(|(component, _)| *component != DataComponent::WritableBookContent); + writable_book + .patch + .push((DataComponent::WritableBookContent, Some(content.to_dyn()))); + player.inventory().set_held_item(writable_book).await; + } + } +} diff --git a/crates/pumpkin/src/net/java/play/interact.rs b/crates/pumpkin/src/net/java/play/interact.rs new file mode 100644 index 000000000..529c0fa60 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/interact.rs @@ -0,0 +1,129 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + #[expect(clippy::too_many_lines)] + pub async fn handle_interact( + &self, + player: &Arc, + interact: SInteract, + server: &Arc, + ) { + if !player.has_client_loaded() { + return; + } + player.update_last_action_time(); + let entity_id = interact.entity_id; + + let sneaking = interact.sneaking; + let player_entity = &player.get_entity(); + if player_entity.is_sneaking() != sneaking { + player_entity.set_sneaking(sneaking).await; + } + let Ok(action) = ActionType::try_from(interact.r#type.0) else { + self.kick(TextComponent::text("Invalid action type")).await; + return; + }; + + // Resolve the target entity for the event + let world = player_entity.world.load_full(); + let player_target = world.get_player_by_id(entity_id.0); + let target: Option> = player_target + .as_ref() + .map(|p| Arc::clone(p) as Arc) + .or_else(|| world.get_entity_by_id(entity_id.0)); + + if let Some(target) = target { + if player.gamemode.load() == GameMode::Spectator { + player.camera_target_id.store(Some(entity_id.0)); + player + .client + .send_packet_now(&CSetCamera::new(entity_id)) + .await; + return; + } + send_cancellable! {{ + server; + PlayerInteractEntityEvent::new( + player, + Arc::clone(&target), + action.clone(), + interact.target_position, + sneaking, + ); + + 'after: { + match event.action { + ActionType::Attack => { + let config = &server.advanced_config.pvp; + if !config.enabled { + return; + } + + if entity_id.0 == player.entity_id() { + self.kick(TextComponent::translate_cross(translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, [],)) + .await; + return; + } + + if let Some(player_victim) = &player_target { + if player_victim.living_entity.health.load() <= 0.0 { + return; + } + if config.protect_creative + && player_victim.gamemode.load() == GameMode::Creative + { + world + .play_sound( + Sound::EntityPlayerAttackNodamage, + SoundCategory::Players, + &player_victim.position(), + ) + ; + return; + } + } + player.attack(event.target).await; + } + ActionType::Interact | ActionType::InteractAt => { + let mut stack = player.inventory().held_item().await; + let target_entity = event.target.get_entity(); + if target_entity.entity_type.resource_name == "zombie_villager" + && stack.item.registry_key == "golden_apple" + { + player.trigger_advancement(crate::entity::player::advancement::trigger::AdvancementTrigger::CuredZombieVillager).await; + } + + let interacted = event.target.interact(player, &mut stack).await; + if !interacted { + server + .item_registry + .use_on_entity(&mut stack, player, event.target) + .await; + } + player.inventory().set_held_item(stack).await; + } + } + } + }} + } else { + // Entity not found + send_cancellable! {{ + server; + PlayerInteractUnknownEntityEvent::new(player, entity_id.0, action); + + 'after: { + if event.action == ActionType::Attack { + error!( + "Player id {} interacted with entity id {}, which was not found.", + player.entity_id(), + event.entity_id + ); + self.kick(TextComponent::translate_cross(translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, translation::java::MULTIPLAYER_DISCONNECT_INVALID_ENTITY_ATTACKED, [],)) + .await; + } + } + }} + } + } +} diff --git a/crates/pumpkin/src/net/java/play/jigsaw_generate.rs b/crates/pumpkin/src/net/java/play/jigsaw_generate.rs new file mode 100644 index 000000000..066614700 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/jigsaw_generate.rs @@ -0,0 +1,21 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_jigsaw_generate(&self, player: &Arc, generate: SJigsawGenerate) { + if !player.is_creative() { + return; + } + if player.permission_lvl.load() < PermissionLvl::Two { + return; + } + let pos = generate.pos; + if let Some(block_entity) = player.world().get_block_entity(&pos) + && let Some(jigsaw_block) = block_entity.as_any().downcast_ref::() + { + jigsaw_block + .generate(&player.world(), generate.levels.0, generate.keep_jigsaws) + .await; + } + } +} diff --git a/crates/pumpkin/src/net/java/play/keep_alive.rs b/crates/pumpkin/src/net/java/play/keep_alive.rs new file mode 100644 index 000000000..3f3c53fd9 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/keep_alive.rs @@ -0,0 +1,24 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_keep_alive(&self, player: &Player, keep_alive: SKeepAlive) { + if self.wait_for_keep_alive.load(Ordering::Relaxed) + && keep_alive.keep_alive_id == self.keep_alive_id.load() + { + let ping = self.last_keep_alive_time.load().elapsed(); + // Vanilla logic + player.ping.store( + (player.ping.load(Ordering::Relaxed) * 3 + ping.as_millis() as u32) / 4, + Ordering::Relaxed, + ); + self.wait_for_keep_alive.store(false, Ordering::Relaxed); + } else { + self.kick(pumpkin_macros::translate_cross!( + translation::java::DISCONNECT_TIMEOUT, + translation::bedrock::DISCONNECT_TIMEOUT + )) + .await; + } + } +} diff --git a/crates/pumpkin/src/net/java/play/mod.rs b/crates/pumpkin/src/net/java/play/mod.rs new file mode 100644 index 000000000..696dd0cc7 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/mod.rs @@ -0,0 +1,265 @@ +use pumpkin_protocol::bedrock::server::text::SText; +use pumpkin_util::{Hand, PermissionLvl}; +use rsa::pkcs1v15::{Signature as RsaPkcs1v15Signature, VerifyingKey}; +use rsa::signature::Verifier; +use sha1::Sha1; +use std::num::NonZeroU8; +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; +use thiserror::Error; +use tracing::{Level, debug, error, info, trace, warn}; + +use crate::block::BlockHitResult; +use crate::block::registry::BlockActionResult; +use crate::block::{self}; +use crate::entity::EntityBase; +use crate::entity::equipment_break_status; +use crate::entity::player::statistics::{CustomStatistic, StatisticCategory}; +use crate::entity::player::{ChatMode, ChatSession, MINE_BLOCK_EXHAUSTION, Player}; +use crate::error::PumpkinError; +use crate::log_at_level; +use crate::net::PlayerConfig; +use crate::net::java::JavaClient; +use crate::plugin::player::changed_main_hand::PlayerChangedMainHandEvent; +use crate::plugin::player::fish::{PlayerFishEvent, PlayerFishState}; +use crate::plugin::player::item_held::PlayerItemHeldEvent; +use crate::plugin::player::player_chat::PlayerChatEvent; +use crate::plugin::player::player_command_send::PlayerCommandSendEvent; +use crate::plugin::player::player_interact_entity_event::PlayerInteractEntityEvent; +use crate::plugin::player::player_interact_event::{InteractAction, PlayerInteractEvent}; +use crate::plugin::player::player_interact_unknown_entity_event::PlayerInteractUnknownEntityEvent; +use crate::plugin::player::player_move::PlayerMoveEvent; +use crate::plugin::player::player_toggle_flight_event::PlayerToggleFlightEvent; +use crate::plugin::player::player_toggle_sneak_event::PlayerToggleSneakEvent; + +use crate::block::entities::command_block::CommandBlockEntity; +use crate::block::entities::jigsaw_block::JigsawBlockEntity; +use crate::block::entities::sign::SignBlockEntity; +use crate::plugin::player::player_toggle_sprint_event::PlayerToggleSprintEvent; +use crate::server::{Server, seasonal_events}; +use crate::world::{BlockBreakingProgress, World, chunker}; +use pumpkin_data::block_properties::{BlockProperties, CommandBlockLikeProperties}; +use pumpkin_data::data_component::DataComponent; +use pumpkin_data::data_component_impl::{ + BlocksAttacksImpl, ConsumableImpl, DataComponentImpl, EquipmentSlot, EquippableImpl, FoodImpl, + WritableBookContentImpl, WrittenBookContentImpl, +}; +use pumpkin_data::item::Item; +use pumpkin_data::item_stack::ItemStack; +use pumpkin_data::sound::{Sound, SoundCategory}; +use pumpkin_data::{Advancement, Block, BlockDirection, translation}; +use pumpkin_inventory::InventoryError; +use pumpkin_inventory::merchant::merchant_screen_handler::MerchantScreenHandler; +use pumpkin_inventory::player::player_inventory::PlayerInventory; +use pumpkin_inventory::screen_handler::{InventoryPlayer, ScreenHandler}; +use pumpkin_protocol::bedrock::client::CMovePlayer; +use pumpkin_protocol::codec::var_int::VarInt; +use pumpkin_protocol::codec::var_ulong::VarULong; +use pumpkin_protocol::java::client::play::{ + CBlockUpdate, CCommandSuggestions, CEntityPositionSync, CHeadRot, COpenSignEditor, + CPingResponse, CPlayerInfoUpdate, CPlayerPosition, CSetCamera, CSetSelectedSlot, + CSystemChatMessage, CUpdateEntityPos, CUpdateEntityPosRot, CUpdateEntityRot, InitChat, + PlayerAction, +}; +use pumpkin_protocol::java::server::play::{ + Action, ActionType, CommandBlockMode, FLAG_ON_GROUND, SAttack, SBundleItemSelected, + SChangeGameMode, SChatCommand, SChatMessage, SChunkBatch, SClientCommand, + SClientInformationPlay, SCloseContainer, SCommandSuggestion, SConfirmTeleport, + SCookieResponse as SPCookieResponse, SEditBook, SInteract, SJigsawGenerate, SKeepAlive, + SMoveVehicle, SPaddleBoat, SPickItemFromBlock, SPickItemFromEntity, SPlaceRecipe, + SPlayPingRequest, SPlayerAbilities, SPlayerAction, SPlayerCommand, SPlayerInput, + SPlayerPosition, SPlayerPositionRotation, SPlayerRotation, SPlayerSession, + SRecipeBookChangeSettings, SRecipeBookSeenRecipe, SSeenAdvancement, SSelectTrade, + SSetCommandBlock, SSetCreativeSlot, SSetHeldItem, SSetJigsawBlock, SSetPlayerGround, + SSetTestBlock, SSwingArm, STeleportToEntity, STestInstanceBlockAction, SUpdateSign, SUseItem, + SUseItemOn, Status, +}; +use pumpkin_util::math::vector3::Vector3; +use pumpkin_util::math::{polynomial_rolling_hash, position::BlockPos, wrap_degrees}; +use pumpkin_util::{GameMode, text::TextComponent}; +use pumpkin_world::generation::structure::structures::jigsaw::JigsawJointType; +use pumpkin_world::world::BlockFlags; +use tokio::sync::Mutex; + +/// In secure chat mode, Player will be kicked if they send a chat message with a timestamp that is older than this (in ms) +/// Vanilla: 2 minutes +const CHAT_MESSAGE_MAX_AGE: i64 = 1000 * 60 * 2; + +#[derive(Debug, Error)] +pub enum BlockPlacingError { + BlockOutOfReach, + InvalidHand, + InvalidBlockFace, + BlockOutOfWorld, + InvalidGamemode, +} + +impl std::fmt::Display for BlockPlacingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +impl PumpkinError for BlockPlacingError { + fn is_kick(&self) -> bool { + match self { + Self::BlockOutOfReach | Self::BlockOutOfWorld | Self::InvalidGamemode => false, + Self::InvalidBlockFace | Self::InvalidHand => true, + } + } + + fn severity(&self) -> Level { + match self { + Self::BlockOutOfWorld | Self::InvalidGamemode => Level::TRACE, + Self::BlockOutOfReach | Self::InvalidBlockFace | Self::InvalidHand => Level::WARN, + } + } + + fn client_kick_reason(&self) -> Option { + match self { + Self::BlockOutOfReach | Self::BlockOutOfWorld | Self::InvalidGamemode => None, + Self::InvalidBlockFace => Some("Invalid block face".into()), + Self::InvalidHand => Some("Invalid hand".into()), + } + } +} + +#[derive(Debug, Error)] +pub enum ChatError { + #[error("sent an oversized message")] + OversizedMessage, + #[error("sent a message with illegal characters")] + IllegalCharacters, + #[error("sent a chat with invalid/no signature")] + UnsignedChat, + #[error("has too many unacknowledged chats queued")] + TooManyPendingChats, + #[error("sent a chat that couldn't be validated")] + ChatValidationFailed, + #[error("sent a chat with an out of order timestamp")] + OutOfOrderChat, + #[error("has an expired public key")] + ExpiredPublicKey, + #[error("attempted to initialize a session with an invalid public key")] + InvalidPublicKey, +} + +impl PumpkinError for ChatError { + fn is_kick(&self) -> bool { + true + } + + fn severity(&self) -> Level { + Level::WARN + } + + fn client_kick_reason(&self) -> Option { + match self { + Self::OversizedMessage => Some("Chat message too long".into()), + Self::IllegalCharacters => Some( + TextComponent::translate_cross( + translation::java::MULTIPLAYER_DISCONNECT_ILLEGAL_CHARACTERS, + translation::java::MULTIPLAYER_DISCONNECT_ILLEGAL_CHARACTERS, + [], + ) + .get_text(), + ), + Self::UnsignedChat => Some( + TextComponent::translate_cross( + translation::java::MULTIPLAYER_DISCONNECT_UNSIGNED_CHAT, + translation::java::MULTIPLAYER_DISCONNECT_UNSIGNED_CHAT, + [], + ) + .get_text(), + ), + Self::TooManyPendingChats => Some( + TextComponent::translate_cross( + translation::java::MULTIPLAYER_DISCONNECT_TOO_MANY_PENDING_CHATS, + translation::java::MULTIPLAYER_DISCONNECT_TOO_MANY_PENDING_CHATS, + [], + ) + .get_text(), + ), + Self::ChatValidationFailed => Some( + TextComponent::translate_cross( + translation::java::MULTIPLAYER_DISCONNECT_CHAT_VALIDATION_FAILED, + translation::java::MULTIPLAYER_DISCONNECT_CHAT_VALIDATION_FAILED, + [], + ) + .get_text(), + ), + Self::OutOfOrderChat => Some( + TextComponent::translate_cross( + translation::java::MULTIPLAYER_DISCONNECT_OUT_OF_ORDER_CHAT, + translation::java::MULTIPLAYER_DISCONNECT_OUT_OF_ORDER_CHAT, + [], + ) + .get_text(), + ), + Self::ExpiredPublicKey => Some( + TextComponent::translate_cross( + translation::java::MULTIPLAYER_DISCONNECT_EXPIRED_PUBLIC_KEY, + translation::java::MULTIPLAYER_DISCONNECT_EXPIRED_PUBLIC_KEY, + [], + ) + .get_text(), + ), + Self::InvalidPublicKey => Some( + TextComponent::translate_cross( + translation::java::MULTIPLAYER_DISCONNECT_INVALID_PUBLIC_KEY_SIGNATURE, + translation::java::MULTIPLAYER_DISCONNECT_INVALID_PUBLIC_KEY_SIGNATURE, + [], + ) + .get_text(), + ), + } + } +} + +pub mod attack; +pub mod bundle_item_selected; +pub mod change_game_mode; +pub mod chat_command; +pub mod chat_message; +pub mod chunk_batch; +pub mod client_command; +pub mod client_information; +pub mod close_container; +pub mod command_suggestion; +pub mod confirm_teleport; +pub mod cookie_response; +pub mod debug_sample_subscription; +pub mod debug_subscription_request; +pub mod edit_book; +pub mod interact; +pub mod jigsaw_generate; +pub mod keep_alive; +pub mod move_vehicle; +pub mod paddle_boat; +pub mod pick_item; +pub mod ping_request; +pub mod place_recipe; +pub mod player_abilities; +pub mod player_action; +pub mod player_command; +pub mod player_ground; +pub mod player_input; +pub mod player_loaded; +pub mod player_position; +pub mod player_rotation; +pub mod recipe_book_change_settings; +pub mod recipe_book_seen_recipe; +pub mod seen_advancement; +pub mod select_trade; +pub mod set_command_block; +pub mod set_creative_slot; +pub mod set_held_item; +pub mod set_jigsaw_block; +pub mod set_test_block; +pub mod swing_arm; +pub mod teleport_to_entity; +pub mod test_instance_block_action; +pub mod update_sign; +pub mod use_item; +pub mod use_item_on; diff --git a/crates/pumpkin/src/net/java/play/move_vehicle.rs b/crates/pumpkin/src/net/java/play/move_vehicle.rs new file mode 100644 index 000000000..0c8fc689b --- /dev/null +++ b/crates/pumpkin/src/net/java/play/move_vehicle.rs @@ -0,0 +1,18 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_move_vehicle(&self, player: &Arc, packet: SMoveVehicle) { + let entity = player.get_entity(); + let pos = Vector3::new(packet.x, packet.y, packet.z); + let vehicle = entity.vehicle.lock().await; + if let Some(vehicle) = vehicle.as_ref() { + let vehicle_entity = vehicle.get_entity(); + vehicle_entity.set_pos(pos); + vehicle_entity.set_rotation(packet.yaw, packet.pitch); + } + drop(vehicle); + entity.set_pos(pos); + chunker::update_position(player).await; + } +} diff --git a/crates/pumpkin/src/net/java/play/paddle_boat.rs b/crates/pumpkin/src/net/java/play/paddle_boat.rs new file mode 100644 index 000000000..e2a5ea705 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/paddle_boat.rs @@ -0,0 +1,13 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_paddle_boat(&self, player: &Arc, packet: SPaddleBoat) { + let vehicle = player.get_entity().vehicle.lock().await.clone(); + if let Some(vehicle) = vehicle { + vehicle + .set_paddle_state(packet.left_paddle, packet.right_paddle) + .await; + } + } +} diff --git a/crates/pumpkin/src/net/java/play/pick_item.rs b/crates/pumpkin/src/net/java/play/pick_item.rs new file mode 100644 index 000000000..d176ca969 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/pick_item.rs @@ -0,0 +1,120 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_pick_item_from_block( + &self, + player: &Arc, + pick_item: SPickItemFromBlock, + ) { + if !player.can_interact_with_block_at(&pick_item.pos, 1.0) { + return; + } + + let world = player.world(); + let block = world.get_block(&pick_item.pos); + + if block.item_id == 0 { + // Invalid block id (blocks such as tall seagrass) + return; + } + + let Some(item) = Item::from_id(block.item_id) else { + return; + }; + let stack = ItemStack::new(1, item); + + let slot_with_stack = player.inventory().get_slot_with_stack(&stack).await; + + if slot_with_stack != -1 { + if PlayerInventory::is_valid_hotbar_index(slot_with_stack as usize) { + player.inventory.set_selected_slot(slot_with_stack as u8); + } else { + player + .inventory + .swap_slot_with_hotbar(slot_with_stack as usize) + .await; + } + } else if player.gamemode.load() == GameMode::Creative { + player.inventory.swap_stack_with_hotbar(stack).await; + } + + player + .client + .enqueue_packet(&CSetSelectedSlot::new( + player.inventory.get_selected_slot() as i8 + )) + .await; + player + .player_screen_handler + .lock() + .await + .send_content_updates() + .await; + } + + pub async fn handle_pick_item_from_entity( + &self, + player: &Arc, + pick_item: SPickItemFromEntity, + ) { + use pumpkin_data::entity::{entity_from_egg, spawn_egg_ids}; + + let world = player.world(); + let Some(target) = world.get_entity_by_id(pick_item.id.0) else { + return; + }; + + let p_eye = player.get_entity().get_eye_pos(); + let t_eye = target.get_eye_pos(); + let dx = p_eye.x - t_eye.x; + let dy = p_eye.y - t_eye.y; + let dz = p_eye.z - t_eye.z; + if dx * dx + dy * dy + dz * dz > 64.0 { + return; + } + + let target_type_id = target.get_entity().entity_type.id; + let mut found_egg: Option = None; + for &egg_id in &spawn_egg_ids() { + if let Some(et) = entity_from_egg(egg_id) + && et.id == target_type_id + { + found_egg = Some(egg_id); + break; + } + } + + if let Some(item) = found_egg.and_then(Item::from_id) { + let stack = ItemStack::new(1, item); + + let slot_with_stack = player.inventory().get_slot_with_stack(&stack).await; + + if slot_with_stack != -1 { + if PlayerInventory::is_valid_hotbar_index(slot_with_stack as usize) { + player.inventory.set_selected_slot(slot_with_stack as u8); + } else { + player + .inventory + .swap_slot_with_hotbar(slot_with_stack as usize) + .await; + } + } else if player.gamemode.load() == GameMode::Creative { + player.inventory.swap_stack_with_hotbar(stack).await; + } + + player + .client + .enqueue_packet(&CSetSelectedSlot::new( + player.inventory.get_selected_slot() as i8 + )) + .await; + player + .player_screen_handler + .lock() + .await + .send_content_updates() + .await; + } + } +} diff --git a/crates/pumpkin/src/net/java/play/ping_request.rs b/crates/pumpkin/src/net/java/play/ping_request.rs new file mode 100644 index 000000000..d7293d591 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/ping_request.rs @@ -0,0 +1,9 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_play_ping_request(&self, request: SPlayPingRequest) { + self.enqueue_packet(&CPingResponse::new(request.payload)) + .await; + } +} diff --git a/crates/pumpkin/src/net/java/play/place_recipe.rs b/crates/pumpkin/src/net/java/play/place_recipe.rs new file mode 100644 index 000000000..346fba000 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/place_recipe.rs @@ -0,0 +1,213 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + #[allow(clippy::too_many_lines)] + pub async fn handle_place_recipe( + &self, + server: &Arc, + player: &Arc, + packet: SPlaceRecipe, + ) { + use crate::net::java::recipe_helper::{ + GenericIngredient, compute_biggest_craftable, take_n_ingredient, + }; + use crate::server::recipe::DynamicRecipe; + use pumpkin_data::recipes::{CraftingRecipeTypes, RECIPES_COOKING, RECIPES_CRAFTING}; + use pumpkin_data::screen::WindowType; + use pumpkin_inventory::crafting::recipe_provider::RecipeProvider; + + let target_id = packet.recipe_display_id.0 as usize; + let use_max = packet.use_max_items; + + // Count crafting display IDs. + let crafting_display_count = RECIPES_CRAFTING + .iter() + .filter(|r| { + !matches!( + r, + CraftingRecipeTypes::CraftingSpecial + | CraftingRecipeTypes::CraftingDecoratedPot { .. } + ) + }) + .count(); + let cooking_display_count = RECIPES_COOKING.len(); + let dynamic_recipes = server.recipe_manager.get_dynamic_recipes().await; + + let (grid_width, crafting_inv) = { + let screen_handler_arc = player.current_screen_handler.lock().await.clone(); + let handler = screen_handler_arc.lock().await; + let grid_width: usize = match handler.window_type() { + Some(WindowType::Crafting) => 3, + None => 2, // player inventory 2x2 + _ => return, + }; + (grid_width, handler.get_behaviour().slots[1].get_inventory()) + }; + + let grid_size = grid_width * grid_width; + let mut ingredient_slots: Vec>> = vec![None; grid_size]; + + if target_id < crafting_display_count { + // Crafting recipe + let mut counter = 0usize; + let recipe = RECIPES_CRAFTING.iter().find(|r| { + if matches!( + r, + CraftingRecipeTypes::CraftingSpecial + | CraftingRecipeTypes::CraftingDecoratedPot { .. } + ) { + return false; + } + let found = counter == target_id; + counter += 1; + found + }); + let Some(recipe) = recipe else { return }; + + match recipe { + CraftingRecipeTypes::CraftingShaped { pattern, key, .. } => { + for (row, row_str) in pattern.iter().enumerate() { + for (col, ch) in row_str.chars().enumerate() { + if ch != ' ' + && let Some(ing) = + key.iter().find_map(|(k, v)| (*k == ch).then_some(v)) + && row * grid_width + col < grid_size + { + ingredient_slots[row * grid_width + col] = + Some(GenericIngredient::Vanilla(ing)); + } + } + } + } + CraftingRecipeTypes::CraftingShapeless { ingredients, .. } => { + for (i, ing) in ingredients.iter().enumerate().take(grid_size) { + ingredient_slots[i] = Some(GenericIngredient::Vanilla(ing)); + } + } + CraftingRecipeTypes::CraftingTransmute { + input, material, .. + } => { + if grid_size >= 2 { + ingredient_slots[0] = Some(GenericIngredient::Vanilla(input)); + ingredient_slots[1] = Some(GenericIngredient::Vanilla(material)); + } + } + _ => return, + } + } else if target_id < crafting_display_count + cooking_display_count { + // TODO: cooking recipes + return; + } else { + let dynamic_id = target_id - crafting_display_count - cooking_display_count; + let Some(DynamicRecipe::Crafting(crafting)) = dynamic_recipes.get(dynamic_id) else { + return; + }; + + match crafting { + pumpkin_protocol::codec::recipe::OwnedCraftingRecipe::Shaped { + pattern, + key, + .. + } => { + for (row, row_str) in pattern.iter().enumerate() { + for (col, ch) in row_str.chars().enumerate() { + if ch != ' ' + && let Some((_, ing)) = key.iter().find(|(k, _)| *k == ch) + && row * grid_width + col < grid_size + { + ingredient_slots[row * grid_width + col] = + Some(GenericIngredient::Dynamic(ing)); + } + } + } + } + + pumpkin_protocol::codec::recipe::OwnedCraftingRecipe::Shapeless { + ingredients, + .. + } => { + for (i, ing) in ingredients.iter().enumerate().take(grid_size) { + ingredient_slots[i] = Some(GenericIngredient::Dynamic(ing)); + } + } + } + } + + // Check if this exact recipe is already placed (determines stacking vs fresh fill). + let recipe_matches = { + let mut ok = true; + for (idx, ing) in ingredient_slots.iter().enumerate() { + let stack = crafting_inv.get_stack(idx).await; + match ing { + None => { + if !stack.is_empty() { + ok = false; + break; + } + } + Some(ingredient) => { + if stack.is_empty() || !ingredient.match_item(stack.item) { + ok = false; + break; + } + } + } + } + ok + }; + + // Read minimum count from occupied slots before clearing (needed for stacking). + let current_min = if recipe_matches && !use_max { + let mut min = u8::MAX; + for (idx, ing) in ingredient_slots.iter().enumerate() { + if ing.is_some() { + let stack = crafting_inv.get_stack(idx).await; + if !stack.is_empty() { + min = min.min(stack.item_count); + } + } + } + if min == u8::MAX { 0 } else { min } + } else { + 0 + }; + + // Always clear the grid first, returning items to inventory. + for i in 0..grid_size { + let stack = crafting_inv.remove_stack(i).await; + if !stack.is_empty() { + player.inventory.offer(stack, false, player.as_ref()).await; + } + } + + // Determine how many of each ingredient to place per slot. + let active_ingredients: Vec> = + ingredient_slots.iter().flatten().copied().collect(); + let amount_to_craft = if use_max { + compute_biggest_craftable(&active_ingredients, &player.inventory).await + } else if recipe_matches { + current_min.saturating_add(1) + } else { + 1 + }; + + if amount_to_craft == 0 { + let screen_handler_arc = player.current_screen_handler.lock().await.clone(); + screen_handler_arc.lock().await.send_content_updates().await; + return; + } + + // Fill each grid slot with exactly `amount_to_craft` matching items. + for (idx, ing) in ingredient_slots.iter().enumerate() { + let Some(ingredient) = ing else { continue }; + let taken = take_n_ingredient(&player.inventory, ingredient, amount_to_craft).await; + if !taken.is_empty() { + crafting_inv.set_stack(idx, taken).await; + } + } + + let screen_handler_arc = player.current_screen_handler.lock().await.clone(); + screen_handler_arc.lock().await.send_content_updates().await; + } +} diff --git a/crates/pumpkin/src/net/java/play/player_abilities.rs b/crates/pumpkin/src/net/java/play/player_abilities.rs new file mode 100644 index 000000000..662108f88 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/player_abilities.rs @@ -0,0 +1,34 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_player_abilities( + &self, + player: &Arc, + player_abilities: SPlayerAbilities, + server: &Arc, + ) { + let (flying, allow_flying) = { + let abilities = player.abilities.lock().await; + (abilities.flying, abilities.allow_flying) + }; + + // Set the flying ability + let new_flying = player_abilities.flags & 0x02 != 0 && allow_flying; + if flying != new_flying { + send_cancellable! {{ + server; + PlayerToggleFlightEvent::new(player.clone(), new_flying); + 'after: { + if event.is_flying { + player.living_entity.fall_distance.store(0.0); + } + player.abilities.lock().await.flying = event.is_flying; + } + 'cancelled: { + player.send_abilities_update().await; + } + }} + } + } +} diff --git a/crates/pumpkin/src/net/java/play/player_action.rs b/crates/pumpkin/src/net/java/play/player_action.rs new file mode 100644 index 000000000..18d96288c --- /dev/null +++ b/crates/pumpkin/src/net/java/play/player_action.rs @@ -0,0 +1,286 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + #[expect(clippy::too_many_lines)] + pub async fn handle_player_action( + &self, + player: &Arc, + player_action: SPlayerAction, + server: &Server, + ) { + if !player.has_client_loaded() { + return; + } + player.update_last_action_time(); + match Status::try_from(player_action.status.0) { + Ok(status) => match status { + Status::StartedDigging => { + if !player.can_interact_with_block_at(&player_action.position, 1.0) { + warn!( + "Player {0} tried to interact with block out of reach at {1}", + player.gameprofile.name, player_action.position + ); + self.update_sequence(player, player_action.sequence.0); + return; + } + let position = player_action.position; + let entity = &player.get_entity(); + let world = entity.world.load_full(); + let (block, state) = world.get_block_and_state(&position); + + if let Some(server_arc) = world.server.upgrade() { + let mut event = + crate::plugin::api::events::block::block_damage::BlockDamageEvent::new( + player.clone(), + block, + position, + false, + ); + server_arc + .plugin_manager + .fire(&server_arc, &mut event) + .await; + if event.cancelled { + self.update_sequence(player, player_action.sequence.0); + return; + } + } + + if block == &pumpkin_data::Block::NOTE_BLOCK { + let props = + pumpkin_data::block_properties::NoteBlockLikeProperties::from_state_id( + state.id, block, + ); + crate::block::blocks::note::NoteBlock::play_note(&props, &world, &position) + .await; + player + .increment_stat( + StatisticCategory::Custom, + CustomStatistic::PlayNoteblock as i32, + 1, + ) + .await; + } + + let inventory = player.inventory(); + let held = inventory.held_item().await; + if !server.item_registry.can_mine(held.item, player) { + self.enqueue_packet(&CBlockUpdate::new( + position, + VarInt(i32::from(state.id.as_u16())), + )) + .await; + self.update_sequence(player, player_action.sequence.0); + return; + } + + // TODO: do validation + // TODO: Config + if player.gamemode.load() == GameMode::Creative { + // Block break & play sound + let new_state = world + .break_block( + &position, + Some(player.clone()), + BlockFlags::NOTIFY_NEIGHBORS | BlockFlags::SKIP_DROPS, + ) + .await; + if new_state.is_some() { + server + .block_registry + .broken(&world, block, player, &position, server, state) + .await; + } + self.sync_block_state_to_client(&world, position).await; + self.update_sequence(player, player_action.sequence.0); + return; + } + player.start_mining_time.store( + player.tick_counter.load(Ordering::Relaxed), + Ordering::Relaxed, + ); + if !state.is_air() { + let speed = block::calc_block_breaking(player, state, block).await; + // Instant break + if speed >= 1.0 { + let broken_state = world.get_block_state(&position); + let can_harvest = player.can_harvest(broken_state, block).await; + let new_state = world + .break_block( + &position, + Some(player.clone()), + BlockFlags::NOTIFY_NEIGHBORS, + ) + .await; + if new_state.is_some() { + server + .block_registry + .broken(&world, block, player, &position, server, broken_state) + .await; + player.apply_tool_damage_for_block_break(broken_state).await; + if can_harvest { + player.add_exhaustion(MINE_BLOCK_EXHAUSTION).await; + } + let item_id = player.inventory().held_item().await.item.id; + player + .increment_stat(StatisticCategory::Used, item_id as i32, 1) + .await; + player + .increment_stat( + StatisticCategory::Mined, + broken_state.id.as_u16() as i32, + 1, + ) + .await; + } + self.sync_block_state_to_client(&world, position).await; + } else { + player.mining.store(true, Ordering::Relaxed); + *player.mining_pos.lock().await = position; + let progress = (speed * 10.0) as i32; + player + .current_block_breaking_speed + .store(speed.to_bits(), Ordering::Relaxed); + world + .set_block_breaking( + entity, + position, + BlockBreakingProgress::Start { + stage: progress, + speed, + }, + ) + .await; + player + .current_block_destroy_stage + .store(progress, Ordering::Relaxed); + } + } + self.update_sequence(player, player_action.sequence.0); + } + Status::CancelledDigging => { + if !player.can_interact_with_block_at(&player_action.position, 1.0) { + warn!( + "Player {0} tried to interact with block out of reach at {1}", + player.gameprofile.name, player_action.position + ); + self.update_sequence(player, player_action.sequence.0); + return; + } + player.mining.store(false, Ordering::Relaxed); + let entity = &player.get_entity(); + entity + .world + .load() + .set_block_breaking( + entity, + player_action.position, + BlockBreakingProgress::Stop, + ) + .await; + self.update_sequence(player, player_action.sequence.0); + } + Status::FinishedDigging => { + // TODO: do validation + let location = player_action.position; + if !player.can_interact_with_block_at(&location, 1.0) { + warn!( + "Player {0} tried to interact with block out of reach at {1}", + player.gameprofile.name, player_action.position + ); + self.update_sequence(player, player_action.sequence.0); + return; + } + + // Block break & play sound + let entity = &player.get_entity(); + let world = entity.world.load_full(); + + player.mining.store(false, Ordering::Relaxed); + world + .set_block_breaking(entity, location, BlockBreakingProgress::Stop) + .await; + + let (block, state) = world.get_block_and_state(&location); + let block_drop = player.gamemode.load() != GameMode::Creative + && player.can_harvest(state, block).await; + + let new_state = world + .break_block( + &location, + Some(player.clone()), + if block_drop { + BlockFlags::NOTIFY_NEIGHBORS + } else { + BlockFlags::SKIP_DROPS | BlockFlags::NOTIFY_NEIGHBORS + }, + ) + .await; + if new_state.is_some() { + server + .block_registry + .broken(&world, block, player, &location, server, state) + .await; + + player.apply_tool_damage_for_block_break(state).await; + if block_drop { + player.add_exhaustion(MINE_BLOCK_EXHAUSTION).await; + } + let item_id = player.inventory().held_item().await.item.id; + player + .increment_stat(StatisticCategory::Used, item_id as i32, 1) + .await; + player + .increment_stat(StatisticCategory::Mined, state.id.as_u16() as i32, 1) + .await; + } + + self.sync_block_state_to_client(&world, location).await; + + self.update_sequence(player, player_action.sequence.0); + } + Status::DropItem => { + player.drop_held_item(false).await; + } + Status::DropItemStack => { + player.drop_held_item(true).await; + } + Status::ReleaseItemInUse => { + let item_in_use = player.living_entity.item_in_use.lock().await.clone(); + if let Some(stack) = item_in_use { + server.item_registry.on_stopped_using(&stack, player).await; + } + + player.living_entity.clear_active_hand().await; + } + Status::SwapItem => { + player.swap_item().await; + } + Status::SpearJab => { + debug!("todo"); + } + }, + Err(_) => self.kick(TextComponent::text("Invalid status")).await, + } + } + + pub fn update_sequence(&self, _player: &Player, sequence: i32) { + if sequence < 0 { + error!("Expected packet sequence >= 0"); + } + self.packet_sequence.store( + self.packet_sequence.load(Ordering::Relaxed).max(sequence), + Ordering::Relaxed, + ); + } + + async fn sync_block_state_to_client(&self, world: &World, position: BlockPos) { + let synced_state_id = world.get_block_state_id(&position); + self.send_packet_now(&CBlockUpdate::new( + position, + VarInt(i32::from(synced_state_id.as_u16())), + )) + .await; + } +} diff --git a/crates/pumpkin/src/net/java/play/player_command.rs b/crates/pumpkin/src/net/java/play/player_command.rs new file mode 100644 index 000000000..0adc87f66 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/player_command.rs @@ -0,0 +1,67 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_player_command( + &self, + player: &Arc, + command: SPlayerCommand, + server: &Arc, + ) { + if command.entity_id != player.entity_id().into() { + return; + } + if !player.has_client_loaded() { + return; + } + player.update_last_action_time(); + + let entity = &player.get_entity(); + match command.action { + Action::StartSprinting => { + if !entity.is_sprinting() { + send_cancellable! {{ + server; + PlayerToggleSprintEvent::new(player.clone(), true); + 'after: { + player.get_entity().set_sprinting(event.is_sprinting).await; + } + }} + } + } + Action::StopSprinting => { + if entity.is_sprinting() { + send_cancellable! {{ + server; + PlayerToggleSprintEvent::new(player.clone(), false); + 'after: { + player.get_entity().set_sprinting(event.is_sprinting).await; + } + }} + } + } + Action::LeaveBed => player.wake_up().await, + + Action::StartHorseJump | Action::StopHorseJump | Action::OpenVehicleInventory => { + debug!("todo"); + } + Action::StartFlyingElytra => { + let fall_flying = entity.check_fall_flying(); + if entity.is_fall_flying() != fall_flying { + entity.set_fall_flying(fall_flying).await; + } + } + // <= 1.21.5 + Action::StartSneaking | Action::StopSneaking => { + self.handle_player_input( + player, + SPlayerInput { + input: SPlayerInput::SNEAK, + }, + server, + ) + .await; + } + } + } +} diff --git a/crates/pumpkin/src/net/java/play/player_ground.rs b/crates/pumpkin/src/net/java/play/player_ground.rs new file mode 100644 index 000000000..89db88b8f --- /dev/null +++ b/crates/pumpkin/src/net/java/play/player_ground.rs @@ -0,0 +1,12 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub fn handle_player_ground(&self, player: &Player, ground: &SSetPlayerGround) { + player + .living_entity + .entity + .on_ground + .store(ground.on_ground, Ordering::Relaxed); + } +} diff --git a/crates/pumpkin/src/net/java/play/player_input.rs b/crates/pumpkin/src/net/java/play/player_input.rs new file mode 100644 index 000000000..0f3a6cedd --- /dev/null +++ b/crates/pumpkin/src/net/java/play/player_input.rs @@ -0,0 +1,52 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_player_input( + &self, + player: &Arc, + input: SPlayerInput, + server: &Arc, + ) { + player.last_input.store(input.input, Ordering::Relaxed); + + let sneak = input.input & SPlayerInput::SNEAK != 0; + if sneak + && player.gamemode.load() == GameMode::Spectator + && player.camera_target_id.load().is_some() + { + player.camera_target_id.store(None); + player + .client + .send_packet_now(&CSetCamera::new(player.entity_id().into())) + .await; + } + + if player.get_entity().is_sneaking() != sneak { + send_cancellable! {{ + server; + PlayerToggleSneakEvent::new(player.clone(), sneak); + 'after: { + player.get_entity().set_sneaking(event.is_sneaking).await; + if event.is_sneaking { + let vehicle = player.get_entity().vehicle.lock().await.clone(); + if let Some(vehicle) = vehicle { + vehicle + .get_entity() + .remove_passenger(player.entity_id()) + .await; + } + } + } + }} + } else if sneak { + let vehicle = player.get_entity().vehicle.lock().await.clone(); + if let Some(vehicle) = vehicle { + vehicle + .get_entity() + .remove_passenger(player.entity_id()) + .await; + } + } + } +} diff --git a/crates/pumpkin/src/net/java/play/player_loaded.rs b/crates/pumpkin/src/net/java/play/player_loaded.rs new file mode 100644 index 000000000..d9b2b0aa7 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/player_loaded.rs @@ -0,0 +1,8 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub fn handle_player_loaded(player: &Player) { + player.set_client_loaded(true); + } +} diff --git a/crates/pumpkin/src/net/java/play/player_position.rs b/crates/pumpkin/src/net/java/play/player_position.rs new file mode 100644 index 000000000..5fcac7063 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/player_position.rs @@ -0,0 +1,347 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + const fn clamp_horizontal(pos: f64) -> f64 { + pos.clamp(-3.0E7, 3.0E7) + } + + const fn clamp_vertical(pos: f64) -> f64 { + pos.clamp(-2.0E7, 2.0E7) + } + + /// Returns whether syncing the position was needed + fn sync_position( + player: &Arc, + world: &World, + pos: Vector3, + last_pos: Vector3, + yaw: f32, + pitch: f32, + on_ground: bool, + ) -> bool { + let delta = Vector3::new(pos.x - last_pos.x, pos.y - last_pos.y, pos.z - last_pos.z); + let entity_id = player.entity_id(); + + // Teleport when more than 8 blocks (-8..=7.999755859375) + if delta.length_squared() < 64.0 { + return false; + } + // Sync position with all other players. + world.broadcast_packet_except( + &[player.gameprofile.id], + &CEntityPositionSync::new( + entity_id.into(), + pos, + Vector3::new(0.0, 0.0, 0.0), + yaw, + pitch, + on_ground, + ), + ); + true + } + + #[expect(clippy::too_many_lines)] + pub async fn handle_position( + &self, + player: &Arc, + server: &Arc, + packet: SPlayerPosition, + ) { + if !player.has_client_loaded() { + return; + } + if player.get_entity().has_vehicle().await { + return; + } + // Ignore movement packets while awaiting a teleport confirmation (vanilla behavior) + if player.awaiting_teleport.lock().await.is_some() { + return; + } + // y = feet Y + let position = packet.position; + if position.x.is_nan() || position.y.is_nan() || position.z.is_nan() { + self.kick(TextComponent::translate_cross( + translation::java::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT, + translation::java::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT, + [], + )) + .await; + return; + } + let position = Vector3::new( + Self::clamp_horizontal(position.x), + Self::clamp_vertical(position.y), + Self::clamp_horizontal(position.z), + ); + + send_cancellable! {{ + server; + PlayerMoveEvent { + player: player.clone(), + from: player.get_entity().pos.load(), + to: position, + cancelled: false, + }; + + 'after: { + let pos = event.to; + let entity = &player.get_entity(); + let last_pos = entity.pos.load(); + player.get_entity().set_pos(pos); + + let distance = last_pos.squared_distance_to_vec(&pos).sqrt(); + let cm = (distance * 100.0) as i32; + if cm > 0 { + let stat = player.get_movement_statistic().await; + player + .increment_stat(StatisticCategory::Custom, stat as i32, cm) + .await; + } + + let height_difference = pos.y - last_pos.y; + if entity.on_ground.load(Ordering::Relaxed) && packet.collision & FLAG_ON_GROUND == 0 && height_difference > 0.0 { + player.jump().await; + } + + let new_on_ground = packet.collision & FLAG_ON_GROUND != 0; + entity.on_ground.store(new_on_ground, Ordering::Relaxed); + if new_on_ground && entity.is_fall_flying() { + entity.set_fall_flying(false).await; + } + let world = &player.world(); + + // TODO: Warn when player moves to quickly + if !Self::sync_position(player, world, pos, last_pos, entity.yaw.load(), entity.pitch.load(), packet.collision & FLAG_ON_GROUND != 0) { + // Send the new position to all other players. + world.broadcast_packet_except_editioned_sync( + &[player.gameprofile.id], + &CUpdateEntityPos::new( + player.entity_id().into(), + Vector3::new( + pos.x.mul_add(4096.0, -(last_pos.x * 4096.0)) as i16, + pos.y.mul_add(4096.0, -(last_pos.y * 4096.0)) as i16, + pos.z.mul_add(4096.0, -(last_pos.z * 4096.0)) as i16, + ), + packet.collision & FLAG_ON_GROUND != 0, + ), + &CMovePlayer::new( + VarULong(player.entity_id() as u64), + Vector3::new(pos.x as f32, pos.y as f32 + player.get_entity().entity_type.eye_height, pos.z as f32), + entity.pitch.load(), + entity.yaw.load(), + entity.yaw.load(), + CMovePlayer::MODE_NORMAL, + (packet.collision & FLAG_ON_GROUND) != 0, + VarULong(0), + 0, + 0, + VarULong(0), + ), + ); + } + + // Only process fall damage if player is alive + if !player.abilities.lock().await.flying + && player.living_entity.health.load() > 0.0 + && !player.living_entity.dead.load(Ordering::Relaxed) + { + player.living_entity + .fall( + player.clone(), + height_difference, + packet.collision & FLAG_ON_GROUND != 0, + player.gamemode.load() == GameMode::Creative, + ) + .await; + } + chunker::update_position(player).await; + let delta = Vector3::new( + pos.x - last_pos.x, + pos.y - last_pos.y, + pos.z - last_pos.z, + ); + // Only update idle timeout if there's actual movement (vanilla threshold) + if delta.length_squared() > 1.0E-5 { + player.update_last_action_time(); + } + player.progress_motion(delta).await; + } + + 'cancelled: { + self.force_tp(player, player.get_entity().pos.load()).await; + } + }} + } + + #[expect(clippy::too_many_lines)] + pub async fn handle_position_rotation( + &self, + player: &Arc, + server: &Arc, + packet: SPlayerPositionRotation, + ) { + if !player.has_client_loaded() { + return; + } + if player.get_entity().has_vehicle().await { + return; + } + // Ignore movement packets while awaiting a teleport confirmation (vanilla behavior) + if player.awaiting_teleport.lock().await.is_some() { + return; + } + // y = feet Y + let position = packet.position; + if !position.x.is_finite() + || !position.y.is_finite() + || !position.z.is_finite() + || !packet.yaw.is_finite() + || !packet.pitch.is_finite() + { + self.kick(TextComponent::translate_cross( + translation::java::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT, + translation::java::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT, + [], + )) + .await; + return; + } + + let position = Vector3::new( + Self::clamp_horizontal(position.x), + Self::clamp_vertical(position.y), + Self::clamp_horizontal(position.z), + ); + + send_cancellable! {{ + server; + PlayerMoveEvent::new( + player.clone(), + player.get_entity().pos.load(), + position, + ); + + 'after: { + let pos = event.to; + let entity = &player.get_entity(); + let last_pos = entity.pos.load(); + player.get_entity().set_pos(pos); + + let distance = last_pos.squared_distance_to_vec(&pos).sqrt(); + let cm = (distance * 100.0) as i32; + if cm > 0 { + let stat = player.get_movement_statistic().await; + player + .increment_stat(StatisticCategory::Custom, stat as i32, cm) + .await; + } + + let height_difference = pos.y - last_pos.y; + if entity.on_ground.load(Ordering::Relaxed) + && (packet.collision & FLAG_ON_GROUND) != 0 + && height_difference > 0.0 + { + player.jump().await; + } + entity + .on_ground + .store((packet.collision & FLAG_ON_GROUND) != 0, Ordering::Relaxed); + + entity.set_rotation(wrap_degrees(packet.yaw) % 360.0, wrap_degrees(packet.pitch)); + + let entity_id = entity.entity_id; + + let yaw = (entity.yaw.load() * 256.0 / 360.0).rem_euclid(256.0); + let pitch = (entity.pitch.load() * 256.0 / 360.0).rem_euclid(256.0); + // let head_yaw = (entity.head_yaw * 256.0 / 360.0).floor(); + let world = entity.world.load_full(); + + // TODO: Warn when player moves to quickly + if !Self:: + sync_position(player, &world, pos, last_pos, yaw, pitch, (packet.collision & FLAG_ON_GROUND) != 0) + { + // Send the new position to all other players. + world.broadcast_packet_except_editioned_sync( + &[player.gameprofile.id], + &CUpdateEntityPosRot::new( + entity_id.into(), + Vector3::new( + pos.x.mul_add(4096.0, -(last_pos.x * 4096.0)) as i16, + pos.y.mul_add(4096.0, -(last_pos.y * 4096.0)) as i16, + pos.z.mul_add(4096.0, -(last_pos.z * 4096.0)) as i16, + ), + yaw as u8, + pitch as u8, + (packet.collision & FLAG_ON_GROUND) != 0, + ), + &CMovePlayer::new( + VarULong(entity_id as u64), + Vector3::new(pos.x as f32, pos.y as f32 + player.get_entity().entity_type.eye_height, pos.z as f32), + entity.pitch.load(), + entity.yaw.load(), + entity.yaw.load(), + CMovePlayer::MODE_NORMAL, + (packet.collision & FLAG_ON_GROUND) != 0, + VarULong(0), + 0, + 0, + VarULong(0), + ), + ); + } + + world + .broadcast_packet_except( + &[player.gameprofile.id], + &CHeadRot::new(entity_id.into(), yaw as u8), + ) + ; + // Only process fall damage if player is alive + if !player.abilities.lock().await.flying + && player.living_entity.health.load() > 0.0 + && !player.living_entity.dead.load(Ordering::Relaxed) + { + player.living_entity + .fall( + player.clone(), + height_difference, + (packet.collision & FLAG_ON_GROUND) != 0, + player.gamemode.load() == GameMode::Creative, + ) + .await; + } + chunker::update_position(player).await; + let delta = Vector3::new( + pos.x - last_pos.x, + pos.y - last_pos.y, + pos.z - last_pos.z, + ); + // Only update idle timeout if there's actual movement (vanilla threshold) + if delta.length_squared() > 1.0E-5 { + player.update_last_action_time(); + } + player.progress_motion(delta).await; + } + + 'cancelled: { + self.force_tp(player, position).await; + } + }} + } + + pub async fn force_tp(&self, player: &Arc, position: Vector3) { + let teleport_id = player.teleport_id_count.fetch_add(1, Ordering::Relaxed) + 1; + *player.awaiting_teleport.lock().await = Some((teleport_id.into(), position)); + self.enqueue_packet(&CPlayerPosition::new( + teleport_id.into(), + player.get_entity().pos.load(), + Vector3::new(0.0, 0.0, 0.0), + player.get_entity().yaw.load(), + player.get_entity().pitch.load(), + Vec::new(), + )) + .await; + } +} diff --git a/crates/pumpkin/src/net/java/play/player_rotation.rs b/crates/pumpkin/src/net/java/play/player_rotation.rs new file mode 100644 index 000000000..342e42020 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/player_rotation.rs @@ -0,0 +1,63 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_rotation(&self, player: &Player, rotation: SPlayerRotation) { + if !player.has_client_loaded() { + return; + } + if !rotation.yaw.is_finite() || !rotation.pitch.is_finite() { + self.kick(TextComponent::translate_cross( + translation::java::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT, + translation::java::MULTIPLAYER_DISCONNECT_INVALID_PLAYER_MOVEMENT, + [], + )) + .await; + return; + } + let entity = &player.get_entity(); + entity.on_ground.store(rotation.ground, Ordering::Relaxed); + entity.set_rotation( + wrap_degrees(rotation.yaw) % 360.0, + wrap_degrees(rotation.pitch), + ); + // Send the new position to all other players. + let entity_id = entity.entity_id; + let yaw = (entity.yaw.load() * 256.0 / 360.0).rem_euclid(256.0); + let pitch = (entity.pitch.load() * 256.0 / 360.0).rem_euclid(256.0); + // let head_yaw = modulus(entity.head_yaw * 256.0 / 360.0, 256.0); + + let world = entity.world.load_full(); + let je_packet = + CUpdateEntityRot::new(entity_id.into(), yaw as u8, pitch as u8, rotation.ground); + + let pos = entity.pos.load(); + + let be_packet = CMovePlayer::new( + VarULong(entity_id as u64), + Vector3::new( + pos.x as f32, + pos.y as f32 + player.get_entity().entity_type.eye_height, + pos.z as f32, + ), + entity.pitch.load(), + entity.yaw.load(), + entity.yaw.load(), + CMovePlayer::MODE_ROTATION, + rotation.ground, + VarULong(0), + 0, + 0, + VarULong(0), + ); + + world.broadcast_packet_except_editioned_sync( + &[player.gameprofile.id], + &je_packet, + &be_packet, + ); + + let je_packet = CHeadRot::new(entity_id.into(), yaw as u8); + world.broadcast_packet_except(&[player.gameprofile.id], &je_packet); + } +} diff --git a/crates/pumpkin/src/net/java/play/recipe_book_change_settings.rs b/crates/pumpkin/src/net/java/play/recipe_book_change_settings.rs new file mode 100644 index 000000000..457bae980 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/recipe_book_change_settings.rs @@ -0,0 +1,13 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + #[allow(clippy::unused_async)] + pub async fn handle_recipe_book_change_settings( + &self, + _player: &Arc, + _packet: SRecipeBookChangeSettings, + ) { + // Client is updating its recipe book filter/open state; no server action needed. + } +} diff --git a/crates/pumpkin/src/net/java/play/recipe_book_seen_recipe.rs b/crates/pumpkin/src/net/java/play/recipe_book_seen_recipe.rs new file mode 100644 index 000000000..3e4677c9b --- /dev/null +++ b/crates/pumpkin/src/net/java/play/recipe_book_seen_recipe.rs @@ -0,0 +1,13 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + #[allow(clippy::unused_async)] + pub async fn handle_recipe_book_seen_recipe( + &self, + _player: &Arc, + _packet: SRecipeBookSeenRecipe, + ) { + // Client acknowledged a recipe display; no server action needed. + } +} diff --git a/crates/pumpkin/src/net/java/play/seen_advancement.rs b/crates/pumpkin/src/net/java/play/seen_advancement.rs new file mode 100644 index 000000000..b186c2d0d --- /dev/null +++ b/crates/pumpkin/src/net/java/play/seen_advancement.rs @@ -0,0 +1,18 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_seen_advancement(&self, player: &Arc, packet: SSeenAdvancement) { + if let SSeenAdvancement::OpenTab(tab) = packet { + let advancement = Advancement::from_minecraft_name(&tab.to_string()); + if advancement.is_some() { + player + .advancements + .lock() + .await + .set_selected_tab(advancement) + .await; + } + } + } +} diff --git a/crates/pumpkin/src/net/java/play/select_trade.rs b/crates/pumpkin/src/net/java/play/select_trade.rs new file mode 100644 index 000000000..798ca40d7 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/select_trade.rs @@ -0,0 +1,17 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_select_trade(&self, player: &Arc, packet: SSelectTrade) { + let screen_handler = player.current_screen_handler.lock().await; + let mut screen_handler = screen_handler.lock().await; + if let Some(merchant) = screen_handler + .as_any_mut() + .downcast_mut::() + { + merchant + .set_selected_offer(packet.selected_slot.0 as usize) + .await; + } + } +} diff --git a/crates/pumpkin/src/net/java/play/set_command_block.rs b/crates/pumpkin/src/net/java/play/set_command_block.rs new file mode 100644 index 000000000..0a26b3995 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/set_command_block.rs @@ -0,0 +1,97 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_set_command_block( + &self, + player: &Arc, + command: SSetCommandBlock<'_>, + ) { + if !player.is_creative() { + return; + } + if player.permission_lvl.load() < PermissionLvl::Two { + return; + } + let pos = command.pos; + let block_entity = player.world().get_block_entity(&pos); + if let Some(block_entity) = block_entity { + if block_entity.resource_location() != CommandBlockEntity::ID { + warn!("Client tried to change Command block but not Command block entity found"); + return; + } + + let Ok(command_block_mode) = CommandBlockMode::try_from(command.mode) else { + self.kick(TextComponent::text("Invalid Command block mode")) + .await; + return; + }; + + let block = player.world().get_block(&pos); + let old_state_id = player.world().get_block_state_id(&pos); + let mut props = CommandBlockLikeProperties::from_state_id(old_state_id, block); + + let block_type = match command_block_mode { + CommandBlockMode::Chain => Block::CHAIN_COMMAND_BLOCK, + CommandBlockMode::Repeating => Block::REPEATING_COMMAND_BLOCK, + CommandBlockMode::Impulse => Block::COMMAND_BLOCK, + }; + + let Some(old_command_block) = + block_entity.as_any().downcast_ref::() + else { + return; + }; + + props.conditional = command.flags & 0x2 != 0; + + let new_state_id = props.to_state_id(&block_type); + player + .world() + .set_block_state( + &command.pos, + new_state_id, + BlockFlags::SKIP_BLOCK_ADDED_CALLBACK, + ) + .await; + + let mut cmd = command.command; + if cmd.starts_with('/') { + cmd = &cmd[1..]; + } + + let command_block = CommandBlockEntity { + position: pos, + powered: old_command_block.powered.load(Ordering::SeqCst).into(), + condition_met: old_command_block + .condition_met + .load(Ordering::SeqCst) + .into(), + auto: (command.flags & 0x4 != 0).into(), + dirty: old_command_block.dirty.load(Ordering::SeqCst).into(), + command: Mutex::new(cmd.to_string()), + last_output: old_command_block.last_output.lock().await.clone().into(), + track_output: (command.flags & 0x1 != 0).into(), + success_count: AtomicU32::new(0), + }; + player.world().add_block_entity(Arc::new(command_block)); + + player + .send_system_message(&TextComponent::text(format!( + "Command set: {}", + command.command + ))) + .await; + + // The 0x4 flag means always active + if command.flags & 0x4 != 0 && block_type != Block::CHAIN_COMMAND_BLOCK { + player.world().schedule_block_tick( + &block_type, + pos, + 1, + pumpkin_world::tick::TickPriority::Normal, + ); + } + } + } +} diff --git a/crates/pumpkin/src/net/java/play/set_creative_slot.rs b/crates/pumpkin/src/net/java/play/set_creative_slot.rs new file mode 100644 index 000000000..8c6e16de9 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/set_creative_slot.rs @@ -0,0 +1,68 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_set_creative_slot( + &self, + player: &Player, + packet: SSetCreativeSlot, + ) -> Result<(), InventoryError> { + if player.gamemode.load() != GameMode::Creative { + return Err(InventoryError::PermissionError); + } + let is_negative = packet.slot < 0; + let valid_slot = packet.slot >= 1 && packet.slot as usize <= 45; + let item_stack = packet + .clicked_item + .to_stack_for_version(&self.version.load()); + let is_legal = + item_stack.is_empty() || item_stack.item_count <= item_stack.get_max_stack_size(); + + if valid_slot && is_legal { + let mut player_screen_handler = player.player_screen_handler.lock().await; + + let is_armor_equipped = player_screen_handler + .get_slot(packet.slot as usize) + .get_stack() + .await + .are_equal(&item_stack); + if !is_armor_equipped { + if (5..9).contains(&packet.slot) { + player + .enqueue_equipment_change( + &match packet.slot { + 5 => EquipmentSlot::HEAD, + 6 => EquipmentSlot::CHEST, + 7 => EquipmentSlot::LEGS, + 8 => EquipmentSlot::FEET, + _ => { + tracing::error!("Invalid armor slot: {}", packet.slot); + EquipmentSlot::HEAD + } + }, + &item_stack, + ) + .await; + } else if (36..45).contains(&packet.slot) { + let slot = packet.slot - 36; + if player.inventory().get_selected_slot() == slot as u8 { + let equipment = &[(EquipmentSlot::MAIN_HAND, item_stack.clone())]; + player.living_entity.send_equipment_changes(equipment); + } + } + } + + player_screen_handler + .get_slot(packet.slot as usize) + .set_stack(item_stack.clone()) + .await; + player_screen_handler.set_received_stack(packet.slot as usize, item_stack); + player_screen_handler.send_content_updates().await; + drop(player_screen_handler); + } else if is_negative && is_legal { + // Item drop + player.drop_item(item_stack).await; + } + Ok(()) + } +} diff --git a/crates/pumpkin/src/net/java/play/set_held_item.rs b/crates/pumpkin/src/net/java/play/set_held_item.rs new file mode 100644 index 000000000..9fe5ece70 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/set_held_item.rs @@ -0,0 +1,39 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_set_held_item( + &self, + server: &Arc, + + player: &Player, + held: SSetHeldItem, + ) { + player.update_last_action_time(); + let slot = held.slot; + if !(0..=8).contains(&slot) { + self.kick(TextComponent::text("Invalid held slot")).await; + return; + } + let slot = slot as u8; + let previous_slot = player.inventory.get_selected_slot(); + let Some(player_arc) = player.world().get_player_by_uuid(player.gameprofile.id) else { + return; + }; + let mut event = PlayerItemHeldEvent::new(player_arc, previous_slot, slot); + server.plugin_manager.fire(server, &mut event).await; + if event.cancelled { + player + .client + .enqueue_packet(&CSetSelectedSlot::new(previous_slot as i8)) + .await; + return; + } + + let inv = player.inventory(); + inv.set_selected_slot(slot); + let stack = inv.held_item().await; + let equipment = &[(EquipmentSlot::MAIN_HAND, stack)]; + player.living_entity.send_equipment_changes(equipment); + } +} diff --git a/crates/pumpkin/src/net/java/play/set_jigsaw_block.rs b/crates/pumpkin/src/net/java/play/set_jigsaw_block.rs new file mode 100644 index 000000000..73b8d2408 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/set_jigsaw_block.rs @@ -0,0 +1,40 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_set_jigsaw_block(&self, player: &Arc, jigsaw: SSetJigsawBlock<'_>) { + if !player.is_creative() { + return; + } + if player.permission_lvl.load() < PermissionLvl::Two { + return; + } + let pos = jigsaw.pos; + let block_entity = player.world().get_block_entity(&pos); + if let Some(block_entity) = block_entity { + if block_entity.resource_location() != JigsawBlockEntity::ID { + warn!("Client tried to change Jigsaw block but not Jigsaw block entity found"); + return; + } + + let Some(jigsaw_block) = block_entity.as_any().downcast_ref::() + else { + return; + }; + + *jigsaw_block.name.lock().await = jigsaw.name.to_string(); + *jigsaw_block.target.lock().await = jigsaw.target.to_string(); + *jigsaw_block.pool.lock().await = jigsaw.pool.to_string(); + *jigsaw_block.final_state.lock().await = jigsaw.final_state.to_string(); + *jigsaw_block.joint.lock().await = JigsawJointType::from_str(jigsaw.joint); + jigsaw_block + .selection_priority + .store(jigsaw.selection_priority.0, Ordering::SeqCst); + jigsaw_block + .placement_priority + .store(jigsaw.placement_priority.0, Ordering::SeqCst); + + player.world().update_block_entity(&block_entity); + } + } +} diff --git a/crates/pumpkin/src/net/java/play/set_test_block.rs b/crates/pumpkin/src/net/java/play/set_test_block.rs new file mode 100644 index 000000000..b97aee022 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/set_test_block.rs @@ -0,0 +1,15 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub fn handle_set_test_block(&self, player: &Arc, packet: &SSetTestBlock<'_>) { + if !player.has_client_loaded() { + return; + } + player.update_last_action_time(); + debug!( + "Set test block at {:?}: mode={:?}, message={}", + packet.position, packet.mode, packet.message + ); + } +} diff --git a/crates/pumpkin/src/net/java/play/swing_arm.rs b/crates/pumpkin/src/net/java/play/swing_arm.rs new file mode 100644 index 000000000..5f2aabf00 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/swing_arm.rs @@ -0,0 +1,63 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_swing_arm( + &self, + server: &Arc, + player: &Arc, + swing_arm: SSwingArm, + ) { + player.update_last_action_time(); + let Ok(hand) = Hand::from_packet_id(swing_arm.hand.0) else { + self.kick(TextComponent::text("Invalid hand")).await; + return; + }; + + let mut anim_event = crate::plugin::api::events::player::player_animation::PlayerAnimationEvent::new( + player.clone(), + match hand { + Hand::Left => crate::plugin::api::events::player::player_animation::PlayerAnimationType::ArmSwingOff, + Hand::Right => crate::plugin::api::events::player::player_animation::PlayerAnimationType::ArmSwingMain, + }, + ); + server.plugin_manager.fire(server, &mut anim_event).await; + if anim_event.cancelled { + return; + } + + let (yaw, pitch) = player.rotation(); + let hit_result = player + .world() + .raycast( + player.eye_position(), + player + .eye_position() + .add(&(Vector3::rotation_vector(f64::from(pitch), f64::from(yaw)) * 4.5)), + async |pos, world| { + let block = world.get_block(pos); + block != &Block::AIR && block != &Block::WATER && block != &Block::LAVA + }, + ) + .await; + + let event = if let Some((hit_pos, _hit_dir)) = hit_result { + PlayerInteractEvent::new( + player, + InteractAction::LeftClickBlock, + player.world().get_block(&hit_pos), + Some(hit_pos), + ) + } else { + PlayerInteractEvent::new(player, InteractAction::LeftClickAir, &Block::AIR, None) + }; + + send_cancellable! {{ + &server; + event; + 'after: { + player.swing_hand(hand, false).await; + } + }} + } +} diff --git a/crates/pumpkin/src/net/java/play/teleport_to_entity.rs b/crates/pumpkin/src/net/java/play/teleport_to_entity.rs new file mode 100644 index 000000000..1c38bcdd9 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/teleport_to_entity.rs @@ -0,0 +1,37 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_teleport_to_entity( + &self, + player: &Arc, + packet: STeleportToEntity, + server: &Server, + ) { + if !player.has_client_loaded() { + return; + } + player.update_last_action_time(); + + if player.gamemode.load() != GameMode::Spectator { + return; + } + + if let Some(target_player) = server.get_player_by_uuid(packet.target) { + let target_pos = target_player.living_entity.entity.pos.load(); + let target_yaw = target_player.living_entity.entity.yaw.load(); + let target_pitch = target_player.living_entity.entity.pitch.load(); + + let target_id = target_player.living_entity.entity.entity_id; + player.camera_target_id.store(Some(target_id)); + player + .client + .send_packet_now(&CSetCamera::new(target_id.into())) + .await; + + player + .request_teleport(target_pos, target_yaw, target_pitch) + .await; + } + } +} diff --git a/crates/pumpkin/src/net/java/play/test_instance_block_action.rs b/crates/pumpkin/src/net/java/play/test_instance_block_action.rs new file mode 100644 index 000000000..9dd561b93 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/test_instance_block_action.rs @@ -0,0 +1,19 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub fn handle_test_instance_block_action( + &self, + player: &Arc, + packet: &STestInstanceBlockAction<'_>, + ) { + if !player.has_client_loaded() { + return; + } + player.update_last_action_time(); + debug!( + "Test instance block action at {:?}: action={:?}", + packet.pos, packet.action + ); + } +} diff --git a/crates/pumpkin/src/net/java/play/update_sign.rs b/crates/pumpkin/src/net/java/play/update_sign.rs new file mode 100644 index 000000000..c096b7317 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/update_sign.rs @@ -0,0 +1,35 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_sign_update(&self, player: &Player, sign_data: SUpdateSign<'_>) { + let world = player.get_entity().world.load_full(); + let Some(block_entity) = world.get_block_entity(&sign_data.location) else { + return; + }; + let Some(sign_entity) = block_entity.as_any().downcast_ref::() else { + return; + }; + if sign_entity.is_waxed.load(Ordering::Relaxed) { + return; + } + + let text = if sign_data.is_front_text { + &sign_entity.front_text + } else { + &sign_entity.back_text + }; + + *text + .messages + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = [ + sign_data.line_1.into(), + sign_data.line_2.into(), + sign_data.line_3.into(), + sign_data.line_4.into(), + ]; + *sign_entity.currently_editing_player.lock().await = None; + world.update_block_entity(&block_entity); + } +} diff --git a/crates/pumpkin/src/net/java/play/use_item.rs b/crates/pumpkin/src/net/java/play/use_item.rs new file mode 100644 index 000000000..8164b60f2 --- /dev/null +++ b/crates/pumpkin/src/net/java/play/use_item.rs @@ -0,0 +1,163 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + pub async fn handle_use_item( + &self, + player: &Arc, + use_item: &SUseItem, + server: &Arc, + ) { + if !player.has_client_loaded() { + return; + } + player.update_last_action_time(); + + let inventory = player.inventory(); + let Ok(hand) = Hand::from_packet_id(use_item.hand.0) else { + self.kick(TextComponent::text("InvalidHand")).await; + return; + }; + self.update_sequence(player, use_item.sequence.0); + + let mut item_in_hand = inventory.get_stack_in_hand(hand).await; + + let (item_id, _item) = (item_in_hand.item.id, item_in_hand.item); + player + .increment_stat(StatisticCategory::Used, item_id as i32, 1) + .await; + + let hit_result = player + .world() + .raycast( + player.eye_position(), + player.eye_position().add( + &(Vector3::rotation_vector(f64::from(use_item.pitch), f64::from(use_item.yaw)) + * 4.5), + ), + async |pos, world| { + let block = world.get_block(pos); + block != &Block::AIR && block != &Block::WATER && block != &Block::LAVA + }, + ) + .await; + + let event = if let Some((hit_pos, _hit_dir)) = hit_result { + PlayerInteractEvent::new( + player, + InteractAction::RightClickBlock, + player.world().get_block(&hit_pos), + Some(hit_pos), + ) + } else { + PlayerInteractEvent::new(player, InteractAction::RightClickAir, &Block::AIR, None) + }; + let (item_for_use, stack_for_use) = (item_in_hand.item, item_in_hand.clone()); + self.prepare_hand_item_for_use(player, hand, &mut item_in_hand) + .await; + + if !self + .should_continue_use_after_fish_event(server, player, hand, item_for_use) + .await + { + return; + } + + send_cancellable! {{ + server; + event; + 'after: { + server.item_registry.on_use(&stack_for_use, player).await; + } + }} + } + + async fn prepare_hand_item_for_use( + &self, + player: &Arc, + hand: Hand, + held: &mut ItemStack, + ) { + let inventory = player.inventory(); + + if let Some(cooldown) = held.get_use_cooldown() { + let group = cooldown + .cooldown_group + .clone() + .unwrap_or_else(|| held.item.registry_key.to_string()); + if player.is_on_cooldown(&group).await { + return; + } + } + + if held.get_data_component::().is_some() + || held.get_data_component::().is_some() + { + // If its food we want to make sure we can actually consume it + if let Some(food) = held.get_data_component::() { + if player.abilities.lock().await.invulnerable + || food.can_always_eat + || player.hunger_manager.level.load() < 20 + { + player + .living_entity + .set_active_hand(hand, held.clone(), held.get_max_use_time()) + .await; + } + } else { + player + .living_entity + .set_active_hand(hand, held.clone(), held.get_max_use_time()) + .await; + } + } + let equipment_slot = held + .get_data_component::() + .map(|equippable| equippable.slot.clone()); + if let Some(slot) = equipment_slot { + // The equipment lock has to be released before touching the hand again: + // the off hand lives in the same map, so holding it here would deadlock. + let current_equipped = inventory.entity_equipment.lock().await.get(&slot); + if current_equipped.are_items_and_components_equal(held) { + return; + } + + player.enqueue_equipment_change(&slot, held).await; + + let equipped = if current_equipped.is_empty() { + let equipped = held.clone(); + held.decrement_unless_creative(player.gamemode.load(), 1); + equipped + } else { + std::mem::replace(held, current_equipped) + }; + inventory.entity_equipment.lock().await.put(&slot, equipped); + inventory.set_stack_in_hand(hand, held.clone()).await; + } + } + + async fn should_continue_use_after_fish_event( + &self, + server: &Arc, + player: &Arc, + hand: Hand, + item_for_use: &Item, + ) -> bool { + if item_for_use.id != Item::FISHING_ROD.id { + return true; + } + + // TODO: Apply fishing rod durability on retrieval based on catch type. + let mut fish_event = PlayerFishEvent::new( + player.clone(), + None, + uuid::Uuid::nil(), + String::new(), + PlayerFishState::Fishing, + hand, + 0, + ); + server.plugin_manager.fire(server, &mut fish_event).await; + !fish_event.cancelled + } +} diff --git a/crates/pumpkin/src/net/java/play/use_item_on.rs b/crates/pumpkin/src/net/java/play/use_item_on.rs new file mode 100644 index 000000000..42ec6feea --- /dev/null +++ b/crates/pumpkin/src/net/java/play/use_item_on.rs @@ -0,0 +1,262 @@ +#[allow(clippy::wildcard_imports)] +use super::*; + +impl JavaClient { + #[allow(clippy::too_many_lines)] + pub async fn handle_use_item_on( + &self, + player: &Arc, + use_item_on: SUseItemOn, + server: &Arc, + ) -> Result<(), BlockPlacingError> { + if !player.has_client_loaded() { + return Ok(()); + } + player.update_last_action_time(); + self.update_sequence(player, use_item_on.sequence.0); + + let position = use_item_on.position; + let cursor_pos = use_item_on.cursor_pos; + + let mut should_try_decrement = false; + + if !player.can_interact_with_block_at(&position, 1.0) { + // TODO: maybe log? + return Err(BlockPlacingError::BlockOutOfReach); + } + + let Ok(face) = BlockDirection::try_from(use_item_on.face.0) else { + return Err(BlockPlacingError::InvalidBlockFace); + }; + + let Ok(hand) = Hand::from_packet_id(use_item_on.hand.0) else { + return Err(BlockPlacingError::InvalidHand); + }; + + if player.gamemode.load() == GameMode::Spectator { + // TODO: openMenu + return Ok(()); + } + + let inventory = player.inventory(); + let held_item = inventory.held_item().await; + let off_hand_item = inventory.off_hand_item().await; + let held_item_empty = held_item.is_empty(); + let off_hand_item_empty = off_hand_item.is_empty(); + + let mut item = inventory.get_stack_in_hand(hand).await; + let item_id = item.item.id; + player + .increment_stat(StatisticCategory::Used, item_id as i32, 1) + .await; + + let entity = &player.get_entity(); + let world = entity.world.load_full(); + let block = world.get_block(&position); + + let event = PlayerInteractEvent::new( + player, + InteractAction::RightClickBlock, + block, + Some(position), + ); + + send_cancellable! {{ + server; + event; + 'cancelled: { + let state_id = world.get_block_state_id(&position); + self.enqueue_packet(&CBlockUpdate::new( + position, + VarInt(i32::from(state_id.as_u16())), + )) + .await; + return Ok(()); + } + }} + + let equipment_slot = if matches!(hand, Hand::Right) { + EquipmentSlot::MAIN_HAND + } else { + EquipmentSlot::OFF_HAND + }; + + let sneaking = player.get_entity().is_sneaking(); + + // Code based on the java class ServerPlayerInteractionManager + if !(sneaking && (!held_item_empty || !off_hand_item_empty)) { + let result = self + .call_use_item_on( + player, + &position, + &cursor_pos, + &face, + &mut item, + &equipment_slot, + &world, + block, + server, + ) + .await; + if result.consumes_action() { + // TODO: Trigger ANY_BLOCK_USE Criteria + + if matches!(result, BlockActionResult::SuccessServer) { + player.swing_hand(hand, true).await; + } + return Ok(()); + } + } + + let slot_index = if matches!(hand, Hand::Right) { + inventory.get_selected_slot() as usize + } else { + PlayerInventory::OFF_HAND_SLOT + }; + + if item.is_empty() { + // TODO item cool down + // If the hand is empty we stop here + return Ok(()); + } + + let before = item.clone(); + + server + .item_registry + .use_on_block(&mut item, player, position, face, cursor_pos, block, server) + .await; + + // Check if the item is a block, because not every item can be placed :D + let item_id = item.item.id; + if let Some(block) = Block::from_item_id(item_id) { + should_try_decrement = self + .run_is_block_place(player, block, server, use_item_on, position, face) + .await?; + } + + if should_try_decrement { + // TODO: Config + // Decrease block count + if player.gamemode.load() != GameMode::Creative { + item.decrement(1); + } + } + + let after = item.clone(); + + // Broadcast the break entity status before the slot sync; the client + // needs the old item texture in the slot for break particles. + if !before.is_empty() && after.is_empty() { + let slot = if slot_index == player.inventory.get_selected_slot() as usize { + &EquipmentSlot::MAIN_HAND + } else { + &EquipmentSlot::OFF_HAND + }; + player.world().send_entity_status( + player.get_entity(), + equipment_break_status(slot), + None, + ); + } + + if !after.are_equal(&before) { + player.sync_hand_slot(slot_index, after.clone()).await; + inventory.set_stack_in_hand(hand, after).await; + } + + Ok(()) + } + + #[expect(clippy::too_many_arguments)] + async fn call_use_item_on( + &self, + player: &Arc, + position: &BlockPos, + cursor_pos: &Vector3, + face: &BlockDirection, + held_item: &mut ItemStack, + equipment_slot: &EquipmentSlot, + world: &Arc, + block: &Block, + server: &Arc, + ) -> BlockActionResult { + let result = server + .block_registry + .use_with_item( + block, + player, + position, + &BlockHitResult { face, cursor_pos }, + held_item, + equipment_slot, + server, + world, + ) + .await; + + if result.consumes_action() { + // TODO: Trigger ITEM_USED_ON_BLOCK Criteria + return result; + } + + if matches!(result, BlockActionResult::PassToDefaultBlockAction) { + let result = server + .block_registry + .on_use( + block, + player, + position, + &BlockHitResult { face, cursor_pos }, + server, + world, + ) + .await; + + if result.consumes_action() { + // TODO: Trigger DEFAULT_BLOCK_USE Criteria + return result; + } + } + + BlockActionResult::Pass + } + + async fn run_is_block_place( + &self, + player: &Arc, + block: &'static Block, + server: &Arc, + use_item_on: SUseItemOn, + location: BlockPos, + face: BlockDirection, + ) -> Result { + match server + .block_registry + .place_block(player, block, server, &use_item_on, location, face) + .await + { + Ok(Some((final_block_pos, new_state))) => { + self.send_packet_now(&CBlockUpdate::new( + final_block_pos, + VarInt(i32::from(new_state.as_u16())), + )) + .await; + Ok(true) + } + Ok(None) => Ok(false), + Err(crate::block::registry::BlockPlacingError::InvalidGamemode) => { + Err(BlockPlacingError::InvalidGamemode) + } + Err(crate::block::registry::BlockPlacingError::BlockOutOfWorld) => { + Err(BlockPlacingError::BlockOutOfWorld) + } + } + } + + /// Checks if the block placed was a sign, then opens a dialog. + pub async fn send_sign_packet(&self, block_position: BlockPos, is_front_text: bool) { + self.enqueue_packet(&COpenSignEditor::new(block_position, is_front_text)) + .await; + } +}