aboutsummaryrefslogtreecommitdiff
path: root/azalea-client/src/plugins
diff options
context:
space:
mode:
authormat <27899617+mat-1@users.noreply.github.com>2025-02-22 21:45:26 -0600
committerGitHub <noreply@github.com>2025-02-22 21:45:26 -0600
commite21e1b97bf9337e9f4747cd1b545b1b3a03e2ce7 (patch)
treeadd6f8bfce40d0c07845d8aa4c9945a0b918444c /azalea-client/src/plugins
parentf8130c3c92946d2293634ba4e252d6bc93026c3c (diff)
downloadazalea-drasl-e21e1b97bf9337e9f4747cd1b545b1b3a03e2ce7.tar.xz
Refactor azalea-client (#205)
* start organizing packet_handling more by moving packet handlers into their own functions * finish writing all the handler functions for packets * use macro for generating match statement for packet handler functions * fix set_entity_data * update config state to also use handler functions * organize az-client file structure by moving things into plugins directory * fix merge issues
Diffstat (limited to 'azalea-client/src/plugins')
-rw-r--r--azalea-client/src/plugins/attack.rs149
-rw-r--r--azalea-client/src/plugins/brand.rs63
-rw-r--r--azalea-client/src/plugins/chat/handler.rs61
-rw-r--r--azalea-client/src/plugins/chat/mod.rs240
-rw-r--r--azalea-client/src/plugins/chunks.rs223
-rw-r--r--azalea-client/src/plugins/disconnect.rs103
-rw-r--r--azalea-client/src/plugins/events.rs259
-rw-r--r--azalea-client/src/plugins/interact.rs370
-rw-r--r--azalea-client/src/plugins/inventory.rs766
-rw-r--r--azalea-client/src/plugins/mining.rs644
-rw-r--r--azalea-client/src/plugins/mod.rs14
-rw-r--r--azalea-client/src/plugins/movement.rs580
-rw-r--r--azalea-client/src/plugins/packet/config/events.rs90
-rw-r--r--azalea-client/src/plugins/packet/config/mod.rs223
-rw-r--r--azalea-client/src/plugins/packet/game/events.rs178
-rw-r--r--azalea-client/src/plugins/packet/game/mod.rs1583
-rw-r--r--azalea-client/src/plugins/packet/login.rs114
-rw-r--r--azalea-client/src/plugins/packet/mod.rs109
-rw-r--r--azalea-client/src/plugins/respawn.rs35
-rw-r--r--azalea-client/src/plugins/task_pool.rs182
-rw-r--r--azalea-client/src/plugins/tick_end.rs36
21 files changed, 6022 insertions, 0 deletions
diff --git a/azalea-client/src/plugins/attack.rs b/azalea-client/src/plugins/attack.rs
new file mode 100644
index 00000000..1b2bc1ee
--- /dev/null
+++ b/azalea-client/src/plugins/attack.rs
@@ -0,0 +1,149 @@
+use azalea_core::{game_type::GameMode, tick::GameTick};
+use azalea_entity::{
+ Attributes, Physics,
+ metadata::{ShiftKeyDown, Sprinting},
+ update_bounding_box,
+};
+use azalea_physics::PhysicsSet;
+use azalea_protocol::packets::game::s_interact::{self, ServerboundInteract};
+use azalea_world::MinecraftEntityId;
+use bevy_app::{App, Plugin, Update};
+use bevy_ecs::prelude::*;
+use derive_more::{Deref, DerefMut};
+
+use super::packet::game::SendPacketEvent;
+use crate::{
+ Client, interact::SwingArmEvent, local_player::LocalGameMode, movement::MoveEventsSet,
+ respawn::perform_respawn,
+};
+
+pub struct AttackPlugin;
+impl Plugin for AttackPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_event::<AttackEvent>()
+ .add_systems(
+ Update,
+ handle_attack_event
+ .before(update_bounding_box)
+ .before(MoveEventsSet)
+ .after(perform_respawn),
+ )
+ .add_systems(
+ GameTick,
+ (
+ increment_ticks_since_last_attack,
+ update_attack_strength_scale.after(PhysicsSet),
+ )
+ .chain(),
+ );
+ }
+}
+
+impl Client {
+ /// Attack the entity with the given id.
+ pub fn attack(&mut self, entity_id: MinecraftEntityId) {
+ self.ecs.lock().send_event(AttackEvent {
+ entity: self.entity,
+ target: entity_id,
+ });
+ }
+
+ /// Whether the player has an attack cooldown.
+ pub fn has_attack_cooldown(&self) -> bool {
+ let Some(AttackStrengthScale(ticks_since_last_attack)) =
+ self.get_component::<AttackStrengthScale>()
+ else {
+ // they don't even have an AttackStrengthScale so they probably can't attack
+ // lmao, just return false
+ return false;
+ };
+ ticks_since_last_attack < 1.0
+ }
+}
+
+#[derive(Event)]
+pub struct AttackEvent {
+ pub entity: Entity,
+ pub target: MinecraftEntityId,
+}
+pub fn handle_attack_event(
+ mut events: EventReader<AttackEvent>,
+ mut query: Query<(
+ &LocalGameMode,
+ &mut TicksSinceLastAttack,
+ &mut Physics,
+ &mut Sprinting,
+ &mut ShiftKeyDown,
+ )>,
+ mut send_packet_events: EventWriter<SendPacketEvent>,
+ mut swing_arm_event: EventWriter<SwingArmEvent>,
+) {
+ for event in events.read() {
+ let (game_mode, mut ticks_since_last_attack, mut physics, mut sprinting, sneaking) =
+ query.get_mut(event.entity).unwrap();
+
+ swing_arm_event.send(SwingArmEvent {
+ entity: event.entity,
+ });
+ send_packet_events.send(SendPacketEvent::new(
+ event.entity,
+ ServerboundInteract {
+ entity_id: event.target,
+ action: s_interact::ActionType::Attack,
+ using_secondary_action: **sneaking,
+ },
+ ));
+
+ // we can't attack if we're in spectator mode but it still sends the attack
+ // packet
+ if game_mode.current == GameMode::Spectator {
+ continue;
+ };
+
+ ticks_since_last_attack.0 = 0;
+
+ physics.velocity = physics.velocity.multiply(0.6, 1.0, 0.6);
+ **sprinting = false;
+ }
+}
+
+#[derive(Default, Bundle)]
+pub struct AttackBundle {
+ pub ticks_since_last_attack: TicksSinceLastAttack,
+ pub attack_strength_scale: AttackStrengthScale,
+}
+
+#[derive(Default, Component, Clone, Deref, DerefMut)]
+pub struct TicksSinceLastAttack(pub u32);
+pub fn increment_ticks_since_last_attack(mut query: Query<&mut TicksSinceLastAttack>) {
+ for mut ticks_since_last_attack in query.iter_mut() {
+ **ticks_since_last_attack += 1;
+ }
+}
+
+#[derive(Default, Component, Clone, Deref, DerefMut)]
+pub struct AttackStrengthScale(pub f32);
+pub fn update_attack_strength_scale(
+ mut query: Query<(&TicksSinceLastAttack, &Attributes, &mut AttackStrengthScale)>,
+) {
+ for (ticks_since_last_attack, attributes, mut attack_strength_scale) in query.iter_mut() {
+ // look 0.5 ticks into the future because that's what vanilla does
+ **attack_strength_scale =
+ get_attack_strength_scale(ticks_since_last_attack.0, attributes, 0.5);
+ }
+}
+
+/// Returns how long it takes for the attack cooldown to reset (in ticks).
+pub fn get_attack_strength_delay(attributes: &Attributes) -> f32 {
+ ((1. / attributes.attack_speed.calculate()) * 20.) as f32
+}
+
+pub fn get_attack_strength_scale(
+ ticks_since_last_attack: u32,
+ attributes: &Attributes,
+ in_ticks: f32,
+) -> f32 {
+ let attack_strength_delay = get_attack_strength_delay(attributes);
+ let attack_strength = (ticks_since_last_attack as f32 + in_ticks) / attack_strength_delay;
+ attack_strength.clamp(0., 1.)
+}
diff --git a/azalea-client/src/plugins/brand.rs b/azalea-client/src/plugins/brand.rs
new file mode 100644
index 00000000..e15a6c67
--- /dev/null
+++ b/azalea-client/src/plugins/brand.rs
@@ -0,0 +1,63 @@
+use azalea_buf::AzaleaWrite;
+use azalea_core::resource_location::ResourceLocation;
+use azalea_protocol::{
+ common::client_information::ClientInformation,
+ packets::config::{
+ s_client_information::ServerboundClientInformation,
+ s_custom_payload::ServerboundCustomPayload,
+ },
+};
+use bevy_app::prelude::*;
+use bevy_ecs::prelude::*;
+use tracing::{debug, warn};
+
+use super::packet::config::SendConfigPacketEvent;
+use crate::packet::login::InLoginState;
+
+pub struct BrandPlugin;
+impl Plugin for BrandPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_systems(
+ Update,
+ handle_end_login_state.before(crate::packet::config::handle_send_packet_event),
+ );
+ }
+}
+
+fn handle_end_login_state(
+ mut removed: RemovedComponents<InLoginState>,
+ query: Query<&ClientInformation>,
+ mut send_packet_events: EventWriter<SendConfigPacketEvent>,
+) {
+ for entity in removed.read() {
+ let mut brand_data = Vec::new();
+ // azalea pretends to be vanilla everywhere else so it makes sense to lie here
+ // too
+ "vanilla".azalea_write(&mut brand_data).unwrap();
+ send_packet_events.send(SendConfigPacketEvent::new(
+ entity,
+ ServerboundCustomPayload {
+ identifier: ResourceLocation::new("brand"),
+ data: brand_data.into(),
+ },
+ ));
+
+ let client_information = match query.get(entity).ok() {
+ Some(i) => i,
+ None => {
+ warn!(
+ "ClientInformation component was not set before leaving login state, using a default"
+ );
+ &ClientInformation::default()
+ }
+ };
+
+ debug!("Writing ClientInformation while in config state: {client_information:?}");
+ send_packet_events.send(SendConfigPacketEvent::new(
+ entity,
+ ServerboundClientInformation {
+ information: client_information.clone(),
+ },
+ ));
+ }
+}
diff --git a/azalea-client/src/plugins/chat/handler.rs b/azalea-client/src/plugins/chat/handler.rs
new file mode 100644
index 00000000..d598acdb
--- /dev/null
+++ b/azalea-client/src/plugins/chat/handler.rs
@@ -0,0 +1,61 @@
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use azalea_protocol::packets::{
+ game::{s_chat::LastSeenMessagesUpdate, ServerboundChat, ServerboundChatCommand},
+ Packet,
+};
+use bevy_ecs::prelude::*;
+
+use super::ChatKind;
+use crate::packet::game::SendPacketEvent;
+
+/// Send a chat packet to the server of a specific kind (chat message or
+/// command). Usually you just want [`SendChatEvent`] instead.
+///
+/// Usually setting the kind to `Message` will make it send a chat message even
+/// if it starts with a slash, but some server implementations will always do a
+/// command if it starts with a slash.
+///
+/// If you're wondering why this isn't two separate events, it's so ordering is
+/// preserved if multiple chat messages and commands are sent at the same time.
+#[derive(Event)]
+pub struct SendChatKindEvent {
+ pub entity: Entity,
+ pub content: String,
+ pub kind: ChatKind,
+}
+
+pub fn handle_send_chat_kind_event(
+ mut events: EventReader<SendChatKindEvent>,
+ mut send_packet_events: EventWriter<SendPacketEvent>,
+) {
+ for event in events.read() {
+ let content = event
+ .content
+ .chars()
+ .filter(|c| !matches!(c, '\x00'..='\x1F' | '\x7F' | 'ยง'))
+ .take(256)
+ .collect::<String>();
+ let packet = match event.kind {
+ ChatKind::Message => ServerboundChat {
+ message: content,
+ timestamp: SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("Time shouldn't be before epoch")
+ .as_millis()
+ .try_into()
+ .expect("Instant should fit into a u64"),
+ salt: azalea_crypto::make_salt(),
+ signature: None,
+ last_seen_messages: LastSeenMessagesUpdate::default(),
+ }
+ .into_variant(),
+ ChatKind::Command => {
+ // TODO: chat signing
+ ServerboundChatCommand { command: content }.into_variant()
+ }
+ };
+
+ send_packet_events.send(SendPacketEvent::new(event.entity, packet));
+ }
+}
diff --git a/azalea-client/src/plugins/chat/mod.rs b/azalea-client/src/plugins/chat/mod.rs
new file mode 100644
index 00000000..66c77b56
--- /dev/null
+++ b/azalea-client/src/plugins/chat/mod.rs
@@ -0,0 +1,240 @@
+//! Implementations of chat-related features.
+
+pub mod handler;
+
+use std::sync::Arc;
+
+use azalea_chat::FormattedText;
+use azalea_protocol::packets::game::{
+ c_disguised_chat::ClientboundDisguisedChat, c_player_chat::ClientboundPlayerChat,
+ c_system_chat::ClientboundSystemChat,
+};
+use bevy_app::{App, Plugin, Update};
+use bevy_ecs::{
+ entity::Entity,
+ event::{EventReader, EventWriter},
+ prelude::Event,
+ schedule::IntoSystemConfigs,
+};
+use handler::{SendChatKindEvent, handle_send_chat_kind_event};
+use uuid::Uuid;
+
+use super::packet::game::handle_outgoing_packets;
+use crate::client::Client;
+
+pub struct ChatPlugin;
+impl Plugin for ChatPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_event::<SendChatEvent>()
+ .add_event::<SendChatKindEvent>()
+ .add_event::<ChatReceivedEvent>()
+ .add_systems(
+ Update,
+ (
+ handle_send_chat_event,
+ handle_send_chat_kind_event.after(handle_outgoing_packets),
+ )
+ .chain(),
+ );
+ }
+}
+
+/// A chat packet, either a system message or a chat message.
+#[derive(Debug, Clone, PartialEq)]
+pub enum ChatPacket {
+ System(Arc<ClientboundSystemChat>),
+ Player(Arc<ClientboundPlayerChat>),
+ Disguised(Arc<ClientboundDisguisedChat>),
+}
+
+macro_rules! regex {
+ ($re:literal $(,)?) => {{
+ static RE: std::sync::LazyLock<regex::Regex> =
+ std::sync::LazyLock::new(|| regex::Regex::new($re).unwrap());
+ &RE
+ }};
+}
+
+impl ChatPacket {
+ /// Get the message shown in chat for this packet.
+ pub fn message(&self) -> FormattedText {
+ match self {
+ ChatPacket::System(p) => p.content.clone(),
+ ChatPacket::Player(p) => p.message(),
+ ChatPacket::Disguised(p) => p.message(),
+ }
+ }
+
+ /// Determine the username of the sender and content of the message. This
+ /// does not preserve formatting codes. If it's not a player-sent chat
+ /// message or the sender couldn't be determined, the username part will be
+ /// None.
+ pub fn split_sender_and_content(&self) -> (Option<String>, String) {
+ match self {
+ ChatPacket::System(p) => {
+ let message = p.content.to_string();
+ // Overlay messages aren't in chat
+ if p.overlay {
+ return (None, message);
+ }
+ // It's a system message, so we'll have to match the content
+ // with regex
+ if let Some(m) = regex!("^<([a-zA-Z_0-9]{1,16})> (.+)$").captures(&message) {
+ return (Some(m[1].to_string()), m[2].to_string());
+ }
+
+ (None, message)
+ }
+ ChatPacket::Player(p) => (
+ // If it's a player chat packet, then the sender and content
+ // are already split for us.
+ Some(p.chat_type.name.to_string()),
+ p.body.content.clone(),
+ ),
+ ChatPacket::Disguised(p) => (
+ // disguised chat packets are basically the same as player chat packets but without
+ // the chat signing things
+ Some(p.chat_type.name.to_string()),
+ p.message.to_string(),
+ ),
+ }
+ }
+
+ /// Get the username of the sender of the message. If it's not a
+ /// player-sent chat message or the sender couldn't be determined, this
+ /// will be None.
+ pub fn username(&self) -> Option<String> {
+ self.split_sender_and_content().0
+ }
+
+ /// Get the UUID of the sender of the message. If it's not a
+ /// player-sent chat message, this will be None (this is sometimes the case
+ /// when a server uses a plugin to modify chat messages).
+ pub fn uuid(&self) -> Option<Uuid> {
+ match self {
+ ChatPacket::System(_) => None,
+ ChatPacket::Player(m) => Some(m.sender),
+ ChatPacket::Disguised(_) => None,
+ }
+ }
+
+ /// Get the content part of the message as a string. This does not preserve
+ /// formatting codes. If it's not a player-sent chat message or the sender
+ /// couldn't be determined, this will contain the entire message.
+ pub fn content(&self) -> String {
+ self.split_sender_and_content().1
+ }
+
+ /// Create a new Chat from a string. This is meant to be used as a
+ /// convenience function for testing.
+ pub fn new(message: &str) -> Self {
+ ChatPacket::System(Arc::new(ClientboundSystemChat {
+ content: FormattedText::from(message),
+ overlay: false,
+ }))
+ }
+
+ /// Whether this message was sent with /msg (or aliases). It works by
+ /// checking the translation key, so it won't work on servers that use their
+ /// own whisper system.
+ pub fn is_whisper(&self) -> bool {
+ match self.message() {
+ FormattedText::Text(_) => false,
+ FormattedText::Translatable(t) => t.key == "commands.message.display.incoming",
+ }
+ }
+}
+
+impl Client {
+ /// Send a chat message to the server. This only sends the chat packet and
+ /// not the command packet, which means on some servers you can use this to
+ /// send chat messages that start with a `/`. The [`Client::chat`] function
+ /// handles checking whether the message is a command and using the
+ /// proper packet for you, so you should use that instead.
+ pub fn send_chat_packet(&self, message: &str) {
+ self.ecs.lock().send_event(SendChatKindEvent {
+ entity: self.entity,
+ content: message.to_string(),
+ kind: ChatKind::Message,
+ });
+ self.run_schedule_sender.send(()).unwrap();
+ }
+
+ /// Send a command packet to the server. The `command` argument should not
+ /// include the slash at the front.
+ ///
+ /// You can also just use [`Client::chat`] and start your message with a `/`
+ /// to send a command.
+ pub fn send_command_packet(&self, command: &str) {
+ self.ecs.lock().send_event(SendChatKindEvent {
+ entity: self.entity,
+ content: command.to_string(),
+ kind: ChatKind::Command,
+ });
+ self.run_schedule_sender.send(()).unwrap();
+ }
+
+ /// Send a message in chat.
+ ///
+ /// ```rust,no_run
+ /// # use azalea_client::Client;
+ /// # async fn example(bot: Client) -> anyhow::Result<()> {
+ /// bot.chat("Hello, world!");
+ /// # Ok(())
+ /// # }
+ /// ```
+ pub fn chat(&self, content: &str) {
+ self.ecs.lock().send_event(SendChatEvent {
+ entity: self.entity,
+ content: content.to_string(),
+ });
+ self.run_schedule_sender.send(()).unwrap();
+ }
+}
+
+/// A client received a chat message packet.
+#[derive(Event, Debug, Clone)]
+pub struct ChatReceivedEvent {
+ pub entity: Entity,
+ pub packet: ChatPacket,
+}
+
+/// Send a chat message (or command, if it starts with a slash) to the server.
+#[derive(Event)]
+pub struct SendChatEvent {
+ pub entity: Entity,
+ pub content: String,
+}
+
+pub fn handle_send_chat_event(
+ mut events: EventReader<SendChatEvent>,
+ mut send_chat_kind_events: EventWriter<SendChatKindEvent>,
+) {
+ for event in events.read() {
+ if event.content.starts_with('/') {
+ send_chat_kind_events.send(SendChatKindEvent {
+ entity: event.entity,
+ content: event.content[1..].to_string(),
+ kind: ChatKind::Command,
+ });
+ } else {
+ send_chat_kind_events.send(SendChatKindEvent {
+ entity: event.entity,
+ content: event.content.clone(),
+ kind: ChatKind::Message,
+ });
+ }
+ }
+}
+
+/// A kind of chat packet, either a chat message or a command.
+pub enum ChatKind {
+ Message,
+ Command,
+}
+
+// TODO
+// MessageSigner, ChatMessageContent, LastSeenMessages
+// fn sign_message() -> MessageSignature {
+// MessageSignature::default()
+// }
diff --git a/azalea-client/src/plugins/chunks.rs b/azalea-client/src/plugins/chunks.rs
new file mode 100644
index 00000000..cdda3eba
--- /dev/null
+++ b/azalea-client/src/plugins/chunks.rs
@@ -0,0 +1,223 @@
+//! Used for Minecraft's chunk batching introduced in 23w31a (1.20.2). It's used
+//! for making the server spread out how often it sends us chunk packets
+//! depending on our receiving speed.
+
+use std::{
+ io::Cursor,
+ ops::Deref,
+ time::{Duration, Instant},
+};
+
+use azalea_core::position::ChunkPos;
+use azalea_protocol::packets::game::{
+ c_level_chunk_with_light::ClientboundLevelChunkWithLight,
+ s_chunk_batch_received::ServerboundChunkBatchReceived,
+};
+use bevy_app::{App, Plugin, Update};
+use bevy_ecs::prelude::*;
+use simdnbt::owned::BaseNbt;
+use tracing::{error, trace};
+
+use super::packet::game::handle_outgoing_packets;
+use crate::{
+ InstanceHolder, interact::handle_block_interact_event, inventory::InventorySet,
+ packet::game::SendPacketEvent, respawn::perform_respawn,
+};
+
+pub struct ChunksPlugin;
+impl Plugin for ChunksPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_systems(
+ Update,
+ (
+ handle_chunk_batch_start_event,
+ handle_receive_chunk_events,
+ handle_chunk_batch_finished_event,
+ )
+ .chain()
+ .before(handle_outgoing_packets)
+ .before(InventorySet)
+ .before(handle_block_interact_event)
+ .before(perform_respawn),
+ )
+ .add_event::<ReceiveChunkEvent>()
+ .add_event::<ChunkBatchStartEvent>()
+ .add_event::<ChunkBatchFinishedEvent>();
+ }
+}
+
+#[derive(Event)]
+pub struct ReceiveChunkEvent {
+ pub entity: Entity,
+ pub packet: ClientboundLevelChunkWithLight,
+}
+
+#[derive(Component, Clone, Debug)]
+pub struct ChunkBatchInfo {
+ pub start_time: Instant,
+ pub aggregated_duration_per_chunk: Duration,
+ pub old_samples_weight: u32,
+}
+
+#[derive(Event)]
+pub struct ChunkBatchStartEvent {
+ pub entity: Entity,
+}
+#[derive(Event)]
+pub struct ChunkBatchFinishedEvent {
+ pub entity: Entity,
+ pub batch_size: u32,
+}
+
+pub fn handle_receive_chunk_events(
+ mut events: EventReader<ReceiveChunkEvent>,
+ mut query: Query<&mut InstanceHolder>,
+) {
+ for event in events.read() {
+ let pos = ChunkPos::new(event.packet.x, event.packet.z);
+
+ let local_player = query.get_mut(event.entity).unwrap();
+
+ let mut instance = local_player.instance.write();
+ let mut partial_instance = local_player.partial_instance.write();
+
+ // OPTIMIZATION: if we already know about the chunk from the shared world (and
+ // not ourselves), then we don't need to parse it again. This is only used when
+ // we have a shared world, since we check that the chunk isn't currently owned
+ // by this client.
+ let shared_chunk = instance.chunks.get(&pos);
+ let this_client_has_chunk = partial_instance.chunks.limited_get(&pos).is_some();
+
+ if !this_client_has_chunk {
+ if let Some(shared_chunk) = shared_chunk {
+ trace!("Skipping parsing chunk {pos:?} because we already know about it");
+ partial_instance
+ .chunks
+ .limited_set(&pos, Some(shared_chunk));
+ continue;
+ }
+ }
+
+ let heightmaps_nbt = &event.packet.chunk_data.heightmaps;
+ // necessary to make the unwrap_or work
+ let empty_nbt = BaseNbt::default();
+ let heightmaps = heightmaps_nbt.unwrap_or(&empty_nbt).deref();
+
+ if let Err(e) = partial_instance.chunks.replace_with_packet_data(
+ &pos,
+ &mut Cursor::new(&event.packet.chunk_data.data),
+ heightmaps,
+ &mut instance.chunks,
+ ) {
+ error!(
+ "Couldn't set chunk data: {e}. World height: {}",
+ instance.chunks.height
+ );
+ }
+ }
+}
+
+impl ChunkBatchInfo {
+ pub fn batch_finished(&mut self, batch_size: u32) {
+ if batch_size == 0 {
+ return;
+ }
+ let batch_duration = self.start_time.elapsed();
+ let duration_per_chunk = batch_duration / batch_size;
+ let clamped_duration = Duration::clamp(
+ duration_per_chunk,
+ self.aggregated_duration_per_chunk / 3,
+ self.aggregated_duration_per_chunk * 3,
+ );
+ self.aggregated_duration_per_chunk =
+ ((self.aggregated_duration_per_chunk * self.old_samples_weight) + clamped_duration)
+ / (self.old_samples_weight + 1);
+ self.old_samples_weight = u32::min(49, self.old_samples_weight + 1);
+ }
+
+ pub fn desired_chunks_per_tick(&self) -> f32 {
+ (7000000. / self.aggregated_duration_per_chunk.as_nanos() as f64) as f32
+ }
+}
+
+pub fn handle_chunk_batch_start_event(
+ mut query: Query<&mut ChunkBatchInfo>,
+ mut events: EventReader<ChunkBatchStartEvent>,
+) {
+ for event in events.read() {
+ if let Ok(mut chunk_batch_info) = query.get_mut(event.entity) {
+ chunk_batch_info.start_time = Instant::now();
+ }
+ }
+}
+
+pub fn handle_chunk_batch_finished_event(
+ mut query: Query<&mut ChunkBatchInfo>,
+ mut events: EventReader<ChunkBatchFinishedEvent>,
+ mut send_packets: EventWriter<SendPacketEvent>,
+) {
+ for event in events.read() {
+ if let Ok(mut chunk_batch_info) = query.get_mut(event.entity) {
+ chunk_batch_info.batch_finished(event.batch_size);
+ let desired_chunks_per_tick = chunk_batch_info.desired_chunks_per_tick();
+ send_packets.send(SendPacketEvent::new(
+ event.entity,
+ ServerboundChunkBatchReceived {
+ desired_chunks_per_tick,
+ },
+ ));
+ }
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct ChunkReceiveSpeedAccumulator {
+ batch_sizes: Vec<u32>,
+ /// as milliseconds
+ batch_durations: Vec<u32>,
+ index: usize,
+ filled_size: usize,
+}
+impl ChunkReceiveSpeedAccumulator {
+ pub fn new(capacity: usize) -> Self {
+ Self {
+ batch_sizes: vec![0; capacity],
+ batch_durations: vec![0; capacity],
+ index: 0,
+ filled_size: 0,
+ }
+ }
+
+ pub fn accumulate(&mut self, batch_size: u32, batch_duration: Duration) {
+ self.batch_sizes[self.index] = batch_size;
+ self.batch_durations[self.index] =
+ f32::clamp(batch_duration.as_millis() as f32, 0., 15000.) as u32;
+ self.index = (self.index + 1) % self.batch_sizes.len();
+ if self.filled_size < self.batch_sizes.len() {
+ self.filled_size += 1;
+ }
+ }
+
+ pub fn get_millis_per_chunk(&self) -> f64 {
+ let mut total_batch_size = 0;
+ let mut total_batch_duration = 0;
+ for i in 0..self.filled_size {
+ total_batch_size += self.batch_sizes[i];
+ total_batch_duration += self.batch_durations[i];
+ }
+ if total_batch_size == 0 {
+ return 0.;
+ }
+ total_batch_duration as f64 / total_batch_size as f64
+ }
+}
+
+impl Default for ChunkBatchInfo {
+ fn default() -> Self {
+ Self {
+ start_time: Instant::now(),
+ aggregated_duration_per_chunk: Duration::from_millis(2),
+ old_samples_weight: 1,
+ }
+ }
+}
diff --git a/azalea-client/src/plugins/disconnect.rs b/azalea-client/src/plugins/disconnect.rs
new file mode 100644
index 00000000..bd10ac75
--- /dev/null
+++ b/azalea-client/src/plugins/disconnect.rs
@@ -0,0 +1,103 @@
+//! Disconnect a client from the server.
+
+use azalea_chat::FormattedText;
+use azalea_entity::{EntityBundle, InLoadedChunk, LocalEntity, metadata::PlayerMetadataBundle};
+use bevy_app::{App, Plugin, PostUpdate};
+use bevy_ecs::{
+ component::Component,
+ entity::Entity,
+ event::{EventReader, EventWriter},
+ prelude::Event,
+ query::{Changed, With},
+ schedule::IntoSystemConfigs,
+ system::{Commands, Query},
+};
+use derive_more::Deref;
+use tracing::trace;
+
+use crate::{
+ InstanceHolder, client::JoinedClientBundle, events::LocalPlayerEvents,
+ raw_connection::RawConnection,
+};
+
+pub struct DisconnectPlugin;
+impl Plugin for DisconnectPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_event::<DisconnectEvent>().add_systems(
+ PostUpdate,
+ (
+ update_read_packets_task_running_component,
+ disconnect_on_connection_dead,
+ remove_components_from_disconnected_players,
+ )
+ .chain(),
+ );
+ }
+}
+
+/// An event sent when a client is getting disconnected.
+#[derive(Event)]
+pub struct DisconnectEvent {
+ pub entity: Entity,
+ pub reason: Option<FormattedText>,
+}
+
+/// A system that removes the several components from our clients when they get
+/// a [`DisconnectEvent`].
+pub fn remove_components_from_disconnected_players(
+ mut commands: Commands,
+ mut events: EventReader<DisconnectEvent>,
+ mut loaded_by_query: Query<&mut azalea_entity::LoadedBy>,
+) {
+ for DisconnectEvent { entity, .. } in events.read() {
+ trace!("Got DisconnectEvent for {entity:?}");
+ commands
+ .entity(*entity)
+ .remove::<JoinedClientBundle>()
+ .remove::<EntityBundle>()
+ .remove::<InstanceHolder>()
+ .remove::<PlayerMetadataBundle>()
+ .remove::<InLoadedChunk>()
+ // this makes it close the tcp connection
+ .remove::<RawConnection>()
+ // swarm detects when this tx gets dropped to fire SwarmEvent::Disconnect
+ .remove::<LocalPlayerEvents>();
+ // note that we don't remove the client from the ECS, so if they decide
+ // to reconnect they'll keep their state
+
+ // now we have to remove ourselves from the LoadedBy for every entity.
+ // in theory this could be inefficient if we have massive swarms... but in
+ // practice this is fine.
+ for mut loaded_by in &mut loaded_by_query.iter_mut() {
+ loaded_by.remove(entity);
+ }
+ }
+}
+
+#[derive(Component, Clone, Copy, Debug, Deref)]
+pub struct IsConnectionAlive(bool);
+
+fn update_read_packets_task_running_component(
+ query: Query<(Entity, &RawConnection)>,
+ mut commands: Commands,
+) {
+ for (entity, raw_connection) in &query {
+ let running = raw_connection.is_alive();
+ commands.entity(entity).insert(IsConnectionAlive(running));
+ }
+}
+
+#[allow(clippy::type_complexity)]
+fn disconnect_on_connection_dead(
+ query: Query<(Entity, &IsConnectionAlive), (Changed<IsConnectionAlive>, With<LocalEntity>)>,
+ mut disconnect_events: EventWriter<DisconnectEvent>,
+) {
+ for (entity, &is_connection_alive) in &query {
+ if !*is_connection_alive {
+ disconnect_events.send(DisconnectEvent {
+ entity,
+ reason: None,
+ });
+ }
+ }
+}
diff --git a/azalea-client/src/plugins/events.rs b/azalea-client/src/plugins/events.rs
new file mode 100644
index 00000000..3d34d75f
--- /dev/null
+++ b/azalea-client/src/plugins/events.rs
@@ -0,0 +1,259 @@
+//! Defines the [`Event`] enum and makes those events trigger when they're sent
+//! in the ECS.
+
+use std::sync::Arc;
+
+use azalea_chat::FormattedText;
+use azalea_core::tick::GameTick;
+use azalea_entity::Dead;
+use azalea_protocol::packets::game::{
+ ClientboundGamePacket, c_player_combat_kill::ClientboundPlayerCombatKill,
+};
+use azalea_world::{InstanceName, MinecraftEntityId};
+use bevy_app::{App, Plugin, PreUpdate, Update};
+use bevy_ecs::{
+ component::Component,
+ event::EventReader,
+ query::{Added, With},
+ schedule::IntoSystemConfigs,
+ system::Query,
+};
+use derive_more::{Deref, DerefMut};
+use tokio::sync::mpsc;
+
+use crate::{
+ PlayerInfo,
+ chat::{ChatPacket, ChatReceivedEvent},
+ disconnect::DisconnectEvent,
+ packet::game::{
+ AddPlayerEvent, DeathEvent, KeepAliveEvent, ReceivePacketEvent, RemovePlayerEvent,
+ UpdatePlayerEvent,
+ },
+};
+
+// (for contributors):
+// HOW TO ADD A NEW (packet based) EVENT:
+// - Add it as an ECS event first:
+// - Make a struct that contains an entity field and some data fields (look
+// in packet/game/events.rs for examples. These structs should always have
+// their names end with "Event".
+// - (the `entity` field is the local player entity that's receiving the
+// event)
+// - In the GamePacketHandler, you always have a `player` field that you can
+// use.
+// - Add the event struct in PacketPlugin::build
+// - (in the `impl Plugin for PacketPlugin`)
+// - To get the event writer, you have to get an EventWriter<ThingEvent>.
+// Look at other packets in packet/game/mod.rs for examples.
+//
+// At this point, you've created a new ECS event. That's annoying for bots to
+// use though, so you might wanna add it to the Event enum too:
+// - In this file, add a new variant to that Event enum with the same name
+// as your event (without the "Event" suffix).
+// - Create a new system function like the other ones here, and put that
+// system function in the `impl Plugin for EventsPlugin`
+
+/// Something that happened in-game, such as a tick passing or chat message
+/// being sent.
+///
+/// Note: Events are sent before they're processed, so for example game ticks
+/// happen at the beginning of a tick before anything has happened.
+#[derive(Debug, Clone)]
+pub enum Event {
+ /// Happens right after the bot switches into the Game state, but before
+ /// it's actually spawned. This can be useful for setting the client
+ /// information with `Client::set_client_information`, so the packet
+ /// doesn't have to be sent twice.
+ ///
+ /// You may want to use [`Event::Login`] instead to wait for the bot to be
+ /// in the world.
+ Init,
+ /// The client is now in the world. Fired when we receive a login packet.
+ Login,
+ /// A chat message was sent in the game chat.
+ Chat(ChatPacket),
+ /// Happens 20 times per second, but only when the world is loaded.
+ Tick,
+ /// We received a packet from the server.
+ ///
+ /// ```
+ /// # use azalea_client::Event;
+ /// # use azalea_protocol::packets::game::ClientboundGamePacket;
+ /// # async fn example(event: Event) {
+ /// # match event {
+ /// Event::Packet(packet) => match *packet {
+ /// ClientboundGamePacket::Login(_) => {
+ /// println!("login packet");
+ /// }
+ /// _ => {}
+ /// },
+ /// # _ => {}
+ /// # }
+ /// # }
+ /// ```
+ Packet(Arc<ClientboundGamePacket>),
+ /// A player joined the game (or more specifically, was added to the tab
+ /// list).
+ AddPlayer(PlayerInfo),
+ /// A player left the game (or maybe is still in the game and was just
+ /// removed from the tab list).
+ RemovePlayer(PlayerInfo),
+ /// A player was updated in the tab list (gamemode, display
+ /// name, or latency changed).
+ UpdatePlayer(PlayerInfo),
+ /// The client player died in-game.
+ Death(Option<Arc<ClientboundPlayerCombatKill>>),
+ /// A `KeepAlive` packet was sent by the server.
+ KeepAlive(u64),
+ /// The client disconnected from the server.
+ Disconnect(Option<FormattedText>),
+}
+
+/// A component that contains an event sender for events that are only
+/// received by local players. The receiver for this is returned by
+/// [`Client::start_client`].
+///
+/// [`Client::start_client`]: crate::Client::start_client
+#[derive(Component, Deref, DerefMut)]
+pub struct LocalPlayerEvents(pub mpsc::UnboundedSender<Event>);
+
+pub struct EventsPlugin;
+impl Plugin for EventsPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_systems(
+ Update,
+ (
+ chat_listener,
+ login_listener,
+ packet_listener,
+ add_player_listener,
+ update_player_listener,
+ remove_player_listener,
+ keepalive_listener,
+ death_listener,
+ disconnect_listener,
+ ),
+ )
+ .add_systems(
+ PreUpdate,
+ init_listener.before(crate::packet::game::process_packet_events),
+ )
+ .add_systems(GameTick, tick_listener);
+ }
+}
+
+// when LocalPlayerEvents is added, it means the client just started
+pub fn init_listener(query: Query<&LocalPlayerEvents, Added<LocalPlayerEvents>>) {
+ for local_player_events in &query {
+ let _ = local_player_events.send(Event::Init);
+ }
+}
+
+// when MinecraftEntityId is added, it means the player is now in the world
+pub fn login_listener(query: Query<&LocalPlayerEvents, Added<MinecraftEntityId>>) {
+ for local_player_events in &query {
+ let _ = local_player_events.send(Event::Login);
+ }
+}
+
+pub fn chat_listener(query: Query<&LocalPlayerEvents>, mut events: EventReader<ChatReceivedEvent>) {
+ for event in events.read() {
+ let local_player_events = query
+ .get(event.entity)
+ .expect("Non-local entities shouldn't be able to receive chat events");
+ let _ = local_player_events.send(Event::Chat(event.packet.clone()));
+ }
+}
+
+// only tick if we're in a world
+pub fn tick_listener(query: Query<&LocalPlayerEvents, With<InstanceName>>) {
+ for local_player_events in &query {
+ let _ = local_player_events.send(Event::Tick);
+ }
+}
+
+pub fn packet_listener(
+ query: Query<&LocalPlayerEvents>,
+ mut events: EventReader<ReceivePacketEvent>,
+) {
+ for event in events.read() {
+ let local_player_events = query
+ .get(event.entity)
+ .expect("Non-local entities shouldn't be able to receive packet events");
+ let _ = local_player_events.send(Event::Packet(event.packet.clone()));
+ }
+}
+
+pub fn add_player_listener(
+ query: Query<&LocalPlayerEvents>,
+ mut events: EventReader<AddPlayerEvent>,
+) {
+ for event in events.read() {
+ let local_player_events = query
+ .get(event.entity)
+ .expect("Non-local entities shouldn't be able to receive add player events");
+ let _ = local_player_events.send(Event::AddPlayer(event.info.clone()));
+ }
+}
+
+pub fn update_player_listener(
+ query: Query<&LocalPlayerEvents>,
+ mut events: EventReader<UpdatePlayerEvent>,
+) {
+ for event in events.read() {
+ let local_player_events = query
+ .get(event.entity)
+ .expect("Non-local entities shouldn't be able to receive update player events");
+ let _ = local_player_events.send(Event::UpdatePlayer(event.info.clone()));
+ }
+}
+
+pub fn remove_player_listener(
+ query: Query<&LocalPlayerEvents>,
+ mut events: EventReader<RemovePlayerEvent>,
+) {
+ for event in events.read() {
+ let local_player_events = query
+ .get(event.entity)
+ .expect("Non-local entities shouldn't be able to receive remove player events");
+ let _ = local_player_events.send(Event::RemovePlayer(event.info.clone()));
+ }
+}
+
+pub fn death_listener(query: Query<&LocalPlayerEvents>, mut events: EventReader<DeathEvent>) {
+ for event in events.read() {
+ if let Ok(local_player_events) = query.get(event.entity) {
+ let _ = local_player_events.send(Event::Death(event.packet.clone().map(|p| p.into())));
+ }
+ }
+}
+
+/// Send the "Death" event for [`LocalEntity`]s that died with no reason.
+pub fn dead_component_listener(query: Query<&LocalPlayerEvents, Added<Dead>>) {
+ for local_player_events in &query {
+ local_player_events.send(Event::Death(None)).unwrap();
+ }
+}
+
+pub fn keepalive_listener(
+ query: Query<&LocalPlayerEvents>,
+ mut events: EventReader<KeepAliveEvent>,
+) {
+ for event in events.read() {
+ let local_player_events = query
+ .get(event.entity)
+ .expect("Non-local entities shouldn't be able to receive keepalive events");
+ let _ = local_player_events.send(Event::KeepAlive(event.id));
+ }
+}
+
+pub fn disconnect_listener(
+ query: Query<&LocalPlayerEvents>,
+ mut events: EventReader<DisconnectEvent>,
+) {
+ for event in events.read() {
+ if let Ok(local_player_events) = query.get(event.entity) {
+ let _ = local_player_events.send(Event::Disconnect(event.reason.clone()));
+ }
+ }
+}
diff --git a/azalea-client/src/plugins/interact.rs b/azalea-client/src/plugins/interact.rs
new file mode 100644
index 00000000..1a344cc8
--- /dev/null
+++ b/azalea-client/src/plugins/interact.rs
@@ -0,0 +1,370 @@
+use std::ops::AddAssign;
+
+use azalea_block::BlockState;
+use azalea_core::{
+ block_hit_result::BlockHitResult,
+ direction::Direction,
+ game_type::GameMode,
+ position::{BlockPos, Vec3},
+};
+use azalea_entity::{
+ Attributes, EyeHeight, LocalEntity, LookDirection, Position, clamp_look_direction, view_vector,
+};
+use azalea_inventory::{ItemStack, ItemStackData, components};
+use azalea_physics::clip::{BlockShapeType, ClipContext, FluidPickType};
+use azalea_protocol::packets::game::{
+ s_interact::InteractionHand,
+ s_swing::ServerboundSwing,
+ s_use_item_on::{BlockHit, ServerboundUseItemOn},
+};
+use azalea_world::{Instance, InstanceContainer, InstanceName};
+use bevy_app::{App, Plugin, Update};
+use bevy_ecs::{
+ component::Component,
+ entity::Entity,
+ event::{Event, EventReader, EventWriter},
+ query::{Changed, With},
+ schedule::IntoSystemConfigs,
+ system::{Commands, Query, Res},
+};
+use derive_more::{Deref, DerefMut};
+use tracing::warn;
+
+use super::packet::game::handle_outgoing_packets;
+use crate::{
+ Client,
+ attack::handle_attack_event,
+ inventory::{Inventory, InventorySet},
+ local_player::{LocalGameMode, PermissionLevel, PlayerAbilities},
+ movement::MoveEventsSet,
+ packet::game::SendPacketEvent,
+ respawn::perform_respawn,
+};
+
+/// A plugin that allows clients to interact with blocks in the world.
+pub struct InteractPlugin;
+impl Plugin for InteractPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_event::<BlockInteractEvent>()
+ .add_event::<SwingArmEvent>()
+ .add_systems(
+ Update,
+ (
+ (
+ update_hit_result_component.after(clamp_look_direction),
+ handle_block_interact_event,
+ handle_swing_arm_event,
+ )
+ .before(handle_outgoing_packets)
+ .after(InventorySet)
+ .after(perform_respawn)
+ .after(handle_attack_event)
+ .chain(),
+ update_modifiers_for_held_item
+ .after(InventorySet)
+ .after(MoveEventsSet),
+ ),
+ );
+ }
+}
+
+impl Client {
+ /// Right click a block. The behavior of this depends on the target block,
+ /// and it'll either place the block you're holding in your hand or use the
+ /// block you clicked (like toggling a lever).
+ ///
+ /// Note that this may trigger anticheats as it doesn't take into account
+ /// whether you're actually looking at the block.
+ pub fn block_interact(&mut self, position: BlockPos) {
+ self.ecs.lock().send_event(BlockInteractEvent {
+ entity: self.entity,
+ position,
+ });
+ }
+}
+
+/// Right click a block. The behavior of this depends on the target block,
+/// and it'll either place the block you're holding in your hand or use the
+/// block you clicked (like toggling a lever).
+#[derive(Event)]
+pub struct BlockInteractEvent {
+ /// The local player entity that's opening the container.
+ pub entity: Entity,
+ /// The coordinates of the container.
+ pub position: BlockPos,
+}
+
+/// A component that contains the number of changes this client has made to
+/// blocks.
+#[derive(Component, Copy, Clone, Debug, Default, Deref)]
+pub struct CurrentSequenceNumber(u32);
+
+impl AddAssign<u32> for CurrentSequenceNumber {
+ fn add_assign(&mut self, rhs: u32) {
+ self.0 += rhs;
+ }
+}
+
+/// A component that contains the block that the player is currently looking at.
+#[derive(Component, Clone, Debug, Deref, DerefMut)]
+pub struct HitResultComponent(BlockHitResult);
+
+pub fn handle_block_interact_event(
+ mut events: EventReader<BlockInteractEvent>,
+ mut query: Query<(Entity, &mut CurrentSequenceNumber, &HitResultComponent)>,
+ mut send_packet_events: EventWriter<SendPacketEvent>,
+) {
+ for event in events.read() {
+ let Ok((entity, mut sequence_number, hit_result)) = query.get_mut(event.entity) else {
+ warn!("Sent BlockInteractEvent for entity that doesn't have the required components");
+ continue;
+ };
+
+ // TODO: check to make sure we're within the world border
+
+ *sequence_number += 1;
+
+ // minecraft also does the interaction client-side (so it looks like clicking a
+ // button is instant) but we don't really need that
+
+ // the block_hit data will depend on whether we're looking at the block and
+ // whether we can reach it
+
+ let block_hit = if hit_result.block_pos == event.position {
+ // we're looking at the block :)
+ BlockHit {
+ block_pos: hit_result.block_pos,
+ direction: hit_result.direction,
+ location: hit_result.location,
+ inside: hit_result.inside,
+ world_border: hit_result.world_border,
+ }
+ } else {
+ // we're not looking at the block, so make up some numbers
+ BlockHit {
+ block_pos: event.position,
+ direction: Direction::Up,
+ location: event.position.center(),
+ inside: false,
+ world_border: false,
+ }
+ };
+
+ send_packet_events.send(SendPacketEvent::new(
+ entity,
+ ServerboundUseItemOn {
+ hand: InteractionHand::MainHand,
+ block_hit,
+ sequence: sequence_number.0,
+ },
+ ));
+ }
+}
+
+#[allow(clippy::type_complexity)]
+pub fn update_hit_result_component(
+ mut commands: Commands,
+ mut query: Query<(
+ Entity,
+ Option<&mut HitResultComponent>,
+ &LocalGameMode,
+ &Position,
+ &EyeHeight,
+ &LookDirection,
+ &InstanceName,
+ )>,
+ instance_container: Res<InstanceContainer>,
+) {
+ for (entity, hit_result_ref, game_mode, position, eye_height, look_direction, world_name) in
+ &mut query
+ {
+ let pick_range = if game_mode.current == GameMode::Creative {
+ 6.
+ } else {
+ 4.5
+ };
+ let eye_position = Vec3 {
+ x: position.x,
+ y: position.y + **eye_height as f64,
+ z: position.z,
+ };
+
+ let Some(instance_lock) = instance_container.get(world_name) else {
+ continue;
+ };
+ let instance = instance_lock.read();
+
+ let hit_result = pick(look_direction, &eye_position, &instance.chunks, pick_range);
+ if let Some(mut hit_result_ref) = hit_result_ref {
+ **hit_result_ref = hit_result;
+ } else {
+ commands
+ .entity(entity)
+ .insert(HitResultComponent(hit_result));
+ }
+ }
+}
+
+/// Get the block that a player would be looking at if their eyes were at the
+/// given direction and position.
+///
+/// If you need to get the block the player is looking at right now, use
+/// [`HitResultComponent`].
+pub fn pick(
+ look_direction: &LookDirection,
+ eye_position: &Vec3,
+ chunks: &azalea_world::ChunkStorage,
+ pick_range: f64,
+) -> BlockHitResult {
+ let view_vector = view_vector(look_direction);
+ let end_position = eye_position + &(view_vector * pick_range);
+ azalea_physics::clip::clip(
+ chunks,
+ ClipContext {
+ from: *eye_position,
+ to: end_position,
+ block_shape_type: BlockShapeType::Outline,
+ fluid_pick_type: FluidPickType::None,
+ },
+ )
+}
+
+/// Whether we can't interact with the block, based on your gamemode. If
+/// this is false, then we can interact with the block.
+///
+/// Passing the inventory, block position, and instance is necessary for the
+/// adventure mode check.
+pub fn check_is_interaction_restricted(
+ instance: &Instance,
+ block_pos: &BlockPos,
+ game_mode: &GameMode,
+ inventory: &Inventory,
+) -> bool {
+ match game_mode {
+ GameMode::Adventure => {
+ // vanilla checks for abilities.mayBuild here but servers have no
+ // way of modifying that
+
+ let held_item = inventory.held_item();
+ match &held_item {
+ ItemStack::Present(item) => {
+ let block = instance.chunks.get_block_state(block_pos);
+ let Some(block) = block else {
+ // block isn't loaded so just say that it is restricted
+ return true;
+ };
+ check_block_can_be_broken_by_item_in_adventure_mode(item, &block)
+ }
+ _ => true,
+ }
+ }
+ GameMode::Spectator => true,
+ _ => false,
+ }
+}
+
+/// Check if the item has the `CanDestroy` tag for the block.
+pub fn check_block_can_be_broken_by_item_in_adventure_mode(
+ item: &ItemStackData,
+ _block: &BlockState,
+) -> bool {
+ // minecraft caches the last checked block but that's kind of an unnecessary
+ // optimization and makes the code too complicated
+
+ if !item.components.has::<components::CanBreak>() {
+ // no CanDestroy tag
+ return false;
+ };
+
+ false
+
+ // for block_predicate in can_destroy {
+ // // TODO
+ // // defined in BlockPredicateArgument.java
+ // }
+
+ // true
+}
+
+pub fn can_use_game_master_blocks(
+ abilities: &PlayerAbilities,
+ permission_level: &PermissionLevel,
+) -> bool {
+ abilities.instant_break && **permission_level >= 2
+}
+
+/// Swing your arm. This is purely a visual effect and won't interact with
+/// anything in the world.
+#[derive(Event)]
+pub struct SwingArmEvent {
+ pub entity: Entity,
+}
+pub fn handle_swing_arm_event(
+ mut events: EventReader<SwingArmEvent>,
+ mut send_packet_events: EventWriter<SendPacketEvent>,
+) {
+ for event in events.read() {
+ send_packet_events.send(SendPacketEvent::new(
+ event.entity,
+ ServerboundSwing {
+ hand: InteractionHand::MainHand,
+ },
+ ));
+ }
+}
+
+#[allow(clippy::type_complexity)]
+fn update_modifiers_for_held_item(
+ mut query: Query<(&mut Attributes, &Inventory), (With<LocalEntity>, Changed<Inventory>)>,
+) {
+ for (mut attributes, inventory) in &mut query {
+ let held_item = inventory.held_item();
+
+ use azalea_registry::Item;
+ let added_attack_speed = match held_item.kind() {
+ Item::WoodenSword => -2.4,
+ Item::WoodenShovel => -3.0,
+ Item::WoodenPickaxe => -2.8,
+ Item::WoodenAxe => -3.2,
+ Item::WoodenHoe => -3.0,
+
+ Item::StoneSword => -2.4,
+ Item::StoneShovel => -3.0,
+ Item::StonePickaxe => -2.8,
+ Item::StoneAxe => -3.2,
+ Item::StoneHoe => -2.0,
+
+ Item::GoldenSword => -2.4,
+ Item::GoldenShovel => -3.0,
+ Item::GoldenPickaxe => -2.8,
+ Item::GoldenAxe => -3.0,
+ Item::GoldenHoe => -3.0,
+
+ Item::IronSword => -2.4,
+ Item::IronShovel => -3.0,
+ Item::IronPickaxe => -2.8,
+ Item::IronAxe => -3.1,
+ Item::IronHoe => -1.0,
+
+ Item::DiamondSword => -2.4,
+ Item::DiamondShovel => -3.0,
+ Item::DiamondPickaxe => -2.8,
+ Item::DiamondAxe => -3.0,
+ Item::DiamondHoe => 0.0,
+
+ Item::NetheriteSword => -2.4,
+ Item::NetheriteShovel => -3.0,
+ Item::NetheritePickaxe => -2.8,
+ Item::NetheriteAxe => -3.0,
+ Item::NetheriteHoe => 0.0,
+
+ Item::Trident => -2.9,
+ _ => 0.,
+ };
+ attributes
+ .attack_speed
+ .insert(azalea_entity::attributes::base_attack_speed_modifier(
+ added_attack_speed,
+ ));
+ }
+}
diff --git a/azalea-client/src/plugins/inventory.rs b/azalea-client/src/plugins/inventory.rs
new file mode 100644
index 00000000..3f823ca2
--- /dev/null
+++ b/azalea-client/src/plugins/inventory.rs
@@ -0,0 +1,766 @@
+use std::collections::{HashMap, HashSet};
+
+use azalea_chat::FormattedText;
+pub use azalea_inventory::*;
+use azalea_inventory::{
+ item::MaxStackSizeExt,
+ operations::{
+ ClickOperation, CloneClick, PickupAllClick, PickupClick, QuickCraftKind, QuickCraftStatus,
+ QuickCraftStatusKind, QuickMoveClick, ThrowClick,
+ },
+};
+use azalea_protocol::packets::game::{
+ s_container_click::ServerboundContainerClick, s_container_close::ServerboundContainerClose,
+ s_set_carried_item::ServerboundSetCarriedItem,
+};
+use azalea_registry::MenuKind;
+use bevy_app::{App, Plugin, Update};
+use bevy_ecs::{
+ component::Component,
+ entity::Entity,
+ event::EventReader,
+ prelude::{Event, EventWriter},
+ schedule::{IntoSystemConfigs, SystemSet},
+ system::Query,
+};
+use tracing::warn;
+
+use super::packet::game::handle_outgoing_packets;
+use crate::{
+ Client, local_player::PlayerAbilities, packet::game::SendPacketEvent, respawn::perform_respawn,
+};
+
+pub struct InventoryPlugin;
+impl Plugin for InventoryPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_event::<ClientSideCloseContainerEvent>()
+ .add_event::<MenuOpenedEvent>()
+ .add_event::<CloseContainerEvent>()
+ .add_event::<ContainerClickEvent>()
+ .add_event::<SetContainerContentEvent>()
+ .add_event::<SetSelectedHotbarSlotEvent>()
+ .add_systems(
+ Update,
+ (
+ handle_set_selected_hotbar_slot_event,
+ handle_menu_opened_event,
+ handle_set_container_content_event,
+ handle_container_click_event,
+ handle_container_close_event.before(handle_outgoing_packets),
+ handle_client_side_close_container_event,
+ )
+ .chain()
+ .in_set(InventorySet)
+ .before(perform_respawn),
+ );
+ }
+}
+
+#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
+pub struct InventorySet;
+
+impl Client {
+ /// Return the menu that is currently open. If no menu is open, this will
+ /// have the player's inventory.
+ pub fn menu(&self) -> Menu {
+ let mut ecs = self.ecs.lock();
+ let inventory = self.query::<&Inventory>(&mut ecs);
+ inventory.menu().clone()
+ }
+}
+
+/// A component present on all local players that have an inventory.
+#[derive(Component, Debug, Clone)]
+pub struct Inventory {
+ /// A component that contains the player's inventory menu. This is
+ /// guaranteed to be a `Menu::Player`.
+ ///
+ /// We keep it as a [`Menu`] since `Menu` has some useful functions that
+ /// bare [`azalea_inventory::Player`] doesn't have.
+ pub inventory_menu: azalea_inventory::Menu,
+
+ /// The ID of the container that's currently open. Its value is not
+ /// guaranteed to be anything specific, and may change every time you open a
+ /// container (unless it's 0, in which case it means that no container is
+ /// open).
+ pub id: i32,
+ /// The current container menu that the player has open. If no container is
+ /// open, this will be `None`.
+ pub container_menu: Option<azalea_inventory::Menu>,
+ /// The custom name of the menu that's currently open. This is Some when
+ /// `container_menu` is Some.
+ pub container_menu_title: Option<FormattedText>,
+ /// The item that is currently held by the cursor. `Slot::Empty` if nothing
+ /// is currently being held.
+ ///
+ /// This is different from [`Self::selected_hotbar_slot`], which is the
+ /// item that's selected in the hotbar.
+ pub carried: ItemStack,
+ /// An identifier used by the server to track client inventory desyncs. This
+ /// is sent on every container click, and it's only ever updated when the
+ /// server sends a new container update.
+ pub state_id: u32,
+
+ pub quick_craft_status: QuickCraftStatusKind,
+ pub quick_craft_kind: QuickCraftKind,
+ /// A set of the indexes of the slots that have been right clicked in
+ /// this "quick craft".
+ pub quick_craft_slots: HashSet<u16>,
+
+ /// The index of the item in the hotbar that's currently being held by the
+ /// player. This MUST be in the range 0..9 (not including 9).
+ ///
+ /// In a vanilla client this is changed by pressing the number keys or using
+ /// the scroll wheel.
+ pub selected_hotbar_slot: u8,
+}
+
+impl Inventory {
+ /// Returns a reference to the currently active menu. If a container is open
+ /// it'll return [`Self::container_menu`], otherwise
+ /// [`Self::inventory_menu`].
+ ///
+ /// Use [`Self::menu_mut`] if you need a mutable reference.
+ pub fn menu(&self) -> &azalea_inventory::Menu {
+ match &self.container_menu {
+ Some(menu) => menu,
+ _ => &self.inventory_menu,
+ }
+ }
+
+ /// Returns a mutable reference to the currently active menu. If a container
+ /// is open it'll return [`Self::container_menu`], otherwise
+ /// [`Self::inventory_menu`].
+ ///
+ /// Use [`Self::menu`] if you don't need a mutable reference.
+ pub fn menu_mut(&mut self) -> &mut azalea_inventory::Menu {
+ match &mut self.container_menu {
+ Some(menu) => menu,
+ _ => &mut self.inventory_menu,
+ }
+ }
+
+ /// Modify the inventory as if the given operation was performed on it.
+ pub fn simulate_click(
+ &mut self,
+ operation: &ClickOperation,
+ player_abilities: &PlayerAbilities,
+ ) {
+ if let ClickOperation::QuickCraft(quick_craft) = operation {
+ let last_quick_craft_status_tmp = self.quick_craft_status.clone();
+ self.quick_craft_status = last_quick_craft_status_tmp.clone();
+ let last_quick_craft_status = last_quick_craft_status_tmp;
+
+ // no carried item, reset
+ if self.carried.is_empty() {
+ return self.reset_quick_craft();
+ }
+ // if we were starting or ending, or now we aren't ending and the status
+ // changed, reset
+ if (last_quick_craft_status == QuickCraftStatusKind::Start
+ || last_quick_craft_status == QuickCraftStatusKind::End
+ || self.quick_craft_status != QuickCraftStatusKind::End)
+ && (self.quick_craft_status != last_quick_craft_status)
+ {
+ return self.reset_quick_craft();
+ }
+ if self.quick_craft_status == QuickCraftStatusKind::Start {
+ self.quick_craft_kind = quick_craft.kind.clone();
+ if self.quick_craft_kind == QuickCraftKind::Middle && player_abilities.instant_break
+ {
+ self.quick_craft_status = QuickCraftStatusKind::Add;
+ self.quick_craft_slots.clear();
+ } else {
+ self.reset_quick_craft();
+ }
+ return;
+ }
+ if let QuickCraftStatus::Add { slot } = quick_craft.status {
+ let slot_item = self.menu().slot(slot as usize);
+ if let Some(slot_item) = slot_item {
+ if let ItemStack::Present(carried) = &self.carried {
+ // minecraft also checks slot.may_place(carried) and
+ // menu.can_drag_to(slot)
+ // but they always return true so they're not relevant for us
+ if can_item_quick_replace(slot_item, &self.carried, true)
+ && (self.quick_craft_kind == QuickCraftKind::Right
+ || carried.count as usize > self.quick_craft_slots.len())
+ {
+ self.quick_craft_slots.insert(slot);
+ }
+ }
+ }
+ return;
+ }
+ if self.quick_craft_status == QuickCraftStatusKind::End {
+ if !self.quick_craft_slots.is_empty() {
+ if self.quick_craft_slots.len() == 1 {
+ // if we only clicked one slot, then turn this
+ // QuickCraftClick into a PickupClick
+ let slot = *self.quick_craft_slots.iter().next().unwrap();
+ self.reset_quick_craft();
+ self.simulate_click(
+ &match self.quick_craft_kind {
+ QuickCraftKind::Left => {
+ PickupClick::Left { slot: Some(slot) }.into()
+ }
+ QuickCraftKind::Right => {
+ PickupClick::Left { slot: Some(slot) }.into()
+ }
+ QuickCraftKind::Middle => {
+ // idk just do nothing i guess
+ return;
+ }
+ },
+ player_abilities,
+ );
+ return;
+ }
+
+ let ItemStack::Present(mut carried) = self.carried.clone() else {
+ // this should never happen
+ return self.reset_quick_craft();
+ };
+
+ let mut carried_count = carried.count;
+ let mut quick_craft_slots_iter = self.quick_craft_slots.iter();
+
+ loop {
+ let mut slot: &ItemStack;
+ let mut slot_index: u16;
+ let mut item_stack: &ItemStack;
+
+ loop {
+ let Some(&next_slot) = quick_craft_slots_iter.next() else {
+ carried.count = carried_count;
+ self.carried = ItemStack::Present(carried);
+ return self.reset_quick_craft();
+ };
+
+ slot = self.menu().slot(next_slot as usize).unwrap();
+ slot_index = next_slot;
+ item_stack = &self.carried;
+
+ if slot.is_present()
+ && can_item_quick_replace(slot, item_stack, true)
+ // this always returns true in most cases
+ // && slot.may_place(item_stack)
+ && (
+ self.quick_craft_kind == QuickCraftKind::Middle
+ || item_stack.count() >= self.quick_craft_slots.len() as i32
+ )
+ {
+ break;
+ }
+ }
+
+ // get the ItemStackData for the slot
+ let ItemStack::Present(slot) = slot else {
+ unreachable!("the loop above requires the slot to be present to break")
+ };
+
+ // if self.can_drag_to(slot) {
+ let mut new_carried = carried.clone();
+ let slot_item_count = slot.count;
+ get_quick_craft_slot_count(
+ &self.quick_craft_slots,
+ &self.quick_craft_kind,
+ &mut new_carried,
+ slot_item_count,
+ );
+ let max_stack_size = i32::min(
+ new_carried.kind.max_stack_size(),
+ i32::min(
+ new_carried.kind.max_stack_size(),
+ slot.kind.max_stack_size(),
+ ),
+ );
+ if new_carried.count > max_stack_size {
+ new_carried.count = max_stack_size;
+ }
+
+ carried_count -= new_carried.count - slot_item_count;
+ // we have to inline self.menu_mut() here to avoid the borrow checker
+ // complaining
+ let menu = match &mut self.container_menu {
+ Some(menu) => menu,
+ _ => &mut self.inventory_menu,
+ };
+ *menu.slot_mut(slot_index as usize).unwrap() =
+ ItemStack::Present(new_carried);
+ }
+ }
+ } else {
+ return self.reset_quick_craft();
+ }
+ }
+ // the quick craft status should always be in start if we're not in quick craft
+ // mode
+ if self.quick_craft_status != QuickCraftStatusKind::Start {
+ return self.reset_quick_craft();
+ }
+
+ match operation {
+ // left clicking outside inventory
+ ClickOperation::Pickup(PickupClick::Left { slot: None }) => {
+ if self.carried.is_present() {
+ // vanilla has `player.drop`s but they're only used
+ // server-side
+ // they're included as comments here in case you want to adapt this for a server
+ // implementation
+
+ // player.drop(self.carried, true);
+ self.carried = ItemStack::Empty;
+ }
+ }
+ ClickOperation::Pickup(PickupClick::Right { slot: None }) => {
+ if self.carried.is_present() {
+ let _item = self.carried.split(1);
+ // player.drop(item, true);
+ }
+ }
+ ClickOperation::Pickup(
+ PickupClick::Left { slot: Some(slot) } | PickupClick::Right { slot: Some(slot) },
+ ) => {
+ let Some(slot_item) = self.menu().slot(*slot as usize) else {
+ return;
+ };
+ let carried = &self.carried;
+ // vanilla does a check called tryItemClickBehaviourOverride
+ // here
+ // i don't understand it so i didn't implement it
+ match slot_item {
+ ItemStack::Empty => if carried.is_present() {},
+ ItemStack::Present(_) => todo!(),
+ }
+ }
+ ClickOperation::QuickMove(
+ QuickMoveClick::Left { slot } | QuickMoveClick::Right { slot },
+ ) => {
+ // in vanilla it also tests if QuickMove has a slot index of -999
+ // but i don't think that's ever possible so it's not covered here
+ loop {
+ let new_slot_item = self.menu_mut().quick_move_stack(*slot as usize);
+ let slot_item = self.menu().slot(*slot as usize).unwrap();
+ if new_slot_item.is_empty() || slot_item != &new_slot_item {
+ break;
+ }
+ }
+ }
+ ClickOperation::Swap(s) => {
+ let source_slot_index = s.source_slot as usize;
+ let target_slot_index = s.target_slot as usize;
+
+ let Some(source_slot) = self.menu().slot(source_slot_index) else {
+ return;
+ };
+ let Some(target_slot) = self.menu().slot(target_slot_index) else {
+ return;
+ };
+ if source_slot.is_empty() && target_slot.is_empty() {
+ return;
+ }
+
+ if target_slot.is_empty() {
+ if self.menu().may_pickup(source_slot_index) {
+ let source_slot = source_slot.clone();
+ let target_slot = self.menu_mut().slot_mut(target_slot_index).unwrap();
+ *target_slot = source_slot;
+ }
+ } else if source_slot.is_empty() {
+ let ItemStack::Present(target_item) = target_slot else {
+ unreachable!("target slot is not empty but is not present");
+ };
+ if self.menu().may_place(source_slot_index, target_item) {
+ // get the target_item but mutable
+ let source_max_stack_size = self.menu().max_stack_size(source_slot_index);
+
+ let target_slot = self.menu_mut().slot_mut(target_slot_index).unwrap();
+ let new_source_slot = target_slot.split(source_max_stack_size);
+ *self.menu_mut().slot_mut(source_slot_index).unwrap() = new_source_slot;
+ }
+ } else if self.menu().may_pickup(source_slot_index) {
+ let ItemStack::Present(target_item) = target_slot else {
+ unreachable!("target slot is not empty but is not present");
+ };
+ if self.menu().may_place(source_slot_index, target_item) {
+ let source_max_stack = self.menu().max_stack_size(source_slot_index);
+ if target_slot.count() > source_max_stack as i32 {
+ // if there's more than the max stack size in the target slot
+
+ let target_slot = self.menu_mut().slot_mut(target_slot_index).unwrap();
+ let new_source_slot = target_slot.split(source_max_stack);
+ *self.menu_mut().slot_mut(source_slot_index).unwrap() = new_source_slot;
+ // if !self.inventory_menu.add(new_source_slot) {
+ // player.drop(new_source_slot, true);
+ // }
+ } else {
+ // normal swap
+ let new_target_slot = source_slot.clone();
+ let new_source_slot = target_slot.clone();
+
+ let target_slot = self.menu_mut().slot_mut(target_slot_index).unwrap();
+ *target_slot = new_target_slot;
+
+ let source_slot = self.menu_mut().slot_mut(source_slot_index).unwrap();
+ *source_slot = new_source_slot;
+ }
+ }
+ }
+ }
+ ClickOperation::Clone(CloneClick { slot }) => {
+ if !player_abilities.instant_break || self.carried.is_present() {
+ return;
+ }
+ let Some(source_slot) = self.menu().slot(*slot as usize) else {
+ return;
+ };
+ let ItemStack::Present(source_item) = source_slot else {
+ return;
+ };
+ let mut new_carried = source_item.clone();
+ new_carried.count = new_carried.kind.max_stack_size();
+ self.carried = ItemStack::Present(new_carried);
+ }
+ ClickOperation::Throw(c) => {
+ if self.carried.is_present() {
+ return;
+ }
+
+ let (ThrowClick::Single { slot: slot_index }
+ | ThrowClick::All { slot: slot_index }) = c;
+ let slot_index = *slot_index as usize;
+
+ let Some(slot) = self.menu_mut().slot_mut(slot_index) else {
+ return;
+ };
+ let ItemStack::Present(slot_item) = slot else {
+ return;
+ };
+
+ let dropping_count = match c {
+ ThrowClick::Single { .. } => 1,
+ ThrowClick::All { .. } => slot_item.count,
+ };
+
+ let _dropping = slot_item.split(dropping_count as u32);
+ // player.drop(dropping, true);
+ }
+ ClickOperation::PickupAll(PickupAllClick {
+ slot: source_slot_index,
+ reversed,
+ }) => {
+ let source_slot_index = *source_slot_index as usize;
+
+ let source_slot = self.menu().slot(source_slot_index).unwrap();
+ let target_slot = self.carried.clone();
+
+ if target_slot.is_empty()
+ || (source_slot.is_present() && self.menu().may_pickup(source_slot_index))
+ {
+ return;
+ }
+
+ let ItemStack::Present(target_slot_item) = &target_slot else {
+ unreachable!("target slot is not empty but is not present");
+ };
+
+ for round in 0..2 {
+ let iterator: Box<dyn Iterator<Item = usize>> = if *reversed {
+ Box::new((0..self.menu().len()).rev())
+ } else {
+ Box::new(0..self.menu().len())
+ };
+
+ for i in iterator {
+ if target_slot_item.count < target_slot_item.kind.max_stack_size() {
+ let checking_slot = self.menu().slot(i).unwrap();
+ if let ItemStack::Present(checking_item) = checking_slot {
+ if can_item_quick_replace(checking_slot, &target_slot, true)
+ && self.menu().may_pickup(i)
+ && (round != 0
+ || checking_item.count
+ != checking_item.kind.max_stack_size())
+ {
+ // get the checking_slot and checking_item again but mutable
+ let checking_slot = self.menu_mut().slot_mut(i).unwrap();
+
+ let taken_item =
+ checking_slot.split(checking_slot.count() as u32);
+
+ // now extend the carried item
+ let target_slot = &mut self.carried;
+ let ItemStack::Present(target_slot_item) = target_slot else {
+ unreachable!("target slot is not empty but is not present");
+ };
+ target_slot_item.count += taken_item.count();
+ }
+ }
+ }
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn reset_quick_craft(&mut self) {
+ self.quick_craft_status = QuickCraftStatusKind::Start;
+ self.quick_craft_slots.clear();
+ }
+
+ /// Get the item in the player's hotbar that is currently being held.
+ pub fn held_item(&self) -> ItemStack {
+ let inventory = &self.inventory_menu;
+ let hotbar_items = &inventory.slots()[inventory.hotbar_slots_range()];
+ hotbar_items[self.selected_hotbar_slot as usize].clone()
+ }
+}
+
+fn can_item_quick_replace(
+ target_slot: &ItemStack,
+ item: &ItemStack,
+ ignore_item_count: bool,
+) -> bool {
+ let ItemStack::Present(target_slot) = target_slot else {
+ return false;
+ };
+ let ItemStack::Present(item) = item else {
+ // i *think* this is what vanilla does
+ // not 100% sure lol probably doesn't matter though
+ return false;
+ };
+
+ if !item.is_same_item_and_components(target_slot) {
+ return false;
+ }
+ let count = target_slot.count as u16
+ + if ignore_item_count {
+ 0
+ } else {
+ item.count as u16
+ };
+ count <= item.kind.max_stack_size() as u16
+}
+
+fn get_quick_craft_slot_count(
+ quick_craft_slots: &HashSet<u16>,
+ quick_craft_kind: &QuickCraftKind,
+ item: &mut ItemStackData,
+ slot_item_count: i32,
+) {
+ item.count = match quick_craft_kind {
+ QuickCraftKind::Left => item.count / quick_craft_slots.len() as i32,
+ QuickCraftKind::Right => 1,
+ QuickCraftKind::Middle => item.kind.max_stack_size(),
+ };
+ item.count += slot_item_count;
+}
+
+impl Default for Inventory {
+ fn default() -> Self {
+ Inventory {
+ inventory_menu: Menu::Player(azalea_inventory::Player::default()),
+ id: 0,
+ container_menu: None,
+ container_menu_title: None,
+ carried: ItemStack::Empty,
+ state_id: 0,
+ quick_craft_status: QuickCraftStatusKind::Start,
+ quick_craft_kind: QuickCraftKind::Middle,
+ quick_craft_slots: HashSet::new(),
+ selected_hotbar_slot: 0,
+ }
+ }
+}
+
+/// Sent from the server when a menu (like a chest or crafting table) was
+/// opened by the client.
+#[derive(Event, Debug)]
+pub struct MenuOpenedEvent {
+ pub entity: Entity,
+ pub window_id: i32,
+ pub menu_type: MenuKind,
+ pub title: FormattedText,
+}
+fn handle_menu_opened_event(
+ mut events: EventReader<MenuOpenedEvent>,
+ mut query: Query<&mut Inventory>,
+) {
+ for event in events.read() {
+ let mut inventory = query.get_mut(event.entity).unwrap();
+ inventory.id = event.window_id;
+ inventory.container_menu = Some(Menu::from_kind(event.menu_type));
+ inventory.container_menu_title = Some(event.title.clone());
+ }
+}
+
+/// Tell the server that we want to close a container.
+///
+/// Note that this is also sent when the client closes its own inventory, even
+/// though there is no packet for opening its inventory.
+#[derive(Event)]
+pub struct CloseContainerEvent {
+ pub entity: Entity,
+ /// The ID of the container to close. 0 for the player's inventory. If this
+ /// is not the same as the currently open inventory, nothing will happen.
+ pub id: i32,
+}
+fn handle_container_close_event(
+ query: Query<(Entity, &Inventory)>,
+ mut events: EventReader<CloseContainerEvent>,
+ mut client_side_events: EventWriter<ClientSideCloseContainerEvent>,
+ mut send_packet_events: EventWriter<SendPacketEvent>,
+) {
+ for event in events.read() {
+ let (entity, inventory) = query.get(event.entity).unwrap();
+ if event.id != inventory.id {
+ warn!(
+ "Tried to close container with ID {}, but the current container ID is {}",
+ event.id, inventory.id
+ );
+ continue;
+ }
+
+ send_packet_events.send(SendPacketEvent::new(
+ entity,
+ ServerboundContainerClose {
+ container_id: inventory.id,
+ },
+ ));
+ client_side_events.send(ClientSideCloseContainerEvent {
+ entity: event.entity,
+ });
+ }
+}
+
+/// Close a container without notifying the server.
+///
+/// Note that this also gets fired when we get a [`CloseContainerEvent`].
+#[derive(Event)]
+pub struct ClientSideCloseContainerEvent {
+ pub entity: Entity,
+}
+pub fn handle_client_side_close_container_event(
+ mut events: EventReader<ClientSideCloseContainerEvent>,
+ mut query: Query<&mut Inventory>,
+) {
+ for event in events.read() {
+ let mut inventory = query.get_mut(event.entity).unwrap();
+ inventory.container_menu = None;
+ inventory.id = 0;
+ inventory.container_menu_title = None;
+ }
+}
+
+#[derive(Event, Debug)]
+pub struct ContainerClickEvent {
+ pub entity: Entity,
+ pub window_id: i32,
+ pub operation: ClickOperation,
+}
+pub fn handle_container_click_event(
+ mut query: Query<(Entity, &mut Inventory)>,
+ mut events: EventReader<ContainerClickEvent>,
+ mut send_packet_events: EventWriter<SendPacketEvent>,
+) {
+ for event in events.read() {
+ let (entity, mut inventory) = query.get_mut(event.entity).unwrap();
+ if inventory.id != event.window_id {
+ warn!(
+ "Tried to click container with ID {}, but the current container ID is {}",
+ event.window_id, inventory.id
+ );
+ continue;
+ }
+
+ let menu = inventory.menu_mut();
+ let old_slots = menu.slots().clone();
+
+ // menu.click(&event.operation);
+
+ // see which slots changed after clicking and put them in the hashmap
+ // the server uses this to check if we desynced
+ let mut changed_slots: HashMap<u16, ItemStack> = HashMap::new();
+ for (slot_index, old_slot) in old_slots.iter().enumerate() {
+ let new_slot = &menu.slots()[slot_index];
+ if old_slot != new_slot {
+ changed_slots.insert(slot_index as u16, new_slot.clone());
+ }
+ }
+
+ send_packet_events.send(SendPacketEvent::new(
+ entity,
+ ServerboundContainerClick {
+ container_id: event.window_id,
+ state_id: inventory.state_id,
+ slot_num: event.operation.slot_num().map(|n| n as i16).unwrap_or(-999),
+ button_num: event.operation.button_num(),
+ click_type: event.operation.click_type(),
+ changed_slots,
+ carried_item: inventory.carried.clone(),
+ },
+ ));
+ }
+}
+
+/// Sent from the server when the contents of a container are replaced. Usually
+/// triggered by the `ContainerSetContent` packet.
+#[derive(Event)]
+pub struct SetContainerContentEvent {
+ pub entity: Entity,
+ pub slots: Vec<ItemStack>,
+ pub container_id: i32,
+}
+fn handle_set_container_content_event(
+ mut events: EventReader<SetContainerContentEvent>,
+ mut query: Query<&mut Inventory>,
+) {
+ for event in events.read() {
+ let mut inventory = query.get_mut(event.entity).unwrap();
+
+ if event.container_id != inventory.id {
+ warn!(
+ "Tried to set container content with ID {}, but the current container ID is {}",
+ event.container_id, inventory.id
+ );
+ continue;
+ }
+
+ let menu = inventory.menu_mut();
+ for (i, slot) in event.slots.iter().enumerate() {
+ if let Some(slot_mut) = menu.slot_mut(i) {
+ *slot_mut = slot.clone();
+ }
+ }
+ }
+}
+
+#[derive(Event)]
+pub struct SetSelectedHotbarSlotEvent {
+ pub entity: Entity,
+ /// The hotbar slot to select. This should be in the range 0..=8.
+ pub slot: u8,
+}
+fn handle_set_selected_hotbar_slot_event(
+ mut events: EventReader<SetSelectedHotbarSlotEvent>,
+ mut send_packet_events: EventWriter<SendPacketEvent>,
+ mut query: Query<&mut Inventory>,
+) {
+ for event in events.read() {
+ let mut inventory = query.get_mut(event.entity).unwrap();
+
+ // if the slot is already selected, don't send a packet
+ if inventory.selected_hotbar_slot == event.slot {
+ continue;
+ }
+
+ inventory.selected_hotbar_slot = event.slot;
+ send_packet_events.send(SendPacketEvent::new(
+ event.entity,
+ ServerboundSetCarriedItem {
+ slot: event.slot as u16,
+ },
+ ));
+ }
+}
diff --git a/azalea-client/src/plugins/mining.rs b/azalea-client/src/plugins/mining.rs
new file mode 100644
index 00000000..beb380b7
--- /dev/null
+++ b/azalea-client/src/plugins/mining.rs
@@ -0,0 +1,644 @@
+use azalea_block::{Block, BlockState, fluid_state::FluidState};
+use azalea_core::{direction::Direction, game_type::GameMode, position::BlockPos, tick::GameTick};
+use azalea_entity::{FluidOnEyes, Physics, mining::get_mine_progress};
+use azalea_inventory::ItemStack;
+use azalea_physics::PhysicsSet;
+use azalea_protocol::packets::game::s_player_action::{self, ServerboundPlayerAction};
+use azalea_world::{InstanceContainer, InstanceName};
+use bevy_app::{App, Plugin, Update};
+use bevy_ecs::prelude::*;
+use derive_more::{Deref, DerefMut};
+
+use crate::{
+ Client,
+ interact::{
+ CurrentSequenceNumber, HitResultComponent, SwingArmEvent, can_use_game_master_blocks,
+ check_is_interaction_restricted,
+ },
+ inventory::{Inventory, InventorySet},
+ local_player::{LocalGameMode, PermissionLevel, PlayerAbilities},
+ movement::MoveEventsSet,
+ packet::game::SendPacketEvent,
+};
+
+/// A plugin that allows clients to break blocks in the world.
+pub struct MiningPlugin;
+impl Plugin for MiningPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_event::<StartMiningBlockEvent>()
+ .add_event::<StartMiningBlockWithDirectionEvent>()
+ .add_event::<FinishMiningBlockEvent>()
+ .add_event::<StopMiningBlockEvent>()
+ .add_event::<MineBlockProgressEvent>()
+ .add_event::<AttackBlockEvent>()
+ .add_systems(
+ GameTick,
+ (continue_mining_block, handle_auto_mine)
+ .chain()
+ .before(PhysicsSet),
+ )
+ .add_systems(
+ Update,
+ (
+ handle_start_mining_block_event,
+ handle_start_mining_block_with_direction_event,
+ handle_finish_mining_block_event,
+ handle_stop_mining_block_event,
+ )
+ .chain()
+ .in_set(MiningSet)
+ .after(InventorySet)
+ .after(MoveEventsSet)
+ .before(azalea_entity::update_bounding_box)
+ .after(azalea_entity::update_fluid_on_eyes)
+ .after(crate::interact::update_hit_result_component)
+ .after(crate::attack::handle_attack_event)
+ .after(crate::interact::handle_block_interact_event)
+ .before(crate::interact::handle_swing_arm_event),
+ );
+ }
+}
+
+/// The Bevy system set for things related to mining.
+#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
+pub struct MiningSet;
+
+impl Client {
+ pub fn start_mining(&mut self, position: BlockPos) {
+ self.ecs.lock().send_event(StartMiningBlockEvent {
+ entity: self.entity,
+ position,
+ });
+ }
+
+ /// When enabled, the bot will mine any block that it is looking at if it is
+ /// reachable.
+ pub fn left_click_mine(&self, enabled: bool) {
+ let mut ecs = self.ecs.lock();
+ let mut entity_mut = ecs.entity_mut(self.entity);
+
+ if enabled {
+ entity_mut.insert(LeftClickMine);
+ } else {
+ entity_mut.remove::<LeftClickMine>();
+ }
+ }
+}
+
+/// A component that simulates the client holding down left click to mine the
+/// block that it's facing, but this only interacts with blocks and not
+/// entities.
+#[derive(Component)]
+pub struct LeftClickMine;
+
+#[allow(clippy::type_complexity)]
+fn handle_auto_mine(
+ mut query: Query<
+ (
+ &HitResultComponent,
+ Entity,
+ Option<&Mining>,
+ &Inventory,
+ &MineBlockPos,
+ &MineItem,
+ ),
+ With<LeftClickMine>,
+ >,
+ mut start_mining_block_event: EventWriter<StartMiningBlockEvent>,
+ mut stop_mining_block_event: EventWriter<StopMiningBlockEvent>,
+) {
+ for (
+ hit_result_component,
+ entity,
+ mining,
+ inventory,
+ current_mining_pos,
+ current_mining_item,
+ ) in &mut query.iter_mut()
+ {
+ let block_pos = hit_result_component.block_pos;
+
+ if (mining.is_none()
+ || !is_same_mining_target(
+ block_pos,
+ inventory,
+ current_mining_pos,
+ current_mining_item,
+ ))
+ && !hit_result_component.miss
+ {
+ start_mining_block_event.send(StartMiningBlockEvent {
+ entity,
+ position: block_pos,
+ });
+ } else if mining.is_some() && hit_result_component.miss {
+ stop_mining_block_event.send(StopMiningBlockEvent { entity });
+ }
+ }
+}
+
+/// Information about the block we're currently mining. This is only present if
+/// we're currently mining a block.
+#[derive(Component)]
+pub struct Mining {
+ pub pos: BlockPos,
+ pub dir: Direction,
+}
+
+/// Start mining the block at the given position.
+///
+/// If we're looking at the block then the correct direction will be used,
+/// otherwise it'll be [`Direction::Down`].
+#[derive(Event)]
+pub struct StartMiningBlockEvent {
+ pub entity: Entity,
+ pub position: BlockPos,
+}
+fn handle_start_mining_block_event(
+ mut events: EventReader<StartMiningBlockEvent>,
+ mut start_mining_events: EventWriter<StartMiningBlockWithDirectionEvent>,
+ mut query: Query<&HitResultComponent>,
+) {
+ for event in events.read() {
+ let hit_result = query.get_mut(event.entity).unwrap();
+ let direction = if hit_result.block_pos == event.position {
+ // we're looking at the block
+ hit_result.direction
+ } else {
+ // we're not looking at the block, arbitrary direction
+ Direction::Down
+ };
+ start_mining_events.send(StartMiningBlockWithDirectionEvent {
+ entity: event.entity,
+ position: event.position,
+ direction,
+ });
+ }
+}
+
+#[derive(Event)]
+pub struct StartMiningBlockWithDirectionEvent {
+ pub entity: Entity,
+ pub position: BlockPos,
+ pub direction: Direction,
+}
+#[allow(clippy::too_many_arguments, clippy::type_complexity)]
+fn handle_start_mining_block_with_direction_event(
+ mut events: EventReader<StartMiningBlockWithDirectionEvent>,
+ mut finish_mining_events: EventWriter<FinishMiningBlockEvent>,
+ mut send_packet_events: EventWriter<SendPacketEvent>,
+ mut attack_block_events: EventWriter<AttackBlockEvent>,
+ mut mine_block_progress_events: EventWriter<MineBlockProgressEvent>,
+ mut query: Query<(
+ &InstanceName,
+ &LocalGameMode,
+ &Inventory,
+ &FluidOnEyes,
+ &Physics,
+ Option<&Mining>,
+ &mut CurrentSequenceNumber,
+ &mut MineDelay,
+ &mut MineProgress,
+ &mut MineTicks,
+ &mut MineItem,
+ &mut MineBlockPos,
+ )>,
+ instances: Res<InstanceContainer>,
+ mut commands: Commands,
+) {
+ for event in events.read() {
+ let (
+ instance_name,
+ game_mode,
+ inventory,
+ fluid_on_eyes,
+ physics,
+ mining,
+ mut sequence_number,
+ mut mine_delay,
+ mut mine_progress,
+ mut mine_ticks,
+ mut current_mining_item,
+ mut current_mining_pos,
+ ) = query.get_mut(event.entity).unwrap();
+
+ let instance_lock = instances.get(instance_name).unwrap();
+ let instance = instance_lock.read();
+ if check_is_interaction_restricted(
+ &instance,
+ &event.position,
+ &game_mode.current,
+ inventory,
+ ) {
+ continue;
+ }
+ // TODO (when world border is implemented): vanilla ignores if the block
+ // is outside of the worldborder
+
+ if game_mode.current == GameMode::Creative {
+ *sequence_number += 1;
+ finish_mining_events.send(FinishMiningBlockEvent {
+ entity: event.entity,
+ position: event.position,
+ });
+ **mine_delay = 5;
+ } else if mining.is_none()
+ || !is_same_mining_target(
+ event.position,
+ inventory,
+ &current_mining_pos,
+ &current_mining_item,
+ )
+ {
+ if mining.is_some() {
+ // send a packet to stop mining since we just changed target
+ send_packet_events.send(SendPacketEvent::new(
+ event.entity,
+ ServerboundPlayerAction {
+ action: s_player_action::Action::AbortDestroyBlock,
+ pos: current_mining_pos
+ .expect("IsMining is true so MineBlockPos must be present"),
+ direction: event.direction,
+ sequence: 0,
+ },
+ ));
+ }
+
+ let target_block_state = instance
+ .get_block_state(&event.position)
+ .unwrap_or_default();
+ *sequence_number += 1;
+ let target_registry_block = azalea_registry::Block::from(target_block_state);
+
+ // we can't break blocks if they don't have a bounding box
+
+ // TODO: So right now azalea doesn't differenciate between different types of
+ // bounding boxes. See ClipContext::block_shape for more info. Ideally this
+ // should just call ClipContext::block_shape and check if it's empty.
+ let block_is_solid = !target_block_state.is_air()
+ // this is a hack to make sure we can't break water or lava
+ && !matches!(
+ target_registry_block,
+ azalea_registry::Block::Water | azalea_registry::Block::Lava
+ );
+
+ if block_is_solid && **mine_progress == 0. {
+ // interact with the block (like note block left click) here
+ attack_block_events.send(AttackBlockEvent {
+ entity: event.entity,
+ position: event.position,
+ });
+ }
+
+ let block = Box::<dyn Block>::from(target_block_state);
+
+ let held_item = inventory.held_item();
+
+ if block_is_solid
+ && get_mine_progress(
+ block.as_ref(),
+ held_item.kind(),
+ &inventory.inventory_menu,
+ fluid_on_eyes,
+ physics,
+ ) >= 1.
+ {
+ // block was broken instantly
+ finish_mining_events.send(FinishMiningBlockEvent {
+ entity: event.entity,
+ position: event.position,
+ });
+ } else {
+ commands.entity(event.entity).insert(Mining {
+ pos: event.position,
+ dir: event.direction,
+ });
+ **current_mining_pos = Some(event.position);
+ **current_mining_item = held_item;
+ **mine_progress = 0.;
+ **mine_ticks = 0.;
+ mine_block_progress_events.send(MineBlockProgressEvent {
+ entity: event.entity,
+ position: event.position,
+ destroy_stage: mine_progress.destroy_stage(),
+ });
+ }
+
+ send_packet_events.send(SendPacketEvent::new(
+ event.entity,
+ ServerboundPlayerAction {
+ action: s_player_action::Action::StartDestroyBlock,
+ pos: event.position,
+ direction: event.direction,
+ sequence: **sequence_number,
+ },
+ ));
+ }
+ }
+}
+
+#[derive(Event)]
+pub struct MineBlockProgressEvent {
+ pub entity: Entity,
+ pub position: BlockPos,
+ pub destroy_stage: Option<u32>,
+}
+
+/// A player left clicked on a block, used for stuff like interacting with note
+/// blocks.
+#[derive(Event)]
+pub struct AttackBlockEvent {
+ pub entity: Entity,
+ pub position: BlockPos,
+}
+
+/// Returns whether the block and item are still the same as when we started
+/// mining.
+fn is_same_mining_target(
+ target_block: BlockPos,
+ inventory: &Inventory,
+ current_mining_pos: &MineBlockPos,
+ current_mining_item: &MineItem,
+) -> bool {
+ let held_item = inventory.held_item();
+ Some(target_block) == current_mining_pos.0 && held_item == current_mining_item.0
+}
+
+/// A component bundle for players that can mine blocks.
+#[derive(Bundle, Default)]
+pub struct MineBundle {
+ pub delay: MineDelay,
+ pub progress: MineProgress,
+ pub ticks: MineTicks,
+ pub mining_pos: MineBlockPos,
+ pub mine_item: MineItem,
+}
+
+/// A component that counts down until we start mining the next block.
+#[derive(Component, Debug, Default, Deref, DerefMut)]
+pub struct MineDelay(pub u32);
+
+/// A component that stores the progress of the current mining operation. This
+/// is a value between 0 and 1.
+#[derive(Component, Debug, Default, Deref, DerefMut)]
+pub struct MineProgress(pub f32);
+
+impl MineProgress {
+ pub fn destroy_stage(&self) -> Option<u32> {
+ if self.0 > 0. {
+ Some((self.0 * 10.) as u32)
+ } else {
+ None
+ }
+ }
+}
+
+/// A component that stores the number of ticks that we've been mining the same
+/// block for. This is a float even though it should only ever be a round
+/// number.
+#[derive(Component, Clone, Debug, Default, Deref, DerefMut)]
+pub struct MineTicks(pub f32);
+
+/// A component that stores the position of the block we're currently mining.
+#[derive(Component, Clone, Debug, Default, Deref, DerefMut)]
+pub struct MineBlockPos(pub Option<BlockPos>);
+
+/// A component that contains the item we're currently using to mine. If we're
+/// not mining anything, it'll be [`ItemStack::Empty`].
+#[derive(Component, Clone, Debug, Default, Deref, DerefMut)]
+pub struct MineItem(pub ItemStack);
+
+/// Sent when we completed mining a block.
+#[derive(Event)]
+pub struct FinishMiningBlockEvent {
+ pub entity: Entity,
+ pub position: BlockPos,
+}
+
+pub fn handle_finish_mining_block_event(
+ mut events: EventReader<FinishMiningBlockEvent>,
+ mut query: Query<(
+ &InstanceName,
+ &LocalGameMode,
+ &Inventory,
+ &PlayerAbilities,
+ &PermissionLevel,
+ &mut CurrentSequenceNumber,
+ )>,
+ instances: Res<InstanceContainer>,
+) {
+ for event in events.read() {
+ let (instance_name, game_mode, inventory, abilities, permission_level, _sequence_number) =
+ query.get_mut(event.entity).unwrap();
+ let instance_lock = instances.get(instance_name).unwrap();
+ let instance = instance_lock.read();
+ if check_is_interaction_restricted(
+ &instance,
+ &event.position,
+ &game_mode.current,
+ inventory,
+ ) {
+ continue;
+ }
+
+ if game_mode.current == GameMode::Creative {
+ let held_item = inventory.held_item().kind();
+ if matches!(
+ held_item,
+ azalea_registry::Item::Trident | azalea_registry::Item::DebugStick
+ ) || azalea_registry::tags::items::SWORDS.contains(&held_item)
+ {
+ continue;
+ }
+ }
+
+ let Some(block_state) = instance.get_block_state(&event.position) else {
+ continue;
+ };
+
+ let registry_block = Box::<dyn Block>::from(block_state).as_registry_block();
+ if !can_use_game_master_blocks(abilities, permission_level)
+ && matches!(
+ registry_block,
+ azalea_registry::Block::CommandBlock | azalea_registry::Block::StructureBlock
+ )
+ {
+ continue;
+ }
+ if block_state == BlockState::AIR {
+ continue;
+ }
+
+ // when we break a waterlogged block we want to keep the water there
+ let fluid_state = FluidState::from(block_state);
+ let block_state_for_fluid = BlockState::from(fluid_state);
+ instance.set_block_state(&event.position, block_state_for_fluid);
+ }
+}
+
+/// Abort mining a block.
+#[derive(Event)]
+pub struct StopMiningBlockEvent {
+ pub entity: Entity,
+}
+pub fn handle_stop_mining_block_event(
+ mut events: EventReader<StopMiningBlockEvent>,
+ mut send_packet_events: EventWriter<SendPacketEvent>,
+ mut mine_block_progress_events: EventWriter<MineBlockProgressEvent>,
+ mut query: Query<(&mut Mining, &MineBlockPos, &mut MineProgress)>,
+ mut commands: Commands,
+) {
+ for event in events.read() {
+ let (mut _mining, mine_block_pos, mut mine_progress) = query.get_mut(event.entity).unwrap();
+
+ let mine_block_pos =
+ mine_block_pos.expect("IsMining is true so MineBlockPos must be present");
+ send_packet_events.send(SendPacketEvent::new(
+ event.entity,
+ ServerboundPlayerAction {
+ action: s_player_action::Action::AbortDestroyBlock,
+ pos: mine_block_pos,
+ direction: Direction::Down,
+ sequence: 0,
+ },
+ ));
+ commands.entity(event.entity).remove::<Mining>();
+ **mine_progress = 0.;
+ mine_block_progress_events.send(MineBlockProgressEvent {
+ entity: event.entity,
+ position: mine_block_pos,
+ destroy_stage: None,
+ });
+ }
+}
+
+#[allow(clippy::too_many_arguments, clippy::type_complexity)]
+pub fn continue_mining_block(
+ mut query: Query<(
+ Entity,
+ &InstanceName,
+ &LocalGameMode,
+ &Inventory,
+ &MineBlockPos,
+ &MineItem,
+ &FluidOnEyes,
+ &Physics,
+ &Mining,
+ &mut MineDelay,
+ &mut MineProgress,
+ &mut MineTicks,
+ &mut CurrentSequenceNumber,
+ )>,
+ mut send_packet_events: EventWriter<SendPacketEvent>,
+ mut mine_block_progress_events: EventWriter<MineBlockProgressEvent>,
+ mut finish_mining_events: EventWriter<FinishMiningBlockEvent>,
+ mut start_mining_events: EventWriter<StartMiningBlockWithDirectionEvent>,
+ mut swing_arm_events: EventWriter<SwingArmEvent>,
+ instances: Res<InstanceContainer>,
+ mut commands: Commands,
+) {
+ for (
+ entity,
+ instance_name,
+ game_mode,
+ inventory,
+ current_mining_pos,
+ current_mining_item,
+ fluid_on_eyes,
+ physics,
+ mining,
+ mut mine_delay,
+ mut mine_progress,
+ mut mine_ticks,
+ mut sequence_number,
+ ) in query.iter_mut()
+ {
+ if **mine_delay > 0 {
+ **mine_delay -= 1;
+ continue;
+ }
+
+ if game_mode.current == GameMode::Creative {
+ // TODO: worldborder check
+ **mine_delay = 5;
+ finish_mining_events.send(FinishMiningBlockEvent {
+ entity,
+ position: mining.pos,
+ });
+ *sequence_number += 1;
+ send_packet_events.send(SendPacketEvent::new(
+ entity,
+ ServerboundPlayerAction {
+ action: s_player_action::Action::StartDestroyBlock,
+ pos: mining.pos,
+ direction: mining.dir,
+ sequence: **sequence_number,
+ },
+ ));
+ swing_arm_events.send(SwingArmEvent { entity });
+ } else if is_same_mining_target(
+ mining.pos,
+ inventory,
+ current_mining_pos,
+ current_mining_item,
+ ) {
+ let instance_lock = instances.get(instance_name).unwrap();
+ let instance = instance_lock.read();
+ let target_block_state = instance.get_block_state(&mining.pos).unwrap_or_default();
+
+ if target_block_state.is_air() {
+ commands.entity(entity).remove::<Mining>();
+ continue;
+ }
+ let block = Box::<dyn Block>::from(target_block_state);
+ **mine_progress += get_mine_progress(
+ block.as_ref(),
+ current_mining_item.kind(),
+ &inventory.inventory_menu,
+ fluid_on_eyes,
+ physics,
+ );
+
+ if **mine_ticks % 4. == 0. {
+ // vanilla makes a mining sound here
+ }
+ **mine_ticks += 1.;
+
+ if **mine_progress >= 1. {
+ commands.entity(entity).remove::<Mining>();
+ *sequence_number += 1;
+ finish_mining_events.send(FinishMiningBlockEvent {
+ entity,
+ position: mining.pos,
+ });
+ send_packet_events.send(SendPacketEvent::new(
+ entity,
+ ServerboundPlayerAction {
+ action: s_player_action::Action::StopDestroyBlock,
+ pos: mining.pos,
+ direction: mining.dir,
+ sequence: **sequence_number,
+ },
+ ));
+ **mine_progress = 0.;
+ **mine_ticks = 0.;
+ **mine_delay = 0;
+ }
+
+ mine_block_progress_events.send(MineBlockProgressEvent {
+ entity,
+ position: mining.pos,
+ destroy_stage: mine_progress.destroy_stage(),
+ });
+ swing_arm_events.send(SwingArmEvent { entity });
+ } else {
+ start_mining_events.send(StartMiningBlockWithDirectionEvent {
+ entity,
+ position: mining.pos,
+ direction: mining.dir,
+ });
+ }
+
+ swing_arm_events.send(SwingArmEvent { entity });
+ }
+}
diff --git a/azalea-client/src/plugins/mod.rs b/azalea-client/src/plugins/mod.rs
new file mode 100644
index 00000000..11794fb3
--- /dev/null
+++ b/azalea-client/src/plugins/mod.rs
@@ -0,0 +1,14 @@
+pub mod attack;
+pub mod brand;
+pub mod chat;
+pub mod chunks;
+pub mod disconnect;
+pub mod events;
+pub mod interact;
+pub mod inventory;
+pub mod mining;
+pub mod movement;
+pub mod packet;
+pub mod respawn;
+pub mod task_pool;
+pub mod tick_end;
diff --git a/azalea-client/src/plugins/movement.rs b/azalea-client/src/plugins/movement.rs
new file mode 100644
index 00000000..17b92e65
--- /dev/null
+++ b/azalea-client/src/plugins/movement.rs
@@ -0,0 +1,580 @@
+use std::backtrace::Backtrace;
+
+use azalea_core::position::Vec3;
+use azalea_core::tick::GameTick;
+use azalea_entity::{Attributes, Jumping, metadata::Sprinting};
+use azalea_entity::{InLoadedChunk, LastSentPosition, LookDirection, Physics, Position};
+use azalea_physics::{PhysicsSet, ai_step};
+use azalea_protocol::packets::game::{ServerboundPlayerCommand, ServerboundPlayerInput};
+use azalea_protocol::packets::{
+ Packet,
+ game::{
+ s_move_player_pos::ServerboundMovePlayerPos,
+ s_move_player_pos_rot::ServerboundMovePlayerPosRot,
+ s_move_player_rot::ServerboundMovePlayerRot,
+ s_move_player_status_only::ServerboundMovePlayerStatusOnly,
+ },
+};
+use azalea_world::{MinecraftEntityId, MoveEntityError};
+use bevy_app::{App, Plugin, Update};
+use bevy_ecs::prelude::{Event, EventWriter};
+use bevy_ecs::schedule::SystemSet;
+use bevy_ecs::system::Commands;
+use bevy_ecs::{
+ component::Component, entity::Entity, event::EventReader, query::With,
+ schedule::IntoSystemConfigs, system::Query,
+};
+use thiserror::Error;
+
+use crate::client::Client;
+use crate::packet::game::SendPacketEvent;
+
+#[derive(Error, Debug)]
+pub enum MovePlayerError {
+ #[error("Player is not in world")]
+ PlayerNotInWorld(Backtrace),
+ #[error("{0}")]
+ Io(#[from] std::io::Error),
+}
+
+impl From<MoveEntityError> for MovePlayerError {
+ fn from(err: MoveEntityError) -> Self {
+ match err {
+ MoveEntityError::EntityDoesNotExist(backtrace) => {
+ MovePlayerError::PlayerNotInWorld(backtrace)
+ }
+ }
+ }
+}
+
+pub struct MovementPlugin;
+
+impl Plugin for MovementPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_event::<StartWalkEvent>()
+ .add_event::<StartSprintEvent>()
+ .add_event::<KnockbackEvent>()
+ .add_systems(
+ Update,
+ (handle_sprint, handle_walk, handle_knockback)
+ .chain()
+ .in_set(MoveEventsSet),
+ )
+ .add_systems(
+ GameTick,
+ (
+ (tick_controls, local_player_ai_step)
+ .chain()
+ .in_set(PhysicsSet)
+ .before(ai_step)
+ .before(azalea_physics::fluids::update_in_water_state_and_do_fluid_pushing),
+ send_sprinting_if_needed.after(azalea_entity::update_in_loaded_chunk),
+ send_player_input_packet,
+ send_position.after(PhysicsSet),
+ )
+ .chain(),
+ );
+ }
+}
+
+#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)]
+pub struct MoveEventsSet;
+
+impl Client {
+ /// Set whether we're jumping. This acts as if you held space in
+ /// vanilla. If you want to jump once, use the `jump` function.
+ ///
+ /// If you're making a realistic client, calling this function every tick is
+ /// recommended.
+ pub fn set_jumping(&mut self, jumping: bool) {
+ let mut ecs = self.ecs.lock();
+ let mut jumping_mut = self.query::<&mut Jumping>(&mut ecs);
+ **jumping_mut = jumping;
+ }
+
+ /// Returns whether the player will try to jump next tick.
+ pub fn jumping(&self) -> bool {
+ *self.component::<Jumping>()
+ }
+
+ /// Sets the direction the client is looking. `y_rot` is yaw (looking to the
+ /// side), `x_rot` is pitch (looking up and down). You can get these
+ /// numbers from the vanilla f3 screen.
+ /// `y_rot` goes from -180 to 180, and `x_rot` goes from -90 to 90.
+ pub fn set_direction(&mut self, y_rot: f32, x_rot: f32) {
+ let mut ecs = self.ecs.lock();
+ let mut look_direction = self.query::<&mut LookDirection>(&mut ecs);
+
+ (look_direction.y_rot, look_direction.x_rot) = (y_rot, x_rot);
+ }
+
+ /// Returns the direction the client is looking. The first value is the y
+ /// rotation (ie. yaw, looking to the side) and the second value is the x
+ /// rotation (ie. pitch, looking up and down).
+ pub fn direction(&self) -> (f32, f32) {
+ let look_direction = self.component::<LookDirection>();
+ (look_direction.y_rot, look_direction.x_rot)
+ }
+}
+
+/// A component that contains the look direction that was last sent over the
+/// network.
+#[derive(Debug, Component, Clone, Default)]
+pub struct LastSentLookDirection {
+ pub x_rot: f32,
+ pub y_rot: f32,
+}
+
+/// Component for entities that can move and sprint. Usually only in
+/// [`LocalEntity`]s.
+///
+/// [`LocalEntity`]: azalea_entity::LocalEntity
+#[derive(Default, Component, Clone)]
+pub struct PhysicsState {
+ /// Minecraft only sends a movement packet either after 20 ticks or if the
+ /// player moved enough. This is that tick counter.
+ pub position_remainder: u32,
+ pub was_sprinting: bool,
+ // Whether we're going to try to start sprinting this tick. Equivalent to
+ // holding down ctrl for a tick.
+ pub trying_to_sprint: bool,
+
+ pub move_direction: WalkDirection,
+ pub forward_impulse: f32,
+ pub left_impulse: f32,
+}
+
+#[allow(clippy::type_complexity)]
+pub fn send_position(
+ mut query: Query<
+ (
+ Entity,
+ &Position,
+ &LookDirection,
+ &mut PhysicsState,
+ &mut LastSentPosition,
+ &mut Physics,
+ &mut LastSentLookDirection,
+ ),
+ With<InLoadedChunk>,
+ >,
+ mut send_packet_events: EventWriter<SendPacketEvent>,
+) {
+ for (
+ entity,
+ position,
+ direction,
+ mut physics_state,
+ mut last_sent_position,
+ mut physics,
+ mut last_direction,
+ ) in query.iter_mut()
+ {
+ let packet = {
+ // TODO: the camera being able to be controlled by other entities isn't
+ // implemented yet if !self.is_controlled_camera() { return };
+
+ let x_delta = position.x - last_sent_position.x;
+ let y_delta = position.y - last_sent_position.y;
+ let z_delta = position.z - last_sent_position.z;
+ let y_rot_delta = (direction.y_rot - last_direction.y_rot) as f64;
+ let x_rot_delta = (direction.x_rot - last_direction.x_rot) as f64;
+
+ physics_state.position_remainder += 1;
+
+ // boolean sendingPosition = Mth.lengthSquared(xDelta, yDelta, zDelta) >
+ // Mth.square(2.0E-4D) || this.positionReminder >= 20;
+ let sending_position = ((x_delta.powi(2) + y_delta.powi(2) + z_delta.powi(2))
+ > 2.0e-4f64.powi(2))
+ || physics_state.position_remainder >= 20;
+ let sending_direction = y_rot_delta != 0.0 || x_rot_delta != 0.0;
+
+ // if self.is_passenger() {
+ // TODO: posrot packet for being a passenger
+ // }
+ let packet = if sending_position && sending_direction {
+ Some(
+ ServerboundMovePlayerPosRot {
+ pos: **position,
+ look_direction: *direction,
+ on_ground: physics.on_ground(),
+ }
+ .into_variant(),
+ )
+ } else if sending_position {
+ Some(
+ ServerboundMovePlayerPos {
+ pos: **position,
+ on_ground: physics.on_ground(),
+ }
+ .into_variant(),
+ )
+ } else if sending_direction {
+ Some(
+ ServerboundMovePlayerRot {
+ look_direction: *direction,
+ on_ground: physics.on_ground(),
+ }
+ .into_variant(),
+ )
+ } else if physics.last_on_ground() != physics.on_ground() {
+ Some(
+ ServerboundMovePlayerStatusOnly {
+ on_ground: physics.on_ground(),
+ }
+ .into_variant(),
+ )
+ } else {
+ None
+ };
+
+ if sending_position {
+ **last_sent_position = **position;
+ physics_state.position_remainder = 0;
+ }
+ if sending_direction {
+ last_direction.y_rot = direction.y_rot;
+ last_direction.x_rot = direction.x_rot;
+ }
+
+ let on_ground = physics.on_ground();
+ physics.set_last_on_ground(on_ground);
+ // minecraft checks for autojump here, but also autojump is bad so
+
+ packet
+ };
+
+ if let Some(packet) = packet {
+ send_packet_events.send(SendPacketEvent {
+ sent_by: entity,
+ packet,
+ });
+ }
+ }
+}
+
+#[derive(Debug, Default, Component, Clone, PartialEq, Eq)]
+pub struct LastSentInput(pub ServerboundPlayerInput);
+pub fn send_player_input_packet(
+ mut query: Query<(Entity, &PhysicsState, &Jumping, Option<&LastSentInput>)>,
+ mut send_packet_events: EventWriter<SendPacketEvent>,
+ mut commands: Commands,
+) {
+ for (entity, physics_state, jumping, last_sent_input) in query.iter_mut() {
+ let dir = physics_state.move_direction;
+ type D = WalkDirection;
+ let input = ServerboundPlayerInput {
+ forward: matches!(dir, D::Forward | D::ForwardLeft | D::ForwardRight),
+ backward: matches!(dir, D::Backward | D::BackwardLeft | D::BackwardRight),
+ left: matches!(dir, D::Left | D::ForwardLeft | D::BackwardLeft),
+ right: matches!(dir, D::Right | D::ForwardRight | D::BackwardRight),
+ jump: **jumping,
+ // TODO: implement sneaking
+ shift: false,
+ sprint: physics_state.trying_to_sprint,
+ };
+
+ // if LastSentInput isn't present, we default to assuming we're not pressing any
+ // keys and insert it anyways every time it changes
+ let last_sent_input = last_sent_input.cloned().unwrap_or_default();
+
+ if input != last_sent_input.0 {
+ send_packet_events.send(SendPacketEvent {
+ sent_by: entity,
+ packet: input.clone().into_variant(),
+ });
+ commands.entity(entity).insert(LastSentInput(input));
+ }
+ }
+}
+
+fn send_sprinting_if_needed(
+ mut query: Query<(Entity, &MinecraftEntityId, &Sprinting, &mut PhysicsState)>,
+ mut send_packet_events: EventWriter<SendPacketEvent>,
+) {
+ for (entity, minecraft_entity_id, sprinting, mut physics_state) in query.iter_mut() {
+ let was_sprinting = physics_state.was_sprinting;
+ if **sprinting != was_sprinting {
+ let sprinting_action = if **sprinting {
+ azalea_protocol::packets::game::s_player_command::Action::StartSprinting
+ } else {
+ azalea_protocol::packets::game::s_player_command::Action::StopSprinting
+ };
+ send_packet_events.send(SendPacketEvent::new(
+ entity,
+ ServerboundPlayerCommand {
+ id: *minecraft_entity_id,
+ action: sprinting_action,
+ data: 0,
+ },
+ ));
+ physics_state.was_sprinting = **sprinting;
+ }
+ }
+}
+
+/// Update the impulse from self.move_direction. The multiplier is used for
+/// sneaking.
+pub(crate) fn tick_controls(mut query: Query<&mut PhysicsState>) {
+ for mut physics_state in query.iter_mut() {
+ let multiplier: Option<f32> = None;
+
+ let mut forward_impulse: f32 = 0.;
+ let mut left_impulse: f32 = 0.;
+ let move_direction = physics_state.move_direction;
+ match move_direction {
+ WalkDirection::Forward | WalkDirection::ForwardRight | WalkDirection::ForwardLeft => {
+ forward_impulse += 1.;
+ }
+ WalkDirection::Backward
+ | WalkDirection::BackwardRight
+ | WalkDirection::BackwardLeft => {
+ forward_impulse -= 1.;
+ }
+ _ => {}
+ };
+ match move_direction {
+ WalkDirection::Right | WalkDirection::ForwardRight | WalkDirection::BackwardRight => {
+ left_impulse += 1.;
+ }
+ WalkDirection::Left | WalkDirection::ForwardLeft | WalkDirection::BackwardLeft => {
+ left_impulse -= 1.;
+ }
+ _ => {}
+ };
+ physics_state.forward_impulse = forward_impulse;
+ physics_state.left_impulse = left_impulse;
+
+ if let Some(multiplier) = multiplier {
+ physics_state.forward_impulse *= multiplier;
+ physics_state.left_impulse *= multiplier;
+ }
+ }
+}
+
+/// Makes the bot do one physics tick. Note that this is already handled
+/// automatically by the client.
+pub fn local_player_ai_step(
+ mut query: Query<
+ (&PhysicsState, &mut Physics, &mut Sprinting, &mut Attributes),
+ With<InLoadedChunk>,
+ >,
+) {
+ for (physics_state, mut physics, mut sprinting, mut attributes) in query.iter_mut() {
+ // server ai step
+ physics.x_acceleration = physics_state.left_impulse;
+ physics.z_acceleration = physics_state.forward_impulse;
+
+ // TODO: food data and abilities
+ // let has_enough_food_to_sprint = self.food_data().food_level ||
+ // self.abilities().may_fly;
+ let has_enough_food_to_sprint = true;
+
+ // TODO: double tapping w to sprint i think
+
+ let trying_to_sprint = physics_state.trying_to_sprint;
+
+ if !**sprinting
+ && (
+ // !self.is_in_water()
+ // || self.is_underwater() &&
+ has_enough_impulse_to_start_sprinting(physics_state)
+ && has_enough_food_to_sprint
+ // && !self.using_item()
+ // && !self.has_effect(MobEffects.BLINDNESS)
+ && trying_to_sprint
+ )
+ {
+ set_sprinting(true, &mut sprinting, &mut attributes);
+ }
+ }
+}
+
+impl Client {
+ /// Start walking in the given direction. To sprint, use
+ /// [`Client::sprint`]. To stop walking, call walk with
+ /// `WalkDirection::None`.
+ ///
+ /// # Examples
+ ///
+ /// Walk for 1 second
+ /// ```rust,no_run
+ /// # use azalea_client::{Client, WalkDirection};
+ /// # use std::time::Duration;
+ /// # async fn example(mut bot: Client) {
+ /// bot.walk(WalkDirection::Forward);
+ /// tokio::time::sleep(Duration::from_secs(1)).await;
+ /// bot.walk(WalkDirection::None);
+ /// # }
+ /// ```
+ pub fn walk(&mut self, direction: WalkDirection) {
+ let mut ecs = self.ecs.lock();
+ ecs.send_event(StartWalkEvent {
+ entity: self.entity,
+ direction,
+ });
+ }
+
+ /// Start sprinting in the given direction. To stop moving, call
+ /// [`Client::walk(WalkDirection::None)`]
+ ///
+ /// # Examples
+ ///
+ /// Sprint for 1 second
+ /// ```rust,no_run
+ /// # use azalea_client::{Client, WalkDirection, SprintDirection};
+ /// # use std::time::Duration;
+ /// # async fn example(mut bot: Client) {
+ /// bot.sprint(SprintDirection::Forward);
+ /// tokio::time::sleep(Duration::from_secs(1)).await;
+ /// bot.walk(WalkDirection::None);
+ /// # }
+ /// ```
+ pub fn sprint(&mut self, direction: SprintDirection) {
+ let mut ecs = self.ecs.lock();
+ ecs.send_event(StartSprintEvent {
+ entity: self.entity,
+ direction,
+ });
+ }
+}
+
+/// An event sent when the client starts walking. This does not get sent for
+/// non-local entities.
+///
+/// To stop walking or sprinting, send this event with `WalkDirection::None`.
+#[derive(Event, Debug)]
+pub struct StartWalkEvent {
+ pub entity: Entity,
+ pub direction: WalkDirection,
+}
+
+/// The system that makes the player start walking when they receive a
+/// [`StartWalkEvent`].
+pub fn handle_walk(
+ mut events: EventReader<StartWalkEvent>,
+ mut query: Query<(&mut PhysicsState, &mut Sprinting, &mut Attributes)>,
+) {
+ for event in events.read() {
+ if let Ok((mut physics_state, mut sprinting, mut attributes)) = query.get_mut(event.entity)
+ {
+ physics_state.move_direction = event.direction;
+ physics_state.trying_to_sprint = false;
+ set_sprinting(false, &mut sprinting, &mut attributes);
+ }
+ }
+}
+
+/// An event sent when the client starts sprinting. This does not get sent for
+/// non-local entities.
+#[derive(Event)]
+pub struct StartSprintEvent {
+ pub entity: Entity,
+ pub direction: SprintDirection,
+}
+/// The system that makes the player start sprinting when they receive a
+/// [`StartSprintEvent`].
+pub fn handle_sprint(
+ mut query: Query<&mut PhysicsState>,
+ mut events: EventReader<StartSprintEvent>,
+) {
+ for event in events.read() {
+ if let Ok(mut physics_state) = query.get_mut(event.entity) {
+ physics_state.move_direction = WalkDirection::from(event.direction);
+ physics_state.trying_to_sprint = true;
+ }
+ }
+}
+
+/// Change whether we're sprinting by adding an attribute modifier to the
+/// player. You should use the [`walk`] and [`sprint`] methods instead.
+/// Returns if the operation was successful.
+fn set_sprinting(
+ sprinting: bool,
+ currently_sprinting: &mut Sprinting,
+ attributes: &mut Attributes,
+) -> bool {
+ **currently_sprinting = sprinting;
+ if sprinting {
+ attributes
+ .speed
+ .try_insert(azalea_entity::attributes::sprinting_modifier())
+ .is_ok()
+ } else {
+ attributes
+ .speed
+ .remove(&azalea_entity::attributes::sprinting_modifier().id)
+ .is_none()
+ }
+}
+
+// Whether the player is moving fast enough to be able to start sprinting.
+fn has_enough_impulse_to_start_sprinting(physics_state: &PhysicsState) -> bool {
+ // if self.underwater() {
+ // self.has_forward_impulse()
+ // } else {
+ physics_state.forward_impulse > 0.8
+ // }
+}
+
+/// An event sent by the server that sets or adds to our velocity. Usually
+/// `KnockbackKind::Set` is used for normal knockback and `KnockbackKind::Add`
+/// is used for explosions, but some servers (notably Hypixel) use explosions
+/// for knockback.
+#[derive(Event)]
+pub struct KnockbackEvent {
+ pub entity: Entity,
+ pub knockback: KnockbackType,
+}
+
+pub enum KnockbackType {
+ Set(Vec3),
+ Add(Vec3),
+}
+
+pub fn handle_knockback(mut query: Query<&mut Physics>, mut events: EventReader<KnockbackEvent>) {
+ for event in events.read() {
+ if let Ok(mut physics) = query.get_mut(event.entity) {
+ match event.knockback {
+ KnockbackType::Set(velocity) => {
+ physics.velocity = velocity;
+ }
+ KnockbackType::Add(velocity) => {
+ physics.velocity += velocity;
+ }
+ }
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum WalkDirection {
+ #[default]
+ None,
+ Forward,
+ Backward,
+ Left,
+ Right,
+ ForwardRight,
+ ForwardLeft,
+ BackwardRight,
+ BackwardLeft,
+}
+
+/// The directions that we can sprint in. It's a subset of [`WalkDirection`].
+#[derive(Clone, Copy, Debug)]
+pub enum SprintDirection {
+ Forward,
+ ForwardRight,
+ ForwardLeft,
+}
+
+impl From<SprintDirection> for WalkDirection {
+ fn from(d: SprintDirection) -> Self {
+ match d {
+ SprintDirection::Forward => WalkDirection::Forward,
+ SprintDirection::ForwardRight => WalkDirection::ForwardRight,
+ SprintDirection::ForwardLeft => WalkDirection::ForwardLeft,
+ }
+ }
+}
diff --git a/azalea-client/src/plugins/packet/config/events.rs b/azalea-client/src/plugins/packet/config/events.rs
new file mode 100644
index 00000000..6b647d74
--- /dev/null
+++ b/azalea-client/src/plugins/packet/config/events.rs
@@ -0,0 +1,90 @@
+use std::io::Cursor;
+
+use azalea_protocol::{
+ packets::{
+ config::{ClientboundConfigPacket, ServerboundConfigPacket},
+ Packet,
+ },
+ read::deserialize_packet,
+};
+use bevy_ecs::prelude::*;
+use tracing::{debug, error};
+
+use crate::{raw_connection::RawConnection, InConfigState};
+
+#[derive(Event, Debug, Clone)]
+pub struct ReceiveConfigPacketEvent {
+ /// The client entity that received the packet.
+ pub entity: Entity,
+ /// The packet that was actually received.
+ pub packet: ClientboundConfigPacket,
+}
+
+/// An event for sending a packet to the server while we're in the
+/// `configuration` state.
+#[derive(Event)]
+pub struct SendConfigPacketEvent {
+ pub sent_by: Entity,
+ pub packet: ServerboundConfigPacket,
+}
+impl SendConfigPacketEvent {
+ pub fn new(sent_by: Entity, packet: impl Packet<ServerboundConfigPacket>) -> Self {
+ let packet = packet.into_variant();
+ Self { sent_by, packet }
+ }
+}
+
+pub fn handle_send_packet_event(
+ mut send_packet_events: EventReader<SendConfigPacketEvent>,
+ mut query: Query<(&mut RawConnection, Option<&InConfigState>)>,
+) {
+ for event in send_packet_events.read() {
+ if let Ok((raw_conn, in_configuration_state)) = query.get_mut(event.sent_by) {
+ if in_configuration_state.is_none() {
+ error!(
+ "Tried to send a configuration packet {:?} while not in configuration state",
+ event.packet
+ );
+ continue;
+ }
+ debug!("Sending packet: {:?}", event.packet);
+ if let Err(e) = raw_conn.write_packet(event.packet.clone()) {
+ error!("Failed to send packet: {e}");
+ }
+ }
+ }
+}
+
+pub fn send_packet_events(
+ query: Query<(Entity, &RawConnection), With<InConfigState>>,
+ mut packet_events: ResMut<Events<ReceiveConfigPacketEvent>>,
+) {
+ // we manually clear and send the events at the beginning of each update
+ // since otherwise it'd cause issues with events in process_packet_events
+ // running twice
+ packet_events.clear();
+ for (player_entity, raw_conn) in &query {
+ let packets_lock = raw_conn.incoming_packet_queue();
+ let mut packets = packets_lock.lock();
+ if !packets.is_empty() {
+ for raw_packet in packets.iter() {
+ let packet = match deserialize_packet::<ClientboundConfigPacket>(&mut Cursor::new(
+ raw_packet,
+ )) {
+ Ok(packet) => packet,
+ Err(err) => {
+ error!("failed to read packet: {err:?}");
+ debug!("packet bytes: {raw_packet:?}");
+ continue;
+ }
+ };
+ packet_events.send(ReceiveConfigPacketEvent {
+ entity: player_entity,
+ packet,
+ });
+ }
+ // clear the packets right after we read them
+ packets.clear();
+ }
+ }
+}
diff --git a/azalea-client/src/plugins/packet/config/mod.rs b/azalea-client/src/plugins/packet/config/mod.rs
new file mode 100644
index 00000000..5cb19b9d
--- /dev/null
+++ b/azalea-client/src/plugins/packet/config/mod.rs
@@ -0,0 +1,223 @@
+mod events;
+
+use azalea_protocol::packets::config::*;
+use azalea_protocol::packets::ConnectionProtocol;
+use bevy_ecs::prelude::*;
+use bevy_ecs::system::SystemState;
+pub use events::*;
+use tracing::{debug, warn};
+
+use super::as_system;
+use crate::client::InConfigState;
+use crate::disconnect::DisconnectEvent;
+use crate::packet::game::KeepAliveEvent;
+use crate::raw_connection::RawConnection;
+use crate::{declare_packet_handlers, InstanceHolder};
+
+pub fn process_packet_events(ecs: &mut World) {
+ let mut events_owned = Vec::new();
+ let mut system_state: SystemState<EventReader<ReceiveConfigPacketEvent>> =
+ SystemState::new(ecs);
+ let mut events = system_state.get_mut(ecs);
+ for ReceiveConfigPacketEvent {
+ entity: player_entity,
+ packet,
+ } in events.read()
+ {
+ // we do this so `ecs` isn't borrowed for the whole loop
+ events_owned.push((*player_entity, packet.clone()));
+ }
+ for (player_entity, packet) in events_owned {
+ let mut handler = ConfigPacketHandler {
+ player: player_entity,
+ ecs,
+ };
+
+ declare_packet_handlers!(
+ ClientboundConfigPacket,
+ packet,
+ handler,
+ [
+ cookie_request,
+ custom_payload,
+ disconnect,
+ finish_configuration,
+ keep_alive,
+ ping,
+ reset_chat,
+ registry_data,
+ resource_pack_pop,
+ resource_pack_push,
+ store_cookie,
+ transfer,
+ update_enabled_features,
+ update_tags,
+ select_known_packs,
+ custom_report_details,
+ server_links,
+ ]
+ );
+ }
+}
+
+pub struct ConfigPacketHandler<'a> {
+ pub ecs: &'a mut World,
+ pub player: Entity,
+}
+impl ConfigPacketHandler<'_> {
+ pub fn registry_data(&mut self, p: ClientboundRegistryData) {
+ as_system::<Query<&mut InstanceHolder>>(self.ecs, |mut query| {
+ let instance_holder = query.get_mut(self.player).unwrap();
+ let mut instance = instance_holder.instance.write();
+
+ // add the new registry data
+ instance.registries.append(p.registry_id, p.entries);
+ });
+ }
+
+ pub fn custom_payload(&mut self, p: ClientboundCustomPayload) {
+ debug!("Got custom payload packet {p:?}");
+ }
+
+ pub fn disconnect(&mut self, p: ClientboundDisconnect) {
+ warn!("Got disconnect packet {p:?}");
+ as_system::<EventWriter<_>>(self.ecs, |mut events| {
+ events.send(DisconnectEvent {
+ entity: self.player,
+ reason: Some(p.reason),
+ });
+ });
+ }
+
+ pub fn finish_configuration(&mut self, p: ClientboundFinishConfiguration) {
+ debug!("got FinishConfiguration packet: {p:?}");
+
+ as_system::<(Commands, Query<&mut RawConnection>)>(
+ self.ecs,
+ |(mut commands, mut query)| {
+ let mut raw_conn = query.get_mut(self.player).unwrap();
+
+ raw_conn
+ .write_packet(ServerboundFinishConfiguration)
+ .expect(
+ "we should be in the right state and encoding this packet shouldn't fail",
+ );
+ raw_conn.set_state(ConnectionProtocol::Game);
+
+ // these components are added now that we're going to be in the Game state
+ commands
+ .entity(self.player)
+ .remove::<InConfigState>()
+ .insert(crate::JoinedClientBundle::default());
+ },
+ );
+ }
+
+ pub fn keep_alive(&mut self, p: ClientboundKeepAlive) {
+ debug!(
+ "Got keep alive packet (in configuration) {p:?} for {:?}",
+ self.player
+ );
+
+ as_system::<(Query<&RawConnection>, EventWriter<_>)>(self.ecs, |(query, mut events)| {
+ let raw_conn = query.get(self.player).unwrap();
+
+ events.send(KeepAliveEvent {
+ entity: self.player,
+ id: p.id,
+ });
+ raw_conn
+ .write_packet(ServerboundKeepAlive { id: p.id })
+ .unwrap();
+ });
+ }
+
+ pub fn ping(&mut self, p: ClientboundPing) {
+ debug!("Got ping packet (in configuration) {p:?}");
+
+ as_system::<Query<&RawConnection>>(self.ecs, |query| {
+ let raw_conn = query.get(self.player).unwrap();
+
+ raw_conn.write_packet(ServerboundPong { id: p.id }).unwrap();
+ });
+ }
+
+ pub fn resource_pack_push(&mut self, p: ClientboundResourcePackPush) {
+ debug!("Got resource pack push packet {p:?}");
+
+ as_system::<Query<&RawConnection>>(self.ecs, |query| {
+ let raw_conn = query.get(self.player).unwrap();
+
+ // always accept resource pack
+ raw_conn
+ .write_packet(ServerboundResourcePack {
+ id: p.id,
+ action: s_resource_pack::Action::Accepted,
+ })
+ .unwrap();
+ });
+ }
+
+ pub fn resource_pack_pop(&mut self, p: ClientboundResourcePackPop) {
+ debug!("Got resource pack pop packet {p:?}");
+ }
+
+ pub fn update_enabled_features(&mut self, p: ClientboundUpdateEnabledFeatures) {
+ debug!("Got update enabled features packet {p:?}");
+ }
+
+ pub fn update_tags(&mut self, _p: ClientboundUpdateTags) {
+ debug!("Got update tags packet");
+ }
+
+ pub fn cookie_request(&mut self, p: ClientboundCookieRequest) {
+ debug!("Got cookie request packet {p:?}");
+
+ as_system::<Query<&RawConnection>>(self.ecs, |query| {
+ let raw_conn = query.get(self.player).unwrap();
+
+ raw_conn
+ .write_packet(ServerboundCookieResponse {
+ key: p.key,
+ // cookies aren't implemented
+ payload: None,
+ })
+ .unwrap();
+ });
+ }
+
+ pub fn reset_chat(&mut self, p: ClientboundResetChat) {
+ debug!("Got reset chat packet {p:?}");
+ }
+
+ pub fn store_cookie(&mut self, p: ClientboundStoreCookie) {
+ debug!("Got store cookie packet {p:?}");
+ }
+
+ pub fn transfer(&mut self, p: ClientboundTransfer) {
+ debug!("Got transfer packet {p:?}");
+ }
+
+ pub fn select_known_packs(&mut self, p: ClientboundSelectKnownPacks) {
+ debug!("Got select known packs packet {p:?}");
+
+ as_system::<Query<&RawConnection>>(self.ecs, |query| {
+ let raw_conn = query.get(self.player).unwrap();
+
+ // resource pack management isn't implemented
+ raw_conn
+ .write_packet(ServerboundSelectKnownPacks {
+ known_packs: vec![],
+ })
+ .unwrap();
+ });
+ }
+
+ pub fn server_links(&mut self, p: ClientboundServerLinks) {
+ debug!("Got server links packet {p:?}");
+ }
+
+ pub fn custom_report_details(&mut self, p: ClientboundCustomReportDetails) {
+ debug!("Got custom report details packet {p:?}");
+ }
+}
diff --git a/azalea-client/src/plugins/packet/game/events.rs b/azalea-client/src/plugins/packet/game/events.rs
new file mode 100644
index 00000000..19f2a571
--- /dev/null
+++ b/azalea-client/src/plugins/packet/game/events.rs
@@ -0,0 +1,178 @@
+use std::{
+ io::Cursor,
+ sync::{Arc, Weak},
+};
+
+use azalea_chat::FormattedText;
+use azalea_core::resource_location::ResourceLocation;
+use azalea_entity::LocalEntity;
+use azalea_protocol::{
+ packets::{
+ Packet,
+ game::{ClientboundGamePacket, ClientboundPlayerCombatKill, ServerboundGamePacket},
+ },
+ read::deserialize_packet,
+};
+use azalea_world::Instance;
+use bevy_ecs::prelude::*;
+use parking_lot::RwLock;
+use tracing::{debug, error};
+use uuid::Uuid;
+
+use crate::{PlayerInfo, raw_connection::RawConnection};
+
+/// An event that's sent when we receive a packet.
+/// ```
+/// # use azalea_client::packet::game::ReceivePacketEvent;
+/// # use azalea_protocol::packets::game::ClientboundGamePacket;
+/// # use bevy_ecs::event::EventReader;
+///
+/// fn handle_packets(mut events: EventReader<ReceivePacketEvent>) {
+/// for ReceivePacketEvent {
+/// entity,
+/// packet,
+/// } in events.read() {
+/// match packet.as_ref() {
+/// ClientboundGamePacket::LevelParticles(p) => {
+/// // ...
+/// }
+/// _ => {}
+/// }
+/// }
+/// }
+/// ```
+#[derive(Event, Debug, Clone)]
+pub struct ReceivePacketEvent {
+ /// The client entity that received the packet.
+ pub entity: Entity,
+ /// The packet that was actually received.
+ pub packet: Arc<ClientboundGamePacket>,
+}
+
+/// An event for sending a packet to the server while we're in the `game` state.
+#[derive(Event)]
+pub struct SendPacketEvent {
+ pub sent_by: Entity,
+ pub packet: ServerboundGamePacket,
+}
+impl SendPacketEvent {
+ pub fn new(sent_by: Entity, packet: impl Packet<ServerboundGamePacket>) -> Self {
+ let packet = packet.into_variant();
+ Self { sent_by, packet }
+ }
+}
+
+pub fn handle_outgoing_packets(
+ mut send_packet_events: EventReader<SendPacketEvent>,
+ mut query: Query<&mut RawConnection>,
+) {
+ for event in send_packet_events.read() {
+ if let Ok(raw_connection) = query.get_mut(event.sent_by) {
+ // debug!("Sending packet: {:?}", event.packet);
+ if let Err(e) = raw_connection.write_packet(event.packet.clone()) {
+ error!("Failed to send packet: {e}");
+ }
+ }
+ }
+}
+
+pub fn send_receivepacketevent(
+ query: Query<(Entity, &RawConnection), With<LocalEntity>>,
+ mut packet_events: ResMut<Events<ReceivePacketEvent>>,
+) {
+ // we manually clear and send the events at the beginning of each update
+ // since otherwise it'd cause issues with events in process_packet_events
+ // running twice
+ packet_events.clear();
+ for (player_entity, raw_connection) in &query {
+ let packets_lock = raw_connection.incoming_packet_queue();
+ let mut packets = packets_lock.lock();
+ if !packets.is_empty() {
+ for raw_packet in packets.iter() {
+ let packet =
+ match deserialize_packet::<ClientboundGamePacket>(&mut Cursor::new(raw_packet))
+ {
+ Ok(packet) => packet,
+ Err(err) => {
+ error!("failed to read packet: {err:?}");
+ debug!("packet bytes: {raw_packet:?}");
+ continue;
+ }
+ };
+ packet_events.send(ReceivePacketEvent {
+ entity: player_entity,
+ packet: Arc::new(packet),
+ });
+ }
+ // clear the packets right after we read them
+ packets.clear();
+ }
+ }
+}
+
+/// A player joined the game (or more specifically, was added to the tab
+/// list of a local player).
+#[derive(Event, Debug, Clone)]
+pub struct AddPlayerEvent {
+ /// The local player entity that received this event.
+ pub entity: Entity,
+ pub info: PlayerInfo,
+}
+/// A player left the game (or maybe is still in the game and was just
+/// removed from the tab list of a local player).
+#[derive(Event, Debug, Clone)]
+pub struct RemovePlayerEvent {
+ /// The local player entity that received this event.
+ pub entity: Entity,
+ pub info: PlayerInfo,
+}
+/// A player was updated in the tab list of a local player (gamemode, display
+/// name, or latency changed).
+#[derive(Event, Debug, Clone)]
+pub struct UpdatePlayerEvent {
+ /// The local player entity that received this event.
+ pub entity: Entity,
+ pub info: PlayerInfo,
+}
+
+/// Event for when an entity dies. dies. If it's a local player and there's a
+/// reason in the death screen, the [`ClientboundPlayerCombatKill`] will
+/// be included.
+#[derive(Event, Debug, Clone)]
+pub struct DeathEvent {
+ pub entity: Entity,
+ pub packet: Option<ClientboundPlayerCombatKill>,
+}
+
+/// A KeepAlive packet is sent from the server to verify that the client is
+/// still connected.
+#[derive(Event, Debug, Clone)]
+pub struct KeepAliveEvent {
+ pub entity: Entity,
+ /// The ID of the keepalive. This is an arbitrary number, but vanilla
+ /// servers use the time to generate this.
+ pub id: u64,
+}
+
+#[derive(Event, Debug, Clone)]
+pub struct ResourcePackEvent {
+ pub entity: Entity,
+ /// The random ID for this request to download the resource pack. The packet
+ /// for replying to a resource pack push must contain the same ID.
+ pub id: Uuid,
+ pub url: String,
+ pub hash: String,
+ pub required: bool,
+ pub prompt: Option<FormattedText>,
+}
+
+/// An instance (aka world, dimension) was loaded by a client.
+///
+/// Since the instance is given to you as a weak reference, it won't be able to
+/// be `upgrade`d if all local players leave it.
+#[derive(Event, Debug, Clone)]
+pub struct InstanceLoadedEvent {
+ pub entity: Entity,
+ pub name: ResourceLocation,
+ pub instance: Weak<RwLock<Instance>>,
+}
diff --git a/azalea-client/src/plugins/packet/game/mod.rs b/azalea-client/src/plugins/packet/game/mod.rs
new file mode 100644
index 00000000..98f76d13
--- /dev/null
+++ b/azalea-client/src/plugins/packet/game/mod.rs
@@ -0,0 +1,1583 @@
+mod events;
+
+use std::{collections::HashSet, ops::Add, sync::Arc};
+
+use azalea_core::{
+ game_type::GameMode,
+ math,
+ position::{ChunkPos, Vec3},
+};
+use azalea_entity::{
+ Dead, EntityBundle, EntityKind, LastSentPosition, LoadedBy, LocalEntity, LookDirection,
+ Physics, Position, RelativeEntityUpdate,
+ indexing::{EntityIdIndex, EntityUuidIndex},
+ metadata::{Health, apply_metadata},
+};
+use azalea_protocol::packets::game::*;
+use azalea_world::{InstanceContainer, InstanceName, MinecraftEntityId, PartialInstance};
+use bevy_ecs::{prelude::*, system::SystemState};
+pub use events::*;
+use tracing::{debug, error, trace, warn};
+
+use crate::{
+ ClientInformation, PlayerInfo,
+ chat::{ChatPacket, ChatReceivedEvent},
+ chunks, declare_packet_handlers,
+ disconnect::DisconnectEvent,
+ inventory::{
+ ClientSideCloseContainerEvent, Inventory, MenuOpenedEvent, SetContainerContentEvent,
+ },
+ local_player::{
+ GameProfileComponent, Hunger, InstanceHolder, LocalGameMode, PlayerAbilities, TabList,
+ },
+ movement::{KnockbackEvent, KnockbackType},
+ packet::as_system,
+};
+
+pub fn process_packet_events(ecs: &mut World) {
+ let mut events_owned = Vec::<(Entity, Arc<ClientboundGamePacket>)>::new();
+
+ {
+ let mut system_state = SystemState::<EventReader<ReceivePacketEvent>>::new(ecs);
+ let mut events = system_state.get_mut(ecs);
+ for ReceivePacketEvent {
+ entity: player_entity,
+ packet,
+ } in events.read()
+ {
+ // we do this so `ecs` isn't borrowed for the whole loop
+ events_owned.push((*player_entity, packet.clone()));
+ }
+ }
+
+ for (player_entity, packet) in events_owned {
+ let mut handler = GamePacketHandler {
+ player: player_entity,
+ ecs,
+ };
+
+ declare_packet_handlers!(
+ ClientboundGamePacket,
+ packet.as_ref(),
+ handler,
+ [
+ login,
+ set_chunk_cache_radius,
+ chunk_batch_start,
+ chunk_batch_finished,
+ custom_payload,
+ change_difficulty,
+ commands,
+ player_abilities,
+ set_cursor_item,
+ update_tags,
+ disconnect,
+ update_recipes,
+ entity_event,
+ player_position,
+ player_info_update,
+ player_info_remove,
+ set_chunk_cache_center,
+ chunks_biomes,
+ light_update,
+ level_chunk_with_light,
+ add_entity,
+ set_entity_data,
+ update_attributes,
+ set_entity_motion,
+ set_entity_link,
+ initialize_border,
+ set_time,
+ set_default_spawn_position,
+ set_health,
+ set_experience,
+ teleport_entity,
+ update_advancements,
+ rotate_head,
+ move_entity_pos,
+ move_entity_pos_rot,
+ move_entity_rot,
+ keep_alive,
+ remove_entities,
+ player_chat,
+ system_chat,
+ disguised_chat,
+ sound,
+ level_event,
+ block_update,
+ animate,
+ section_blocks_update,
+ game_event,
+ level_particles,
+ server_data,
+ set_equipment,
+ update_mob_effect,
+ add_experience_orb,
+ award_stats,
+ block_changed_ack,
+ block_destruction,
+ block_entity_data,
+ block_event,
+ boss_event,
+ command_suggestions,
+ container_set_content,
+ container_set_data,
+ container_set_slot,
+ container_close,
+ cooldown,
+ custom_chat_completions,
+ delete_chat,
+ explode,
+ forget_level_chunk,
+ horse_screen_open,
+ map_item_data,
+ merchant_offers,
+ move_vehicle,
+ open_book,
+ open_screen,
+ open_sign_editor,
+ ping,
+ place_ghost_recipe,
+ player_combat_end,
+ player_combat_enter,
+ player_combat_kill,
+ player_look_at,
+ remove_mob_effect,
+ resource_pack_push,
+ resource_pack_pop,
+ respawn,
+ start_configuration,
+ entity_position_sync,
+ select_advancements_tab,
+ set_action_bar_text,
+ set_border_center,
+ set_border_lerp_size,
+ set_border_size,
+ set_border_warning_delay,
+ set_border_warning_distance,
+ set_camera,
+ set_display_objective,
+ set_objective,
+ set_passengers,
+ set_player_team,
+ set_score,
+ set_simulation_distance,
+ set_subtitle_text,
+ set_title_text,
+ set_titles_animation,
+ clear_titles,
+ sound_entity,
+ stop_sound,
+ tab_list,
+ tag_query,
+ take_item_entity,
+ bundle_delimiter,
+ damage_event,
+ hurt_animation,
+ ticking_state,
+ ticking_step,
+ reset_score,
+ cookie_request,
+ debug_sample,
+ pong_response,
+ store_cookie,
+ transfer,
+ move_minecart_along_track,
+ set_held_slot,
+ set_player_inventory,
+ projectile_power,
+ custom_report_details,
+ server_links,
+ player_rotation,
+ recipe_book_add,
+ recipe_book_remove,
+ recipe_book_settings,
+ ]
+ );
+ }
+}
+
+pub struct GamePacketHandler<'a> {
+ pub ecs: &'a mut World,
+ pub player: Entity,
+}
+impl GamePacketHandler<'_> {
+ pub fn login(&mut self, p: &ClientboundLogin) {
+ debug!("Got login packet");
+
+ as_system::<(
+ Commands,
+ Query<(
+ &GameProfileComponent,
+ &ClientInformation,
+ Option<&mut InstanceName>,
+ Option<&mut LoadedBy>,
+ &mut EntityIdIndex,
+ &mut InstanceHolder,
+ )>,
+ EventWriter<InstanceLoadedEvent>,
+ ResMut<InstanceContainer>,
+ ResMut<EntityUuidIndex>,
+ EventWriter<SendPacketEvent>,
+ )>(
+ self.ecs,
+ |(
+ mut commands,
+ mut query,
+ mut instance_loaded_events,
+ mut instance_container,
+ mut entity_uuid_index,
+ mut send_packet_events,
+ )| {
+ let (
+ game_profile,
+ client_information,
+ instance_name,
+ loaded_by,
+ mut entity_id_index,
+ mut instance_holder,
+ ) = query.get_mut(self.player).unwrap();
+
+ let new_instance_name = p.common.dimension.clone();
+
+ if let Some(mut instance_name) = instance_name {
+ *instance_name = instance_name.clone();
+ } else {
+ commands
+ .entity(self.player)
+ .insert(InstanceName(new_instance_name.clone()));
+ }
+
+ let Some((_dimension_type, dimension_data)) = p
+ .common
+ .dimension_type(&instance_holder.instance.read().registries)
+ else {
+ return;
+ };
+
+ // add this world to the instance_container (or don't if it's already
+ // there)
+ let weak_instance = instance_container.insert(
+ new_instance_name.clone(),
+ dimension_data.height,
+ dimension_data.min_y,
+ &instance_holder.instance.read().registries,
+ );
+ instance_loaded_events.send(InstanceLoadedEvent {
+ entity: self.player,
+ name: new_instance_name.clone(),
+ instance: Arc::downgrade(&weak_instance),
+ });
+
+ // set the partial_world to an empty world
+ // (when we add chunks or entities those will be in the
+ // instance_container)
+
+ *instance_holder.partial_instance.write() = PartialInstance::new(
+ azalea_world::chunk_storage::calculate_chunk_storage_range(
+ client_information.view_distance.into(),
+ ),
+ // this argument makes it so other clients don't update this player entity
+ // in a shared instance
+ Some(self.player),
+ );
+ {
+ let map = instance_holder.instance.read().registries.map.clone();
+ let new_registries = &mut weak_instance.write().registries;
+ // add the registries from this instance to the weak instance
+ for (registry_name, registry) in map {
+ new_registries.map.insert(registry_name, registry);
+ }
+ }
+ instance_holder.instance = weak_instance;
+
+ let entity_bundle = EntityBundle::new(
+ game_profile.uuid,
+ Vec3::default(),
+ azalea_registry::EntityKind::Player,
+ new_instance_name,
+ );
+ let entity_id = p.player_id;
+ // insert our components into the ecs :)
+ commands.entity(self.player).insert((
+ entity_id,
+ LocalGameMode {
+ current: p.common.game_type,
+ previous: p.common.previous_game_type.into(),
+ },
+ entity_bundle,
+ ));
+
+ azalea_entity::indexing::add_entity_to_indexes(
+ entity_id,
+ self.player,
+ Some(game_profile.uuid),
+ &mut entity_id_index,
+ &mut entity_uuid_index,
+ &mut instance_holder.instance.write(),
+ );
+
+ // update or insert loaded_by
+ if let Some(mut loaded_by) = loaded_by {
+ loaded_by.insert(self.player);
+ } else {
+ commands
+ .entity(self.player)
+ .insert(LoadedBy(HashSet::from_iter(vec![self.player])));
+ }
+
+ // send the client information that we have set
+ debug!(
+ "Sending client information because login: {:?}",
+ client_information
+ );
+ send_packet_events.send(SendPacketEvent::new(self.player,
+ azalea_protocol::packets::game::s_client_information::ServerboundClientInformation { information: client_information.clone() },
+ ));
+ },
+ );
+ }
+
+ pub fn set_chunk_cache_radius(&mut self, p: &ClientboundSetChunkCacheRadius) {
+ debug!("Got set chunk cache radius packet {p:?}");
+ }
+
+ pub fn chunk_batch_start(&mut self, _p: &ClientboundChunkBatchStart) {
+ // the packet is empty, it's just a marker to tell us when the batch starts and
+ // ends
+ debug!("Got chunk batch start");
+
+ as_system::<EventWriter<_>>(self.ecs, |mut events| {
+ events.send(chunks::ChunkBatchStartEvent {
+ entity: self.player,
+ });
+ });
+ }
+
+ pub fn chunk_batch_finished(&mut self, p: &ClientboundChunkBatchFinished) {
+ debug!("Got chunk batch finished {p:?}");
+
+ as_system::<EventWriter<_>>(self.ecs, |mut events| {
+ events.send(chunks::ChunkBatchFinishedEvent {
+ entity: self.player,
+ batch_size: p.batch_size,
+ });
+ });
+ }
+
+ pub fn custom_payload(&mut self, p: &ClientboundCustomPayload) {
+ debug!("Got custom payload packet {p:?}");
+ }
+
+ pub fn change_difficulty(&mut self, p: &ClientboundChangeDifficulty) {
+ debug!("Got difficulty packet {p:?}");
+ }
+
+ pub fn commands(&mut self, _p: &ClientboundCommands) {
+ debug!("Got declare commands packet");
+ }
+
+ pub fn player_abilities(&mut self, p: &ClientboundPlayerAbilities) {
+ debug!("Got player abilities packet {p:?}");
+
+ as_system::<Query<&mut PlayerAbilities>>(self.ecs, |mut query| {
+ let mut player_abilities = query.get_mut(self.player).unwrap();
+
+ *player_abilities = PlayerAbilities::from(p);
+ });
+ }
+
+ pub fn set_cursor_item(&mut self, p: &ClientboundSetCursorItem) {
+ debug!("Got set cursor item packet {p:?}");
+ }
+
+ pub fn update_tags(&mut self, _p: &ClientboundUpdateTags) {
+ debug!("Got update tags packet");
+ }
+
+ pub fn disconnect(&mut self, p: &ClientboundDisconnect) {
+ warn!("Got disconnect packet {p:?}");
+
+ as_system::<EventWriter<_>>(self.ecs, |mut events| {
+ events.send(DisconnectEvent {
+ entity: self.player,
+ reason: Some(p.reason.clone()),
+ });
+ });
+ }
+
+ pub fn update_recipes(&mut self, _p: &ClientboundUpdateRecipes) {
+ debug!("Got update recipes packet");
+ }
+
+ pub fn entity_event(&mut self, _p: &ClientboundEntityEvent) {
+ // debug!("Got entity event packet {p:?}");
+ }
+
+ pub fn player_position(&mut self, p: &ClientboundPlayerPosition) {
+ debug!("Got player position packet {p:?}");
+
+ as_system::<(
+ Query<(
+ &mut Physics,
+ &mut LookDirection,
+ &mut Position,
+ &mut LastSentPosition,
+ )>,
+ EventWriter<SendPacketEvent>,
+ )>(self.ecs, |(mut query, mut send_packet_events)| {
+ let Ok((mut physics, mut direction, mut position, mut last_sent_position)) =
+ query.get_mut(self.player)
+ else {
+ return;
+ };
+
+ **last_sent_position = **position;
+
+ fn apply_change<T: Add<Output = T>>(base: T, condition: bool, change: T) -> T {
+ if condition { base + change } else { change }
+ }
+
+ let new_x = apply_change(position.x, p.relative.x, p.change.pos.x);
+ let new_y = apply_change(position.y, p.relative.y, p.change.pos.y);
+ let new_z = apply_change(position.z, p.relative.z, p.change.pos.z);
+
+ let new_y_rot = apply_change(
+ direction.y_rot,
+ p.relative.y_rot,
+ p.change.look_direction.y_rot,
+ );
+ let new_x_rot = apply_change(
+ direction.x_rot,
+ p.relative.x_rot,
+ p.change.look_direction.x_rot,
+ );
+
+ let mut new_delta_from_rotations = physics.velocity;
+ if p.relative.rotate_delta {
+ let y_rot_delta = direction.y_rot - new_y_rot;
+ let x_rot_delta = direction.x_rot - new_x_rot;
+ new_delta_from_rotations = new_delta_from_rotations
+ .x_rot(math::to_radians(x_rot_delta as f64) as f32)
+ .y_rot(math::to_radians(y_rot_delta as f64) as f32);
+ }
+
+ let new_delta = Vec3::new(
+ apply_change(
+ new_delta_from_rotations.x,
+ p.relative.delta_x,
+ p.change.delta.x,
+ ),
+ apply_change(
+ new_delta_from_rotations.y,
+ p.relative.delta_y,
+ p.change.delta.y,
+ ),
+ apply_change(
+ new_delta_from_rotations.z,
+ p.relative.delta_z,
+ p.change.delta.z,
+ ),
+ );
+
+ // apply the updates
+
+ physics.velocity = new_delta;
+
+ (direction.y_rot, direction.x_rot) = (new_y_rot, new_x_rot);
+
+ let new_pos = Vec3::new(new_x, new_y, new_z);
+ if new_pos != **position {
+ **position = new_pos;
+ }
+
+ // old_pos is set to the current position when we're teleported
+ physics.set_old_pos(&position);
+
+ // send the relevant packets
+
+ send_packet_events.send(SendPacketEvent::new(
+ self.player,
+ ServerboundAcceptTeleportation { id: p.id },
+ ));
+ send_packet_events.send(SendPacketEvent::new(
+ self.player,
+ ServerboundMovePlayerPosRot {
+ pos: new_pos,
+ look_direction: LookDirection::new(new_y_rot, new_x_rot),
+ // this is always false
+ on_ground: false,
+ },
+ ));
+ });
+ }
+
+ pub fn player_info_update(&mut self, p: &ClientboundPlayerInfoUpdate) {
+ debug!("Got player info packet {p:?}");
+
+ as_system::<(
+ Query<&mut TabList>,
+ EventWriter<AddPlayerEvent>,
+ EventWriter<UpdatePlayerEvent>,
+ ResMut<TabList>,
+ )>(
+ self.ecs,
+ |(
+ mut query,
+ mut add_player_events,
+ mut update_player_events,
+ mut tab_list_resource,
+ )| {
+ let mut tab_list = query.get_mut(self.player).unwrap();
+
+ for updated_info in &p.entries {
+ // add the new player maybe
+ if p.actions.add_player {
+ let info = PlayerInfo {
+ profile: updated_info.profile.clone(),
+ uuid: updated_info.profile.uuid,
+ gamemode: updated_info.game_mode,
+ latency: updated_info.latency,
+ display_name: updated_info.display_name.clone(),
+ };
+ tab_list.insert(updated_info.profile.uuid, info.clone());
+ add_player_events.send(AddPlayerEvent {
+ entity: self.player,
+ info: info.clone(),
+ });
+ } else if let Some(info) = tab_list.get_mut(&updated_info.profile.uuid) {
+ // `else if` because the block for add_player above
+ // already sets all the fields
+ if p.actions.update_game_mode {
+ info.gamemode = updated_info.game_mode;
+ }
+ if p.actions.update_latency {
+ info.latency = updated_info.latency;
+ }
+ if p.actions.update_display_name {
+ info.display_name.clone_from(&updated_info.display_name);
+ }
+ update_player_events.send(UpdatePlayerEvent {
+ entity: self.player,
+ info: info.clone(),
+ });
+ } else {
+ let uuid = updated_info.profile.uuid;
+ debug!("Ignoring PlayerInfoUpdate for unknown player {uuid}");
+ }
+ }
+
+ *tab_list_resource = tab_list.clone();
+ },
+ );
+ }
+
+ pub fn player_info_remove(&mut self, p: &ClientboundPlayerInfoRemove) {
+ debug!("Got chunk cache center packet {p:?}");
+
+ as_system::<(
+ Query<&mut TabList>,
+ EventWriter<RemovePlayerEvent>,
+ ResMut<TabList>,
+ )>(
+ self.ecs,
+ |(mut query, mut remove_player_events, mut tab_list_resource)| {
+ let mut tab_list = query.get_mut(self.player).unwrap();
+
+ for uuid in &p.profile_ids {
+ if let Some(info) = tab_list.remove(uuid) {
+ remove_player_events.send(RemovePlayerEvent {
+ entity: self.player,
+ info,
+ });
+ }
+ tab_list_resource.remove(uuid);
+ }
+ },
+ );
+ }
+
+ pub fn set_chunk_cache_center(&mut self, p: &ClientboundSetChunkCacheCenter) {
+ debug!("Got chunk cache center packet {p:?}");
+
+ as_system::<Query<&mut InstanceHolder>>(self.ecs, |mut query| {
+ let instance_holder = query.get_mut(self.player).unwrap();
+ let mut partial_world = instance_holder.partial_instance.write();
+
+ partial_world
+ .chunks
+ .update_view_center(ChunkPos::new(p.x, p.z));
+ });
+ }
+
+ pub fn chunks_biomes(&mut self, _p: &ClientboundChunksBiomes) {}
+
+ pub fn light_update(&mut self, _p: &ClientboundLightUpdate) {
+ // debug!("Got light update packet {p:?}");
+ }
+
+ pub fn level_chunk_with_light(&mut self, p: &ClientboundLevelChunkWithLight) {
+ debug!("Got chunk with light packet {} {}", p.x, p.z);
+
+ as_system::<EventWriter<_>>(self.ecs, |mut events| {
+ events.send(chunks::ReceiveChunkEvent {
+ entity: self.player,
+ packet: p.clone(),
+ });
+ });
+ }
+
+ pub fn add_entity(&mut self, p: &ClientboundAddEntity) {
+ debug!("Got add entity packet {p:?}");
+
+ as_system::<(
+ Commands,
+ Query<(&mut EntityIdIndex, Option<&InstanceName>, Option<&TabList>)>,
+ Query<&mut LoadedBy>,
+ Query<Entity>,
+ Res<InstanceContainer>,
+ ResMut<EntityUuidIndex>,
+ )>(
+ self.ecs,
+ |(
+ mut commands,
+ mut query,
+ mut loaded_by_query,
+ entity_query,
+ instance_container,
+ mut entity_uuid_index,
+ )| {
+ let (mut entity_id_index, instance_name, tab_list) =
+ query.get_mut(self.player).unwrap();
+
+ let entity_id = p.id;
+
+ let Some(instance_name) = instance_name else {
+ warn!("got add player packet but we haven't gotten a login packet yet");
+ return;
+ };
+
+ // check if the entity already exists, and if it does then only add to LoadedBy
+ let instance = instance_container.get(instance_name).unwrap();
+ if let Some(&ecs_entity) = instance.read().entity_by_id.get(&entity_id) {
+ // entity already exists
+ let Ok(mut loaded_by) = loaded_by_query.get_mut(ecs_entity) else {
+ // LoadedBy for this entity isn't in the ecs! figure out what went wrong
+ // and print an error
+
+ let entity_in_ecs = entity_query.get(ecs_entity).is_ok();
+
+ if entity_in_ecs {
+ error!(
+ "LoadedBy for entity {entity_id:?} ({ecs_entity:?}) isn't in the ecs, but the entity is in entity_by_id"
+ );
+ } else {
+ error!(
+ "Entity {entity_id:?} ({ecs_entity:?}) isn't in the ecs, but the entity is in entity_by_id"
+ );
+ }
+ return;
+ };
+ loaded_by.insert(self.player);
+
+ // per-client id index
+ entity_id_index.insert(entity_id, ecs_entity);
+
+ debug!("added to LoadedBy of entity {ecs_entity:?} with id {entity_id:?}");
+ return;
+ };
+
+ // entity doesn't exist in the global index!
+
+ let bundle = p.as_entity_bundle((**instance_name).clone());
+ let mut spawned =
+ commands.spawn((entity_id, LoadedBy(HashSet::from([self.player])), bundle));
+ let ecs_entity: Entity = spawned.id();
+ debug!("spawned entity {ecs_entity:?} with id {entity_id:?}");
+
+ azalea_entity::indexing::add_entity_to_indexes(
+ entity_id,
+ ecs_entity,
+ Some(p.uuid),
+ &mut entity_id_index,
+ &mut entity_uuid_index,
+ &mut instance.write(),
+ );
+
+ // add the GameProfileComponent if the uuid is in the tab list
+ if let Some(tab_list) = tab_list {
+ // (technically this makes it possible for non-player entities to have
+ // GameProfileComponents but the server would have to be doing something
+ // really weird)
+ if let Some(player_info) = tab_list.get(&p.uuid) {
+ spawned.insert(GameProfileComponent(player_info.profile.clone()));
+ }
+ }
+
+ // the bundle doesn't include the default entity metadata so we add that
+ // separately
+ p.apply_metadata(&mut spawned);
+ },
+ );
+ }
+
+ pub fn set_entity_data(&mut self, p: &ClientboundSetEntityData) {
+ as_system::<(
+ Commands,
+ Query<(&EntityIdIndex, &InstanceHolder)>,
+ // this is a separate query since it's applied on the entity id that's being updated
+ // instead of the player that received the packet
+ Query<&EntityKind>,
+ )>(self.ecs, |(mut commands, query, entity_kind_query)| {
+ let (entity_id_index, instance_holder) = query.get(self.player).unwrap();
+
+ let entity = entity_id_index.get(p.id);
+
+ let Some(entity) = entity else {
+ // some servers like hypixel trigger this a lot :(
+ debug!(
+ "Server sent an entity data packet for an entity id ({}) that we don't know about",
+ p.id
+ );
+ return;
+ };
+
+ let entity_kind = *entity_kind_query
+ .get(entity)
+ .expect("EntityKind component should always be present for entities");
+
+ debug!("Got set entity data packet {p:?} for entity of kind {entity_kind:?}");
+
+ let packed_items = p.packed_items.clone().to_vec();
+
+ // we use RelativeEntityUpdate because it makes sure changes aren't made
+ // multiple times
+ commands.entity(entity).queue(RelativeEntityUpdate {
+ partial_world: instance_holder.partial_instance.clone(),
+ update: Box::new(move |entity| {
+ let entity_id = entity.id();
+ entity.world_scope(|world| {
+ let mut commands_system_state = SystemState::<Commands>::new(world);
+ let mut commands = commands_system_state.get_mut(world);
+ let mut entity_commands = commands.entity(entity_id);
+ if let Err(e) =
+ apply_metadata(&mut entity_commands, *entity_kind, packed_items)
+ {
+ warn!("{e}");
+ }
+ commands_system_state.apply(world);
+ });
+ }),
+ });
+ });
+ }
+
+ pub fn update_attributes(&mut self, _p: &ClientboundUpdateAttributes) {
+ // debug!("Got update attributes packet {p:?}");
+ }
+
+ pub fn set_entity_motion(&mut self, p: &ClientboundSetEntityMotion) {
+ // vanilla servers use this packet for knockback, but note that the Explode
+ // packet is also sometimes used by servers for knockback
+
+ as_system::<(Commands, Query<(&EntityIdIndex, &InstanceHolder)>)>(
+ self.ecs,
+ |(mut commands, query)| {
+ let (entity_id_index, instance_holder) = query.get(self.player).unwrap();
+
+ let Some(entity) = entity_id_index.get(p.id) else {
+ // note that this log (and some other ones like the one in RemoveEntities)
+ // sometimes happens when killing mobs. it seems to be a vanilla bug, which is
+ // why it's a debug log instead of a warning
+ debug!(
+ "Got set entity motion packet for unknown entity id {}",
+ p.id
+ );
+ return;
+ };
+
+ // this is to make sure the same entity velocity update doesn't get sent
+ // multiple times when in swarms
+
+ let knockback = KnockbackType::Set(Vec3 {
+ x: p.delta.xa as f64 / 8000.,
+ y: p.delta.ya as f64 / 8000.,
+ z: p.delta.za as f64 / 8000.,
+ });
+
+ commands.entity(entity).queue(RelativeEntityUpdate {
+ partial_world: instance_holder.partial_instance.clone(),
+ update: Box::new(move |entity_mut| {
+ entity_mut.world_scope(|world| {
+ world.send_event(KnockbackEvent { entity, knockback })
+ });
+ }),
+ });
+ },
+ );
+ }
+
+ pub fn set_entity_link(&mut self, p: &ClientboundSetEntityLink) {
+ debug!("Got set entity link packet {p:?}");
+ }
+
+ pub fn initialize_border(&mut self, p: &ClientboundInitializeBorder) {
+ debug!("Got initialize border packet {p:?}");
+ }
+
+ pub fn set_time(&mut self, _p: &ClientboundSetTime) {
+ // debug!("Got set time packet {p:?}");
+ }
+
+ pub fn set_default_spawn_position(&mut self, p: &ClientboundSetDefaultSpawnPosition) {
+ debug!("Got set default spawn position packet {p:?}");
+ }
+
+ pub fn set_health(&mut self, p: &ClientboundSetHealth) {
+ debug!("Got set health packet {p:?}");
+
+ as_system::<Query<(&mut Health, &mut Hunger)>>(self.ecs, |mut query| {
+ let (mut health, mut hunger) = query.get_mut(self.player).unwrap();
+
+ **health = p.health;
+ (hunger.food, hunger.saturation) = (p.food, p.saturation);
+
+ // the `Dead` component is added by the `update_dead` system
+ // in azalea-world and then the `dead_event` system fires
+ // the Death event.
+ });
+ }
+
+ pub fn set_experience(&mut self, p: &ClientboundSetExperience) {
+ debug!("Got set experience packet {p:?}");
+ }
+
+ pub fn teleport_entity(&mut self, p: &ClientboundTeleportEntity) {
+ as_system::<(Commands, Query<(&EntityIdIndex, &InstanceHolder)>)>(
+ self.ecs,
+ |(mut commands, mut query)| {
+ let (entity_id_index, instance_holder) = query.get_mut(self.player).unwrap();
+
+ let Some(entity) = entity_id_index.get(p.id) else {
+ warn!("Got teleport entity packet for unknown entity id {}", p.id);
+ return;
+ };
+
+ let new_pos = p.change.pos;
+ let new_look_direction = LookDirection {
+ x_rot: (p.change.look_direction.x_rot as i32 * 360) as f32 / 256.,
+ y_rot: (p.change.look_direction.y_rot as i32 * 360) as f32 / 256.,
+ };
+ commands.entity(entity).queue(RelativeEntityUpdate {
+ partial_world: instance_holder.partial_instance.clone(),
+ update: Box::new(move |entity| {
+ let mut position = entity.get_mut::<Position>().unwrap();
+ if new_pos != **position {
+ **position = new_pos;
+ }
+ let position = *position;
+ let mut look_direction = entity.get_mut::<LookDirection>().unwrap();
+ if new_look_direction != *look_direction {
+ *look_direction = new_look_direction;
+ }
+ // old_pos is set to the current position when we're teleported
+ let mut physics = entity.get_mut::<Physics>().unwrap();
+ physics.set_old_pos(&position);
+ }),
+ });
+ },
+ );
+ }
+
+ pub fn update_advancements(&mut self, p: &ClientboundUpdateAdvancements) {
+ debug!("Got update advancements packet {p:?}");
+ }
+
+ pub fn rotate_head(&mut self, _p: &ClientboundRotateHead) {}
+
+ pub fn move_entity_pos(&mut self, p: &ClientboundMoveEntityPos) {
+ as_system::<(Commands, Query<(&EntityIdIndex, &InstanceHolder)>)>(
+ self.ecs,
+ |(mut commands, mut query)| {
+ let (entity_id_index, instance_holder) = query.get_mut(self.player).unwrap();
+
+ debug!("Got move entity pos packet {p:?}");
+
+ let Some(entity) = entity_id_index.get(p.entity_id) else {
+ debug!(
+ "Got move entity pos packet for unknown entity id {}",
+ p.entity_id
+ );
+ return;
+ };
+
+ let new_delta = p.delta.clone();
+ let new_on_ground = p.on_ground;
+ commands.entity(entity).queue(RelativeEntityUpdate {
+ partial_world: instance_holder.partial_instance.clone(),
+ update: Box::new(move |entity_mut| {
+ let mut physics = entity_mut.get_mut::<Physics>().unwrap();
+ let new_pos = physics.vec_delta_codec.decode(
+ new_delta.xa as i64,
+ new_delta.ya as i64,
+ new_delta.za as i64,
+ );
+ physics.vec_delta_codec.set_base(new_pos);
+ physics.set_on_ground(new_on_ground);
+
+ let mut position = entity_mut.get_mut::<Position>().unwrap();
+ if new_pos != **position {
+ **position = new_pos;
+ }
+ }),
+ });
+ },
+ );
+ }
+
+ pub fn move_entity_pos_rot(&mut self, p: &ClientboundMoveEntityPosRot) {
+ as_system::<(Commands, Query<(&EntityIdIndex, &InstanceHolder)>)>(
+ self.ecs,
+ |(mut commands, mut query)| {
+ let (entity_id_index, instance_holder) = query.get_mut(self.player).unwrap();
+
+ debug!("Got move entity pos rot packet {p:?}");
+
+ let entity = entity_id_index.get(p.entity_id);
+
+ let Some(entity) = entity else {
+ // often triggered by hypixel :(
+ debug!(
+ "Got move entity pos rot packet for unknown entity id {}",
+ p.entity_id
+ );
+ return;
+ };
+
+ let new_delta = p.delta.clone();
+ let new_look_direction = LookDirection {
+ x_rot: (p.x_rot as i32 * 360) as f32 / 256.,
+ y_rot: (p.y_rot as i32 * 360) as f32 / 256.,
+ };
+
+ let new_on_ground = p.on_ground;
+
+ commands.entity(entity).queue(RelativeEntityUpdate {
+ partial_world: instance_holder.partial_instance.clone(),
+ update: Box::new(move |entity_mut| {
+ let mut physics = entity_mut.get_mut::<Physics>().unwrap();
+ let new_pos = physics.vec_delta_codec.decode(
+ new_delta.xa as i64,
+ new_delta.ya as i64,
+ new_delta.za as i64,
+ );
+ physics.vec_delta_codec.set_base(new_pos);
+ physics.set_on_ground(new_on_ground);
+
+ let mut position = entity_mut.get_mut::<Position>().unwrap();
+ if new_pos != **position {
+ **position = new_pos;
+ }
+
+ let mut look_direction = entity_mut.get_mut::<LookDirection>().unwrap();
+ if new_look_direction != *look_direction {
+ *look_direction = new_look_direction;
+ }
+ }),
+ });
+ },
+ );
+ }
+
+ pub fn move_entity_rot(&mut self, p: &ClientboundMoveEntityRot) {
+ as_system::<(Commands, Query<(&EntityIdIndex, &InstanceHolder)>)>(
+ self.ecs,
+ |(mut commands, mut query)| {
+ let (entity_id_index, instance_holder) = query.get_mut(self.player).unwrap();
+
+ let entity = entity_id_index.get(p.entity_id);
+ if let Some(entity) = entity {
+ let new_look_direction = LookDirection {
+ x_rot: (p.x_rot as i32 * 360) as f32 / 256.,
+ y_rot: (p.y_rot as i32 * 360) as f32 / 256.,
+ };
+ let new_on_ground = p.on_ground;
+
+ commands.entity(entity).queue(RelativeEntityUpdate {
+ partial_world: instance_holder.partial_instance.clone(),
+ update: Box::new(move |entity_mut| {
+ let mut physics = entity_mut.get_mut::<Physics>().unwrap();
+ physics.set_on_ground(new_on_ground);
+
+ let mut look_direction = entity_mut.get_mut::<LookDirection>().unwrap();
+ if new_look_direction != *look_direction {
+ *look_direction = new_look_direction;
+ }
+ }),
+ });
+ } else {
+ warn!(
+ "Got move entity rot packet for unknown entity id {}",
+ p.entity_id
+ );
+ }
+ },
+ );
+ }
+ pub fn keep_alive(&mut self, p: &ClientboundKeepAlive) {
+ debug!("Got keep alive packet {p:?} for {:?}", self.player);
+
+ as_system::<(EventWriter<KeepAliveEvent>, EventWriter<SendPacketEvent>)>(
+ self.ecs,
+ |(mut keepalive_events, mut send_packet_events)| {
+ keepalive_events.send(KeepAliveEvent {
+ entity: self.player,
+ id: p.id,
+ });
+ send_packet_events.send(SendPacketEvent::new(
+ self.player,
+ ServerboundKeepAlive { id: p.id },
+ ));
+ },
+ );
+ }
+
+ pub fn remove_entities(&mut self, p: &ClientboundRemoveEntities) {
+ debug!("Got remove entities packet {p:?}");
+
+ as_system::<(Query<&mut EntityIdIndex>, Query<&mut LoadedBy>)>(
+ self.ecs,
+ |(mut query, mut entity_query)| {
+ let Ok(mut entity_id_index) = query.get_mut(self.player) else {
+ warn!("our local player doesn't have EntityIdIndex");
+ return;
+ };
+
+ for &id in &p.entity_ids {
+ let Some(entity) = entity_id_index.remove(id) else {
+ debug!(
+ "Tried to remove entity with id {id} but it wasn't in the EntityIdIndex"
+ );
+ continue;
+ };
+ let Ok(mut loaded_by) = entity_query.get_mut(entity) else {
+ warn!(
+ "tried to despawn entity {id} but it doesn't have a LoadedBy component",
+ );
+ continue;
+ };
+
+ // the [`remove_despawned_entities_from_indexes`] system will despawn the entity
+ // if it's not loaded by anything anymore
+
+ // also we can't just ecs.despawn because if we're in a swarm then the entity
+ // might still be loaded by another client
+
+ loaded_by.remove(&self.player);
+ }
+ },
+ );
+ }
+ pub fn player_chat(&mut self, p: &ClientboundPlayerChat) {
+ debug!("Got player chat packet {p:?}");
+
+ as_system::<EventWriter<_>>(self.ecs, |mut events| {
+ events.send(ChatReceivedEvent {
+ entity: self.player,
+ packet: ChatPacket::Player(Arc::new(p.clone())),
+ });
+ });
+ }
+
+ pub fn system_chat(&mut self, p: &ClientboundSystemChat) {
+ debug!("Got system chat packet {p:?}");
+
+ as_system::<EventWriter<_>>(self.ecs, |mut events| {
+ events.send(ChatReceivedEvent {
+ entity: self.player,
+ packet: ChatPacket::System(Arc::new(p.clone())),
+ });
+ });
+ }
+
+ pub fn disguised_chat(&mut self, p: &ClientboundDisguisedChat) {
+ debug!("Got disguised chat packet {p:?}");
+
+ as_system::<EventWriter<_>>(self.ecs, |mut events| {
+ events.send(ChatReceivedEvent {
+ entity: self.player,
+ packet: ChatPacket::Disguised(Arc::new(p.clone())),
+ });
+ });
+ }
+
+ pub fn sound(&mut self, _p: &ClientboundSound) {}
+
+ pub fn level_event(&mut self, p: &ClientboundLevelEvent) {
+ debug!("Got level event packet {p:?}");
+ }
+
+ pub fn block_update(&mut self, p: &ClientboundBlockUpdate) {
+ debug!("Got block update packet {p:?}");
+
+ as_system::<Query<&mut InstanceHolder>>(self.ecs, |mut query| {
+ let local_player = query.get_mut(self.player).unwrap();
+
+ let world = local_player.instance.write();
+
+ world.chunks.set_block_state(&p.pos, p.block_state);
+ });
+ }
+
+ pub fn animate(&mut self, p: &ClientboundAnimate) {
+ debug!("Got animate packet {p:?}");
+ }
+
+ pub fn section_blocks_update(&mut self, p: &ClientboundSectionBlocksUpdate) {
+ debug!("Got section blocks update packet {p:?}");
+
+ as_system::<Query<&mut InstanceHolder>>(self.ecs, |mut query| {
+ let local_player = query.get_mut(self.player).unwrap();
+ let world = local_player.instance.write();
+ for state in &p.states {
+ world
+ .chunks
+ .set_block_state(&(p.section_pos + state.pos), state.state);
+ }
+ });
+ }
+
+ pub fn game_event(&mut self, p: &ClientboundGameEvent) {
+ use azalea_protocol::packets::game::c_game_event::EventType;
+
+ debug!("Got game event packet {p:?}");
+
+ #[allow(clippy::single_match)]
+ match p.event {
+ EventType::ChangeGameMode => {
+ as_system::<Query<&mut LocalGameMode>>(self.ecs, |mut query| {
+ let mut local_game_mode = query.get_mut(self.player).unwrap();
+ if let Some(new_game_mode) = GameMode::from_id(p.param as u8) {
+ local_game_mode.current = new_game_mode;
+ }
+ });
+ }
+ _ => {}
+ }
+ }
+
+ pub fn level_particles(&mut self, p: &ClientboundLevelParticles) {
+ debug!("Got level particles packet {p:?}");
+ }
+
+ pub fn server_data(&mut self, p: &ClientboundServerData) {
+ debug!("Got server data packet {p:?}");
+ }
+
+ pub fn set_equipment(&mut self, p: &ClientboundSetEquipment) {
+ debug!("Got set equipment packet {p:?}");
+ }
+
+ pub fn update_mob_effect(&mut self, p: &ClientboundUpdateMobEffect) {
+ debug!("Got update mob effect packet {p:?}");
+ }
+
+ pub fn add_experience_orb(&mut self, _p: &ClientboundAddExperienceOrb) {}
+
+ pub fn award_stats(&mut self, _p: &ClientboundAwardStats) {}
+
+ pub fn block_changed_ack(&mut self, _p: &ClientboundBlockChangedAck) {}
+
+ pub fn block_destruction(&mut self, _p: &ClientboundBlockDestruction) {}
+
+ pub fn block_entity_data(&mut self, _p: &ClientboundBlockEntityData) {}
+
+ pub fn block_event(&mut self, p: &ClientboundBlockEvent) {
+ debug!("Got block event packet {p:?}");
+ }
+
+ pub fn boss_event(&mut self, _p: &ClientboundBossEvent) {}
+
+ pub fn command_suggestions(&mut self, _p: &ClientboundCommandSuggestions) {}
+
+ pub fn container_set_content(&mut self, p: &ClientboundContainerSetContent) {
+ debug!("Got container set content packet {p:?}");
+
+ as_system::<(Query<&mut Inventory>, EventWriter<_>)>(
+ self.ecs,
+ |(mut query, mut events)| {
+ let mut inventory = query.get_mut(self.player).unwrap();
+
+ // container id 0 is always the player's inventory
+ if p.container_id == 0 {
+ // this is just so it has the same type as the `else` block
+ for (i, slot) in p.items.iter().enumerate() {
+ if let Some(slot_mut) = inventory.inventory_menu.slot_mut(i) {
+ *slot_mut = slot.clone();
+ }
+ }
+ } else {
+ events.send(SetContainerContentEvent {
+ entity: self.player,
+ slots: p.items.clone(),
+ container_id: p.container_id,
+ });
+ }
+ },
+ );
+ }
+
+ pub fn container_set_data(&mut self, p: &ClientboundContainerSetData) {
+ debug!("Got container set data packet {p:?}");
+
+ // TODO: handle ContainerSetData packet
+ // this is used for various things like the furnace progress
+ // bar
+ // see https://wiki.vg/Protocol#Set_Container_Property
+
+ // as_system::<Query<&mut Inventory>>(self.ecs, |mut query| {
+ // let inventory = query.get_mut(self.player).unwrap();
+ // });
+ }
+
+ pub fn container_set_slot(&mut self, p: &ClientboundContainerSetSlot) {
+ debug!("Got container set slot packet {p:?}");
+
+ as_system::<Query<&mut Inventory>>(self.ecs, |mut query| {
+ let mut inventory = query.get_mut(self.player).unwrap();
+
+ if p.container_id == -1 {
+ // -1 means carried item
+ inventory.carried = p.item_stack.clone();
+ } else if p.container_id == -2 {
+ if let Some(slot) = inventory.inventory_menu.slot_mut(p.slot.into()) {
+ *slot = p.item_stack.clone();
+ }
+ } else {
+ let is_creative_mode_and_inventory_closed = false;
+ // technically minecraft has slightly different behavior here if you're in
+ // creative mode and have your inventory open
+ if p.container_id == 0 && azalea_inventory::Player::is_hotbar_slot(p.slot.into()) {
+ // minecraft also sets a "pop time" here which is used for an animation
+ // but that's not really necessary
+ if let Some(slot) = inventory.inventory_menu.slot_mut(p.slot.into()) {
+ *slot = p.item_stack.clone();
+ }
+ } else if p.container_id == inventory.id
+ && (p.container_id != 0 || !is_creative_mode_and_inventory_closed)
+ {
+ // var2.containerMenu.setItem(var4, var1.getStateId(), var3);
+ if let Some(slot) = inventory.menu_mut().slot_mut(p.slot.into()) {
+ *slot = p.item_stack.clone();
+ inventory.state_id = p.state_id;
+ }
+ }
+ }
+ });
+ }
+
+ pub fn container_close(&mut self, p: &ClientboundContainerClose) {
+ // there's a container_id field in the packet, but minecraft doesn't actually
+ // check it
+
+ debug!("Got container close packet {p:?}");
+
+ as_system::<EventWriter<_>>(self.ecs, |mut events| {
+ events.send(ClientSideCloseContainerEvent {
+ entity: self.player,
+ });
+ });
+ }
+
+ pub fn cooldown(&mut self, _p: &ClientboundCooldown) {}
+
+ pub fn custom_chat_completions(&mut self, _p: &ClientboundCustomChatCompletions) {}
+
+ pub fn delete_chat(&mut self, _p: &ClientboundDeleteChat) {}
+
+ pub fn explode(&mut self, p: &ClientboundExplode) {
+ trace!("Got explode packet {p:?}");
+
+ as_system::<EventWriter<_>>(self.ecs, |mut knockback_events| {
+ if let Some(knockback) = p.knockback {
+ knockback_events.send(KnockbackEvent {
+ entity: self.player,
+ knockback: KnockbackType::Set(knockback),
+ });
+ }
+ });
+ }
+
+ pub fn forget_level_chunk(&mut self, p: &ClientboundForgetLevelChunk) {
+ debug!("Got forget level chunk packet {p:?}");
+
+ as_system::<Query<&mut InstanceHolder>>(self.ecs, |mut query| {
+ let local_player = query.get_mut(self.player).unwrap();
+
+ let mut partial_instance = local_player.partial_instance.write();
+
+ partial_instance.chunks.limited_set(&p.pos, None);
+ });
+ }
+
+ pub fn horse_screen_open(&mut self, _p: &ClientboundHorseScreenOpen) {}
+
+ pub fn map_item_data(&mut self, _p: &ClientboundMapItemData) {}
+
+ pub fn merchant_offers(&mut self, _p: &ClientboundMerchantOffers) {}
+
+ pub fn move_vehicle(&mut self, _p: &ClientboundMoveVehicle) {}
+
+ pub fn open_book(&mut self, _p: &ClientboundOpenBook) {}
+
+ pub fn open_screen(&mut self, p: &ClientboundOpenScreen) {
+ debug!("Got open screen packet {p:?}");
+
+ as_system::<EventWriter<_>>(self.ecs, |mut events| {
+ events.send(MenuOpenedEvent {
+ entity: self.player,
+ window_id: p.container_id,
+ menu_type: p.menu_type,
+ title: p.title.to_owned(),
+ });
+ });
+ }
+
+ pub fn open_sign_editor(&mut self, _p: &ClientboundOpenSignEditor) {}
+
+ pub fn ping(&mut self, p: &ClientboundPing) {
+ debug!("Got ping packet {p:?}");
+
+ as_system::<EventWriter<_>>(self.ecs, |mut events| {
+ events.send(SendPacketEvent::new(
+ self.player,
+ ServerboundPong { id: p.id },
+ ));
+ });
+ }
+
+ pub fn place_ghost_recipe(&mut self, _p: &ClientboundPlaceGhostRecipe) {}
+
+ pub fn player_combat_end(&mut self, _p: &ClientboundPlayerCombatEnd) {}
+
+ pub fn player_combat_enter(&mut self, _p: &ClientboundPlayerCombatEnter) {}
+
+ pub fn player_combat_kill(&mut self, p: &ClientboundPlayerCombatKill) {
+ debug!("Got player kill packet {p:?}");
+
+ as_system::<(
+ Commands,
+ Query<(&MinecraftEntityId, Option<&Dead>)>,
+ EventWriter<_>,
+ )>(self.ecs, |(mut commands, mut query, mut events)| {
+ let (entity_id, dead) = query.get_mut(self.player).unwrap();
+
+ if *entity_id == p.player_id && dead.is_none() {
+ commands.entity(self.player).insert(Dead);
+ events.send(DeathEvent {
+ entity: self.player,
+ packet: Some(p.clone()),
+ });
+ }
+ });
+ }
+
+ pub fn player_look_at(&mut self, _p: &ClientboundPlayerLookAt) {}
+
+ pub fn remove_mob_effect(&mut self, _p: &ClientboundRemoveMobEffect) {}
+
+ pub fn resource_pack_push(&mut self, p: &ClientboundResourcePackPush) {
+ debug!("Got resource pack packet {p:?}");
+
+ as_system::<EventWriter<_>>(self.ecs, |mut events| {
+ events.send(ResourcePackEvent {
+ entity: self.player,
+ id: p.id,
+ url: p.url.to_owned(),
+ hash: p.hash.to_owned(),
+ required: p.required,
+ prompt: p.prompt.to_owned(),
+ });
+ });
+ }
+
+ pub fn resource_pack_pop(&mut self, _p: &ClientboundResourcePackPop) {}
+
+ pub fn respawn(&mut self, p: &ClientboundRespawn) {
+ debug!("Got respawn packet {p:?}");
+
+ as_system::<(
+ Commands,
+ Query<(
+ &mut InstanceHolder,
+ &GameProfileComponent,
+ &ClientInformation,
+ )>,
+ EventWriter<_>,
+ ResMut<InstanceContainer>,
+ )>(
+ self.ecs,
+ |(mut commands, mut query, mut events, mut instance_container)| {
+ let (mut instance_holder, game_profile, client_information) =
+ query.get_mut(self.player).unwrap();
+
+ let new_instance_name = p.common.dimension.clone();
+
+ let Some((_dimension_type, dimension_data)) = p
+ .common
+ .dimension_type(&instance_holder.instance.read().registries)
+ else {
+ return;
+ };
+
+ // add this world to the instance_container (or don't if it's already
+ // there)
+ let weak_instance = instance_container.insert(
+ new_instance_name.clone(),
+ dimension_data.height,
+ dimension_data.min_y,
+ &instance_holder.instance.read().registries,
+ );
+ events.send(InstanceLoadedEvent {
+ entity: self.player,
+ name: new_instance_name.clone(),
+ instance: Arc::downgrade(&weak_instance),
+ });
+
+ // set the partial_world to an empty world
+ // (when we add chunks or entities those will be in the
+ // instance_container)
+
+ *instance_holder.partial_instance.write() = PartialInstance::new(
+ azalea_world::chunk_storage::calculate_chunk_storage_range(
+ client_information.view_distance.into(),
+ ),
+ Some(self.player),
+ );
+ instance_holder.instance = weak_instance;
+
+ // this resets a bunch of our components like physics and stuff
+ let entity_bundle = EntityBundle::new(
+ game_profile.uuid,
+ Vec3::default(),
+ azalea_registry::EntityKind::Player,
+ new_instance_name,
+ );
+ // update the local gamemode and metadata things
+ commands.entity(self.player).insert((
+ LocalGameMode {
+ current: p.common.game_type,
+ previous: p.common.previous_game_type.into(),
+ },
+ entity_bundle,
+ ));
+
+ // Remove the Dead marker component from the player.
+ commands.entity(self.player).remove::<Dead>();
+ },
+ )
+ }
+
+ pub fn start_configuration(&mut self, _p: &ClientboundStartConfiguration) {
+ as_system::<(Commands, EventWriter<_>)>(self.ecs, |(mut commands, mut events)| {
+ events.send(SendPacketEvent::new(
+ self.player,
+ ServerboundConfigurationAcknowledged {},
+ ));
+
+ commands
+ .entity(self.player)
+ .insert(crate::client::InConfigState)
+ .remove::<crate::JoinedClientBundle>();
+ });
+ }
+
+ pub fn entity_position_sync(&mut self, p: &ClientboundEntityPositionSync) {
+ as_system::<(Commands, Query<(&EntityIdIndex, &InstanceHolder)>)>(
+ self.ecs,
+ |(mut commands, mut query)| {
+ let (entity_id_index, instance_holder) = query.get_mut(self.player).unwrap();
+
+ let Some(entity) = entity_id_index.get(p.id) else {
+ debug!("Got teleport entity packet for unknown entity id {}", p.id);
+ return;
+ };
+
+ let new_position = p.values.pos;
+ let new_on_ground = p.on_ground;
+ let new_look_direction = p.values.look_direction;
+
+ commands.entity(entity).queue(RelativeEntityUpdate {
+ partial_world: instance_holder.partial_instance.clone(),
+ update: Box::new(move |entity_mut| {
+ let is_local_entity = entity_mut.get::<LocalEntity>().is_some();
+ let mut physics = entity_mut.get_mut::<Physics>().unwrap();
+
+ physics.vec_delta_codec.set_base(new_position);
+
+ if is_local_entity {
+ debug!("Ignoring entity position sync packet for local player");
+ return;
+ }
+
+ physics.set_on_ground(new_on_ground);
+
+ let mut last_sent_position =
+ entity_mut.get_mut::<LastSentPosition>().unwrap();
+ **last_sent_position = new_position;
+ let mut position = entity_mut.get_mut::<Position>().unwrap();
+ **position = new_position;
+
+ let mut look_direction = entity_mut.get_mut::<LookDirection>().unwrap();
+ *look_direction = new_look_direction;
+ }),
+ });
+ },
+ );
+ }
+
+ pub fn select_advancements_tab(&mut self, _p: &ClientboundSelectAdvancementsTab) {}
+ pub fn set_action_bar_text(&mut self, _p: &ClientboundSetActionBarText) {}
+ pub fn set_border_center(&mut self, _p: &ClientboundSetBorderCenter) {}
+ pub fn set_border_lerp_size(&mut self, _p: &ClientboundSetBorderLerpSize) {}
+ pub fn set_border_size(&mut self, _p: &ClientboundSetBorderSize) {}
+ pub fn set_border_warning_delay(&mut self, _p: &ClientboundSetBorderWarningDelay) {}
+ pub fn set_border_warning_distance(&mut self, _p: &ClientboundSetBorderWarningDistance) {}
+ pub fn set_camera(&mut self, _p: &ClientboundSetCamera) {}
+ pub fn set_display_objective(&mut self, _p: &ClientboundSetDisplayObjective) {}
+ pub fn set_objective(&mut self, _p: &ClientboundSetObjective) {}
+ pub fn set_passengers(&mut self, _p: &ClientboundSetPassengers) {}
+ pub fn set_player_team(&mut self, _p: &ClientboundSetPlayerTeam) {}
+ pub fn set_score(&mut self, _p: &ClientboundSetScore) {}
+ pub fn set_simulation_distance(&mut self, _p: &ClientboundSetSimulationDistance) {}
+ pub fn set_subtitle_text(&mut self, _p: &ClientboundSetSubtitleText) {}
+ pub fn set_title_text(&mut self, _p: &ClientboundSetTitleText) {}
+ pub fn set_titles_animation(&mut self, _p: &ClientboundSetTitlesAnimation) {}
+ pub fn clear_titles(&mut self, _p: &ClientboundClearTitles) {}
+ pub fn sound_entity(&mut self, _p: &ClientboundSoundEntity) {}
+ pub fn stop_sound(&mut self, _p: &ClientboundStopSound) {}
+ pub fn tab_list(&mut self, _p: &ClientboundTabList) {}
+ pub fn tag_query(&mut self, _p: &ClientboundTagQuery) {}
+ pub fn take_item_entity(&mut self, _p: &ClientboundTakeItemEntity) {}
+ pub fn bundle_delimiter(&mut self, _p: &ClientboundBundleDelimiter) {}
+ pub fn damage_event(&mut self, _p: &ClientboundDamageEvent) {}
+ pub fn hurt_animation(&mut self, _p: &ClientboundHurtAnimation) {}
+ pub fn ticking_state(&mut self, _p: &ClientboundTickingState) {}
+ pub fn ticking_step(&mut self, _p: &ClientboundTickingStep) {}
+ pub fn reset_score(&mut self, _p: &ClientboundResetScore) {}
+ pub fn cookie_request(&mut self, _p: &ClientboundCookieRequest) {}
+ pub fn debug_sample(&mut self, _p: &ClientboundDebugSample) {}
+ pub fn pong_response(&mut self, _p: &ClientboundPongResponse) {}
+ pub fn store_cookie(&mut self, _p: &ClientboundStoreCookie) {}
+ pub fn transfer(&mut self, _p: &ClientboundTransfer) {}
+ pub fn move_minecart_along_track(&mut self, _p: &ClientboundMoveMinecartAlongTrack) {}
+ pub fn set_held_slot(&mut self, _p: &ClientboundSetHeldSlot) {}
+ pub fn set_player_inventory(&mut self, _p: &ClientboundSetPlayerInventory) {}
+ pub fn projectile_power(&mut self, _p: &ClientboundProjectilePower) {}
+ pub fn custom_report_details(&mut self, _p: &ClientboundCustomReportDetails) {}
+ pub fn server_links(&mut self, _p: &ClientboundServerLinks) {}
+ pub fn player_rotation(&mut self, _p: &ClientboundPlayerRotation) {}
+ pub fn recipe_book_add(&mut self, _p: &ClientboundRecipeBookAdd) {}
+ pub fn recipe_book_remove(&mut self, _p: &ClientboundRecipeBookRemove) {}
+ pub fn recipe_book_settings(&mut self, _p: &ClientboundRecipeBookSettings) {}
+}
diff --git a/azalea-client/src/plugins/packet/login.rs b/azalea-client/src/plugins/packet/login.rs
new file mode 100644
index 00000000..1bb07266
--- /dev/null
+++ b/azalea-client/src/plugins/packet/login.rs
@@ -0,0 +1,114 @@
+// login packets aren't actually handled here because compression/encryption
+// would make packet handling a lot messier
+
+use std::{collections::HashSet, sync::Arc};
+
+use azalea_protocol::packets::{
+ Packet,
+ login::{
+ ClientboundLoginPacket, ServerboundLoginPacket,
+ s_custom_query_answer::ServerboundCustomQueryAnswer,
+ },
+};
+use bevy_ecs::{prelude::*, system::SystemState};
+use derive_more::{Deref, DerefMut};
+use tokio::sync::mpsc;
+use tracing::error;
+
+// this struct is defined here anyways though so it's consistent with the other
+// ones
+
+/// An event that's sent when we receive a login packet from the server. Note
+/// that if you want to handle this in a system, you must add
+/// `.before(azalea::packet::login::process_packet_events)` to it
+/// because that system clears the events.
+#[derive(Event, Debug, Clone)]
+pub struct LoginPacketEvent {
+ /// The client entity that received the packet.
+ pub entity: Entity,
+ /// The packet that was actually received.
+ pub packet: Arc<ClientboundLoginPacket>,
+}
+
+/// Event for sending a login packet to the server.
+#[derive(Event)]
+pub struct SendLoginPacketEvent {
+ pub entity: Entity,
+ pub packet: ServerboundLoginPacket,
+}
+impl SendLoginPacketEvent {
+ pub fn new(entity: Entity, packet: impl Packet<ServerboundLoginPacket>) -> Self {
+ let packet = packet.into_variant();
+ Self { entity, packet }
+ }
+}
+
+#[derive(Component)]
+pub struct LoginSendPacketQueue {
+ pub tx: mpsc::UnboundedSender<ServerboundLoginPacket>,
+}
+
+/// A marker component for local players that are currently in the
+/// `login` state.
+#[derive(Component, Clone, Debug)]
+pub struct InLoginState;
+
+pub fn handle_send_packet_event(
+ mut send_packet_events: EventReader<SendLoginPacketEvent>,
+ mut query: Query<&mut LoginSendPacketQueue>,
+) {
+ for event in send_packet_events.read() {
+ if let Ok(queue) = query.get_mut(event.entity) {
+ let _ = queue.tx.send(event.packet.clone());
+ } else {
+ error!("Sent SendPacketEvent for entity that doesn't have a LoginSendPacketQueue");
+ }
+ }
+}
+
+/// Plugins can add to this set if they want to handle a custom query packet
+/// themselves. This component removed after the login state ends.
+#[derive(Component, Default, Debug, Deref, DerefMut)]
+pub struct IgnoreQueryIds(HashSet<u32>);
+
+pub fn process_packet_events(ecs: &mut World) {
+ let mut events_owned = Vec::new();
+ let mut system_state: SystemState<ResMut<Events<LoginPacketEvent>>> = SystemState::new(ecs);
+ let mut events = system_state.get_mut(ecs);
+ for LoginPacketEvent {
+ entity: player_entity,
+ packet,
+ } in events.drain()
+ {
+ // we do this so `ecs` isn't borrowed for the whole loop
+ events_owned.push((player_entity, packet));
+ }
+ for (player_entity, packet) in events_owned {
+ #[allow(clippy::single_match)]
+ match packet.as_ref() {
+ ClientboundLoginPacket::CustomQuery(p) => {
+ let mut system_state: SystemState<(
+ EventWriter<SendLoginPacketEvent>,
+ Query<&IgnoreQueryIds>,
+ )> = SystemState::new(ecs);
+ let (mut send_packet_events, query) = system_state.get_mut(ecs);
+
+ let ignore_query_ids = query.get(player_entity).ok().map(|x| x.0.clone());
+ if let Some(ignore_query_ids) = ignore_query_ids {
+ if ignore_query_ids.contains(&p.transaction_id) {
+ continue;
+ }
+ }
+
+ send_packet_events.send(SendLoginPacketEvent::new(
+ player_entity,
+ ServerboundCustomQueryAnswer {
+ transaction_id: p.transaction_id,
+ data: None,
+ },
+ ));
+ }
+ _ => {}
+ }
+ }
+}
diff --git a/azalea-client/src/plugins/packet/mod.rs b/azalea-client/src/plugins/packet/mod.rs
new file mode 100644
index 00000000..cbd8a175
--- /dev/null
+++ b/azalea-client/src/plugins/packet/mod.rs
@@ -0,0 +1,109 @@
+use azalea_entity::{EntityUpdateSet, metadata::Health};
+use bevy_app::{App, First, Plugin, PreUpdate, Update};
+use bevy_ecs::{
+ prelude::*,
+ system::{SystemParam, SystemState},
+};
+
+use self::{
+ game::{
+ AddPlayerEvent, DeathEvent, InstanceLoadedEvent, KeepAliveEvent, RemovePlayerEvent,
+ ResourcePackEvent, UpdatePlayerEvent,
+ },
+ login::{LoginPacketEvent, SendLoginPacketEvent},
+};
+use crate::{chat::ChatReceivedEvent, events::death_listener};
+
+pub mod config;
+pub mod game;
+pub mod login;
+
+pub struct PacketPlugin;
+
+pub fn death_event_on_0_health(
+ query: Query<(Entity, &Health), Changed<Health>>,
+ mut death_events: EventWriter<DeathEvent>,
+) {
+ for (entity, health) in query.iter() {
+ if **health == 0. {
+ death_events.send(DeathEvent {
+ entity,
+ packet: None,
+ });
+ }
+ }
+}
+
+impl Plugin for PacketPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_systems(
+ First,
+ (game::send_receivepacketevent, config::send_packet_events),
+ )
+ .add_systems(
+ PreUpdate,
+ (
+ game::process_packet_events
+ // we want to index and deindex right after
+ .before(EntityUpdateSet::Deindex),
+ config::process_packet_events,
+ login::handle_send_packet_event,
+ login::process_packet_events,
+ ),
+ )
+ .add_systems(
+ Update,
+ (
+ (
+ config::handle_send_packet_event,
+ game::handle_outgoing_packets,
+ )
+ .chain(),
+ death_event_on_0_health.before(death_listener),
+ ),
+ )
+ // we do this instead of add_event so we can handle the events ourselves
+ .init_resource::<Events<game::ReceivePacketEvent>>()
+ .init_resource::<Events<config::ReceiveConfigPacketEvent>>()
+ .add_event::<game::SendPacketEvent>()
+ .add_event::<config::SendConfigPacketEvent>()
+ .add_event::<AddPlayerEvent>()
+ .add_event::<RemovePlayerEvent>()
+ .add_event::<UpdatePlayerEvent>()
+ .add_event::<ChatReceivedEvent>()
+ .add_event::<DeathEvent>()
+ .add_event::<KeepAliveEvent>()
+ .add_event::<ResourcePackEvent>()
+ .add_event::<InstanceLoadedEvent>()
+ .add_event::<LoginPacketEvent>()
+ .add_event::<SendLoginPacketEvent>();
+ }
+}
+
+#[macro_export]
+macro_rules! declare_packet_handlers {
+ (
+ $packetenum:ident,
+ $packetvar:expr,
+ $handler:ident,
+ [$($packet:path),+ $(,)?]
+ ) => {
+ paste::paste! {
+ match $packetvar {
+ $(
+ $packetenum::[< $packet:camel >](p) => $handler.$packet(p),
+ )+
+ }
+ }
+ };
+}
+
+pub(crate) fn as_system<T>(ecs: &mut World, f: impl FnOnce(T::Item<'_, '_>))
+where
+ T: SystemParam + 'static,
+{
+ let mut system_state = SystemState::<T>::new(ecs);
+ let values = system_state.get_mut(ecs);
+ f(values);
+ system_state.apply(ecs);
+}
diff --git a/azalea-client/src/plugins/respawn.rs b/azalea-client/src/plugins/respawn.rs
new file mode 100644
index 00000000..5797406b
--- /dev/null
+++ b/azalea-client/src/plugins/respawn.rs
@@ -0,0 +1,35 @@
+use azalea_protocol::packets::game::s_client_command::{self, ServerboundClientCommand};
+use bevy_app::{App, Plugin, Update};
+use bevy_ecs::prelude::*;
+
+use super::packet::game::handle_outgoing_packets;
+use crate::packet::game::SendPacketEvent;
+
+/// Tell the server that we're respawning.
+#[derive(Event, Debug, Clone)]
+pub struct PerformRespawnEvent {
+ pub entity: Entity,
+}
+
+/// A plugin that makes [`PerformRespawnEvent`] send the packet to respawn.
+pub struct RespawnPlugin;
+impl Plugin for RespawnPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_event::<PerformRespawnEvent>()
+ .add_systems(Update, perform_respawn.before(handle_outgoing_packets));
+ }
+}
+
+pub fn perform_respawn(
+ mut events: EventReader<PerformRespawnEvent>,
+ mut send_packets: EventWriter<SendPacketEvent>,
+) {
+ for event in events.read() {
+ send_packets.send(SendPacketEvent::new(
+ event.entity,
+ ServerboundClientCommand {
+ action: s_client_command::Action::PerformRespawn,
+ },
+ ));
+ }
+}
diff --git a/azalea-client/src/plugins/task_pool.rs b/azalea-client/src/plugins/task_pool.rs
new file mode 100644
index 00000000..ab56bf69
--- /dev/null
+++ b/azalea-client/src/plugins/task_pool.rs
@@ -0,0 +1,182 @@
+//! Borrowed from `bevy_core`.
+
+use std::marker::PhantomData;
+
+use bevy_app::{App, Last, Plugin};
+use bevy_ecs::system::{NonSend, Resource};
+use bevy_tasks::{
+ AsyncComputeTaskPool, ComputeTaskPool, IoTaskPool, TaskPoolBuilder,
+ tick_global_task_pools_on_main_thread,
+};
+
+/// Setup of default task pools: `AsyncComputeTaskPool`, `ComputeTaskPool`,
+/// `IoTaskPool`.
+#[derive(Default)]
+pub struct TaskPoolPlugin {
+ /// Options for the [`TaskPool`](bevy_tasks::TaskPool) created at
+ /// application start.
+ pub task_pool_options: TaskPoolOptions,
+}
+
+impl Plugin for TaskPoolPlugin {
+ fn build(&self, app: &mut App) {
+ // Setup the default bevy task pools
+ self.task_pool_options.create_default_pools();
+
+ #[cfg(not(target_arch = "wasm32"))]
+ app.add_systems(Last, tick_global_task_pools);
+ }
+}
+
+pub struct NonSendMarker(PhantomData<*mut ()>);
+#[cfg(not(target_arch = "wasm32"))]
+fn tick_global_task_pools(_main_thread_marker: Option<NonSend<NonSendMarker>>) {
+ tick_global_task_pools_on_main_thread();
+}
+
+/// Helper for configuring and creating the default task pools. For end-users
+/// who want full control, set up [`TaskPoolPlugin`]
+#[derive(Clone, Resource)]
+pub struct TaskPoolOptions {
+ /// If the number of physical cores is less than min_total_threads, force
+ /// using min_total_threads
+ pub min_total_threads: usize,
+ /// If the number of physical cores is greater than max_total_threads, force
+ /// using max_total_threads
+ pub max_total_threads: usize,
+
+ /// Used to determine number of IO threads to allocate
+ pub io: TaskPoolThreadAssignmentPolicy,
+ /// Used to determine number of async compute threads to allocate
+ pub async_compute: TaskPoolThreadAssignmentPolicy,
+ /// Used to determine number of compute threads to allocate
+ pub compute: TaskPoolThreadAssignmentPolicy,
+}
+
+impl Default for TaskPoolOptions {
+ fn default() -> Self {
+ TaskPoolOptions {
+ // By default, use however many cores are available on the system
+ min_total_threads: 1,
+ max_total_threads: usize::MAX,
+
+ // Use 25% of cores for IO, at least 1, no more than 4
+ io: TaskPoolThreadAssignmentPolicy {
+ min_threads: 1,
+ max_threads: 4,
+ percent: 0.25,
+ },
+
+ // Use 25% of cores for async compute, at least 1, no more than 4
+ async_compute: TaskPoolThreadAssignmentPolicy {
+ min_threads: 1,
+ max_threads: 4,
+ percent: 0.25,
+ },
+
+ // Use all remaining cores for compute (at least 1)
+ compute: TaskPoolThreadAssignmentPolicy {
+ min_threads: 1,
+ max_threads: usize::MAX,
+ percent: 1.0, // This 1.0 here means "whatever is left over"
+ },
+ }
+ }
+}
+
+impl TaskPoolOptions {
+ // /// Create a configuration that forces using the given number of threads.
+ // pub fn with_num_threads(thread_count: usize) -> Self {
+ // TaskPoolOptions {
+ // min_total_threads: thread_count,
+ // max_total_threads: thread_count,
+ // ..Default::default()
+ // }
+ // }
+
+ /// Inserts the default thread pools into the given resource map based on
+ /// the configured values
+ pub fn create_default_pools(&self) {
+ let total_threads = bevy_tasks::available_parallelism()
+ .clamp(self.min_total_threads, self.max_total_threads);
+
+ let mut remaining_threads = total_threads;
+
+ {
+ // Determine the number of IO threads we will use
+ let io_threads = self
+ .io
+ .get_number_of_threads(remaining_threads, total_threads);
+
+ remaining_threads = remaining_threads.saturating_sub(io_threads);
+
+ IoTaskPool::get_or_init(|| {
+ TaskPoolBuilder::default()
+ .num_threads(io_threads)
+ .thread_name("IO Task Pool".to_string())
+ .build()
+ });
+ }
+
+ {
+ // Determine the number of async compute threads we will use
+ let async_compute_threads = self
+ .async_compute
+ .get_number_of_threads(remaining_threads, total_threads);
+
+ remaining_threads = remaining_threads.saturating_sub(async_compute_threads);
+
+ AsyncComputeTaskPool::get_or_init(|| {
+ TaskPoolBuilder::default()
+ .num_threads(async_compute_threads)
+ .thread_name("Async Compute Task Pool".to_string())
+ .build()
+ });
+ }
+
+ {
+ // Determine the number of compute threads we will use
+ // This is intentionally last so that an end user can specify 1.0 as the percent
+ let compute_threads = self
+ .compute
+ .get_number_of_threads(remaining_threads, total_threads);
+
+ ComputeTaskPool::get_or_init(|| {
+ TaskPoolBuilder::default()
+ .num_threads(compute_threads)
+ .thread_name("Compute Task Pool".to_string())
+ .build()
+ });
+ }
+ }
+}
+
+/// Defines a simple way to determine how many threads to use given the number
+/// of remaining cores and number of total cores
+#[derive(Clone)]
+pub struct TaskPoolThreadAssignmentPolicy {
+ /// Force using at least this many threads
+ pub min_threads: usize,
+ /// Under no circumstance use more than this many threads for this pool
+ pub max_threads: usize,
+ /// Target using this percentage of total cores, clamped by min_threads and
+ /// max_threads. It is permitted to use 1.0 to try to use all remaining
+ /// threads
+ pub percent: f32,
+}
+
+impl TaskPoolThreadAssignmentPolicy {
+ /// Determine the number of threads to use for this task pool
+ fn get_number_of_threads(&self, remaining_threads: usize, total_threads: usize) -> usize {
+ assert!(self.percent >= 0.0);
+ let mut desired = (total_threads as f32 * self.percent).round() as usize;
+
+ // Limit ourselves to the number of cores available
+ desired = desired.min(remaining_threads);
+
+ // Clamp by min_threads, max_threads. (This may result in us using more threads
+ // than are available, this is intended. An example case where this
+ // might happen is a device with <= 2 threads.
+ desired.clamp(self.min_threads, self.max_threads)
+ }
+}
diff --git a/azalea-client/src/plugins/tick_end.rs b/azalea-client/src/plugins/tick_end.rs
new file mode 100644
index 00000000..c7737eb1
--- /dev/null
+++ b/azalea-client/src/plugins/tick_end.rs
@@ -0,0 +1,36 @@
+//! Clients send a [`ServerboundClientTickEnd`] packet every tick.
+
+use azalea_core::tick::GameTick;
+use azalea_entity::LocalEntity;
+use azalea_physics::PhysicsSet;
+use azalea_protocol::packets::game::ServerboundClientTickEnd;
+use azalea_world::InstanceName;
+use bevy_app::{App, Plugin};
+use bevy_ecs::prelude::*;
+
+use crate::{mining::MiningSet, packet::game::SendPacketEvent};
+
+/// A plugin that makes clients send a [`ServerboundClientTickEnd`] packet every
+/// tick.
+pub struct TickEndPlugin;
+impl Plugin for TickEndPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_systems(
+ GameTick,
+ // this has to happen after every other event that might send packets
+ game_tick_packet
+ .after(PhysicsSet)
+ .after(MiningSet)
+ .after(crate::movement::send_position),
+ );
+ }
+}
+
+pub fn game_tick_packet(
+ query: Query<Entity, (With<LocalEntity>, With<InstanceName>)>,
+ mut send_packets: EventWriter<SendPacketEvent>,
+) {
+ for entity in query.iter() {
+ send_packets.send(SendPacketEvent::new(entity, ServerboundClientTickEnd));
+ }
+}