aboutsummaryrefslogtreecommitdiff
path: root/azalea-entity/src/plugin
diff options
context:
space:
mode:
Diffstat (limited to 'azalea-entity/src/plugin')
-rw-r--r--azalea-entity/src/plugin/indexing.rs243
-rw-r--r--azalea-entity/src/plugin/mod.rs147
-rw-r--r--azalea-entity/src/plugin/relative_updates.rs122
3 files changed, 512 insertions, 0 deletions
diff --git a/azalea-entity/src/plugin/indexing.rs b/azalea-entity/src/plugin/indexing.rs
new file mode 100644
index 00000000..f7dfe0fa
--- /dev/null
+++ b/azalea-entity/src/plugin/indexing.rs
@@ -0,0 +1,243 @@
+//! Stuff related to entity indexes and keeping track of entities in the world.
+
+use azalea_core::ChunkPos;
+use azalea_world::{InstanceContainer, InstanceName, MinecraftEntityId};
+use bevy_ecs::{
+ entity::Entity,
+ query::{Changed, With, Without},
+ system::{Commands, Query, Res, ResMut, Resource},
+};
+use log::{debug, error, info, warn};
+use std::{collections::HashMap, fmt::Debug};
+use uuid::Uuid;
+
+use crate::{EntityUuid, LastSentPosition, Local, Position};
+
+use super::LoadedBy;
+
+#[derive(Resource, Default)]
+pub struct EntityUuidIndex {
+ /// An index of entities by their UUIDs
+ entity_by_uuid: HashMap<Uuid, Entity>,
+}
+
+impl EntityUuidIndex {
+ pub fn new() -> Self {
+ Self {
+ entity_by_uuid: HashMap::default(),
+ }
+ }
+
+ pub fn get(&self, uuid: &Uuid) -> Option<Entity> {
+ self.entity_by_uuid.get(uuid).copied()
+ }
+
+ pub fn contains_key(&self, uuid: &Uuid) -> bool {
+ self.entity_by_uuid.contains_key(uuid)
+ }
+
+ pub fn insert(&mut self, uuid: Uuid, entity: Entity) {
+ self.entity_by_uuid.insert(uuid, entity);
+ }
+}
+
+impl Debug for EntityUuidIndex {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("EntityUuidIndex").finish()
+ }
+}
+
+/// Remove new entities that have the same id as an existing entity, and
+/// increase the reference counts.
+///
+/// This is the reason why spawning entities into the ECS when you get a spawn
+/// entity packet is okay. This system will make sure the new entity gets
+/// combined into the old one.
+#[allow(clippy::type_complexity)]
+pub fn deduplicate_entities(
+ mut commands: Commands,
+ mut query: Query<
+ (Entity, &MinecraftEntityId, &InstanceName),
+ (Changed<MinecraftEntityId>, Without<Local>),
+ >,
+ mut loaded_by_query: Query<&mut LoadedBy>,
+ instance_container: Res<InstanceContainer>,
+) {
+ // if this entity already exists, remove it
+ for (new_entity, id, world_name) in query.iter_mut() {
+ if let Some(world_lock) = instance_container.get(world_name) {
+ let world = world_lock.write();
+ if let Some(old_entity) = world.entity_by_id.get(id) {
+ if old_entity == &new_entity {
+ continue;
+ }
+
+ // this entity already exists!!! remove the one we just added but increase
+ // the reference count
+ let new_loaded_by = loaded_by_query
+ .get(new_entity)
+ .unwrap_or_else(|_| panic!(
+ "Entities should always have the LoadedBy component ({new_entity:?} did not)"
+ ))
+ .clone();
+ let old_loaded_by = loaded_by_query.get_mut(*old_entity);
+ // merge them if possible
+ if let Ok(mut old_loaded_by) = old_loaded_by {
+ old_loaded_by.extend(new_loaded_by.iter());
+ }
+ commands.entity(new_entity).despawn();
+ info!(
+ "Entity with id {id:?} / {new_entity:?} already existed in the world, merging it with {old_entity:?}"
+ );
+ break;
+ }
+ } else {
+ error!("Entity was inserted into a world that doesn't exist.");
+ }
+ }
+}
+
+// when a local entity is added, if there was already an entity with the same id
+// then delete the old entity
+#[allow(clippy::type_complexity)]
+pub fn deduplicate_local_entities(
+ mut commands: Commands,
+ mut query: Query<
+ (Entity, &MinecraftEntityId, &InstanceName),
+ (Changed<MinecraftEntityId>, With<Local>),
+ >,
+ instance_container: Res<InstanceContainer>,
+) {
+ // if this entity already exists, remove the old one
+ for (new_entity, id, world_name) in query.iter_mut() {
+ if let Some(world_lock) = instance_container.get(world_name) {
+ let world = world_lock.write();
+ if let Some(old_entity) = world.entity_by_id.get(id) {
+ if old_entity == &new_entity {
+ // lol
+ continue;
+ }
+
+ commands.entity(*old_entity).despawn();
+ debug!(
+ "Added local entity {id:?} / {new_entity:?} but already existed in world as {old_entity:?}, despawning {old_entity:?}"
+ );
+ break;
+ }
+ } else {
+ error!("Entity was inserted into a world that doesn't exist.");
+ }
+ }
+}
+
+pub fn update_uuid_index(
+ mut entity_infos: ResMut<EntityUuidIndex>,
+ query: Query<(Entity, &EntityUuid, Option<&Local>), Changed<EntityUuid>>,
+) {
+ for (entity, &uuid, local) in query.iter() {
+ // only add it if it doesn't already exist in
+ // entity_infos.entity_by_uuid
+ if local.is_none() {
+ if let Some(old_entity) = entity_infos.entity_by_uuid.get(&uuid) {
+ debug!(
+ "Entity with UUID {uuid:?} already existed in the world, not adding to
+ index (old ecs id: {old_entity:?} / new ecs id: {entity:?})"
+ );
+ continue;
+ }
+ }
+ entity_infos.entity_by_uuid.insert(*uuid, entity);
+ }
+}
+
+/// System to keep the entity_by_id index up-to-date.
+pub fn update_entity_by_id_index(
+ mut query: Query<
+ (Entity, &MinecraftEntityId, &InstanceName, Option<&Local>),
+ Changed<MinecraftEntityId>,
+ >,
+ instance_container: Res<InstanceContainer>,
+) {
+ for (entity, id, world_name, local) in query.iter_mut() {
+ let world_lock = instance_container.get(world_name).unwrap();
+ let mut world = world_lock.write();
+ if local.is_none() {
+ if let Some(old_entity) = world.entity_by_id.get(id) {
+ debug!(
+ "Entity with ID {id:?} already existed in the world, not adding to
+ index (old ecs id: {old_entity:?} / new ecs id: {entity:?})"
+ );
+ continue;
+ }
+ }
+ world.entity_by_id.insert(*id, entity);
+ debug!("Added {entity:?} to {world_name:?} with {id:?}.");
+ }
+}
+
+/// Update the chunk position indexes in [`EntityUuidIndex`].
+pub fn update_entity_chunk_positions(
+ mut query: Query<(Entity, &Position, &mut LastSentPosition, &InstanceName), Changed<Position>>,
+ instance_container: Res<InstanceContainer>,
+) {
+ for (entity, pos, last_pos, world_name) in query.iter_mut() {
+ let world_lock = instance_container.get(world_name).unwrap();
+ let mut world = world_lock.write();
+
+ let old_chunk = ChunkPos::from(*last_pos);
+ let new_chunk = ChunkPos::from(*pos);
+
+ if old_chunk != new_chunk {
+ // move the entity from the old chunk to the new one
+ if let Some(entities) = world.entities_by_chunk.get_mut(&old_chunk) {
+ entities.remove(&entity);
+ }
+ world
+ .entities_by_chunk
+ .entry(new_chunk)
+ .or_default()
+ .insert(entity);
+ }
+ }
+}
+
+/// Despawn entities that aren't being loaded by anything.
+pub fn remove_despawned_entities_from_indexes(
+ mut commands: Commands,
+ mut entity_infos: ResMut<EntityUuidIndex>,
+ instance_container: Res<InstanceContainer>,
+ query: Query<(Entity, &EntityUuid, &Position, &InstanceName, &LoadedBy), Changed<LoadedBy>>,
+) {
+ for (entity, uuid, position, world_name, loaded_by) in &query {
+ let world_lock = instance_container.get(world_name).unwrap();
+ let mut world = world_lock.write();
+
+ // if the entity has no references left, despawn it
+ if !loaded_by.is_empty() {
+ continue;
+ }
+
+ // remove the entity from the chunk index
+ let chunk = ChunkPos::from(*position);
+ if let Some(entities_in_chunk) = world.entities_by_chunk.get_mut(&chunk) {
+ if entities_in_chunk.remove(&entity) {
+ // remove the chunk if there's no entities in it anymore
+ if entities_in_chunk.is_empty() {
+ world.entities_by_chunk.remove(&chunk);
+ }
+ } else {
+ warn!("Tried to remove entity from chunk {chunk:?} but the entity was not there.");
+ }
+ } else {
+ warn!("Tried to remove entity from chunk {chunk:?} but the chunk was not found.");
+ }
+ // remove it from the uuid index
+ if entity_infos.entity_by_uuid.remove(uuid).is_none() {
+ warn!("Tried to remove entity {entity:?} from the uuid index but it was not there.");
+ }
+ // and now remove the entity from the ecs
+ commands.entity(entity).despawn();
+ debug!("Despawned entity {entity:?} because it was not loaded by anything.");
+ return;
+ }
+}
diff --git a/azalea-entity/src/plugin/mod.rs b/azalea-entity/src/plugin/mod.rs
new file mode 100644
index 00000000..7b514fcc
--- /dev/null
+++ b/azalea-entity/src/plugin/mod.rs
@@ -0,0 +1,147 @@
+pub mod indexing;
+mod relative_updates;
+
+use std::collections::HashSet;
+
+use azalea_core::{BlockPos, Vec3};
+use azalea_world::{InstanceContainer, InstanceName, MinecraftEntityId};
+use bevy_app::{App, Plugin, PostUpdate, PreUpdate, Update};
+use bevy_ecs::prelude::*;
+use derive_more::{Deref, DerefMut};
+use log::debug;
+
+use crate::{
+ metadata::Health, Dead, EyeHeight, FluidOnEyes, Local, LookDirection, Physics, Position,
+};
+
+use indexing::EntityUuidIndex;
+pub use relative_updates::RelativeEntityUpdate;
+
+/// A Bevy [`SystemSet`] for various types of entity updates.
+#[derive(SystemSet, Debug, Hash, Eq, PartialEq, Clone)]
+pub enum EntityUpdateSet {
+ /// Remove ECS entities that refer to an entity that was already in the ECS
+ /// before.
+ Deduplicate,
+ /// Create search indexes for entities.
+ Index,
+ /// Remove despawned entities from search indexes.
+ Deindex,
+}
+
+/// Plugin handling some basic entity functionality.
+pub struct EntityPlugin;
+impl Plugin for EntityPlugin {
+ fn build(&self, app: &mut App) {
+ // entities get added pre-update
+ // added to indexes during update (done by this plugin)
+ // modified during update
+ // despawned post-update (done by this plugin)
+ app.add_systems(
+ PreUpdate,
+ indexing::remove_despawned_entities_from_indexes.in_set(EntityUpdateSet::Deindex),
+ )
+ .add_systems(
+ PostUpdate,
+ (
+ indexing::deduplicate_entities,
+ indexing::deduplicate_local_entities,
+ )
+ .in_set(EntityUpdateSet::Deduplicate),
+ )
+ .add_systems(
+ Update,
+ (
+ (
+ indexing::update_entity_chunk_positions,
+ indexing::update_uuid_index,
+ indexing::update_entity_by_id_index,
+ )
+ .in_set(EntityUpdateSet::Index),
+ (
+ relative_updates::add_updates_received,
+ relative_updates::debug_detect_updates_received_on_local_entities,
+ debug_new_entity,
+ add_dead,
+ update_bounding_box,
+ clamp_look_direction,
+ update_fluid_on_eyes,
+ ),
+ ),
+ )
+ .init_resource::<EntityUuidIndex>();
+ }
+}
+
+fn debug_new_entity(query: Query<(Entity, Option<&Local>), Added<MinecraftEntityId>>) {
+ for (entity, local) in query.iter() {
+ if local.is_some() {
+ debug!("new local entity: {:?}", entity);
+ } else {
+ debug!("new entity: {:?}", entity);
+ }
+ }
+}
+
+/// System that adds the [`Dead`] marker component if an entity's health is set
+/// to 0 (or less than 0). This will be present if an entity is doing the death
+/// animation.
+///
+/// Entities that are dead can not be revived.
+/// TODO: fact check this in-game by setting an entity's health to 0 and then
+/// not 0
+pub fn add_dead(mut commands: Commands, query: Query<(Entity, &Health), Changed<Health>>) {
+ for (entity, health) in query.iter() {
+ if **health <= 0.0 {
+ commands.entity(entity).insert(Dead);
+ }
+ }
+}
+
+pub fn update_fluid_on_eyes(
+ mut query: Query<(&mut FluidOnEyes, &Position, &EyeHeight, &InstanceName)>,
+ instance_container: Res<InstanceContainer>,
+) {
+ for (mut fluid_on_eyes, position, eye_height, instance_name) in query.iter_mut() {
+ let Some(instance) = instance_container.get(instance_name) else {
+ continue;
+ };
+
+ let adjusted_eye_y = position.y + (**eye_height as f64) - 0.1111111119389534;
+ let eye_block_pos = BlockPos::from(Vec3::new(position.x, adjusted_eye_y, position.z));
+ let fluid_at_eye = instance
+ .read()
+ .get_fluid_state(&eye_block_pos)
+ .unwrap_or_default();
+ let fluid_cutoff_y = eye_block_pos.y as f64 + (fluid_at_eye.height as f64 / 16f64);
+ if fluid_cutoff_y > adjusted_eye_y {
+ **fluid_on_eyes = fluid_at_eye.fluid;
+ } else {
+ **fluid_on_eyes = azalea_registry::Fluid::Empty;
+ }
+ }
+}
+
+/// A component that lists all the local player entities that have this entity
+/// loaded. If this is empty, the entity will be removed from the ECS.
+#[derive(Component, Clone, Deref, DerefMut)]
+pub struct LoadedBy(pub HashSet<Entity>);
+
+pub fn clamp_look_direction(mut query: Query<&mut LookDirection>) {
+ for mut look_direction in &mut query {
+ look_direction.y_rot %= 360.0;
+ look_direction.x_rot = look_direction.x_rot.clamp(-90.0, 90.0) % 360.0;
+ }
+}
+
+/// Sets the position of the entity. This doesn't update the cache in
+/// azalea-world, and should only be used within azalea-world!
+///
+/// # Safety
+/// Cached position in the world must be updated.
+pub fn update_bounding_box(mut query: Query<(&Position, &mut Physics), Changed<Position>>) {
+ for (position, mut physics) in query.iter_mut() {
+ let bounding_box = physics.dimensions.make_bounding_box(position);
+ physics.bounding_box = bounding_box;
+ }
+}
diff --git a/azalea-entity/src/plugin/relative_updates.rs b/azalea-entity/src/plugin/relative_updates.rs
new file mode 100644
index 00000000..7d01feda
--- /dev/null
+++ b/azalea-entity/src/plugin/relative_updates.rs
@@ -0,0 +1,122 @@
+// How entity updates are processed (to avoid issues with shared worlds)
+// - each bot contains a map of { entity id: updates received }
+// - the shared world also contains a canonical "true" updates received for each
+// entity
+// - when a client loads an entity, its "updates received" is set to the same as
+// the global "updates received"
+// - when the shared world sees an entity for the first time, the "updates
+// received" is set to 1.
+// - clients can force the shared "updates received" to 0 to make it so certain
+// entities (i.e. other bots in our swarm) don't get confused and updated by
+// other bots
+// - when a client gets an update to an entity, we check if our "updates
+// received" is the same as the shared world's "updates received": if it is,
+// then process the update and increment the client's and shared world's
+// "updates received" if not, then we simply increment our local "updates
+// received" and do nothing else
+
+use std::sync::Arc;
+
+use azalea_world::{MinecraftEntityId, PartialInstance};
+use bevy_ecs::{
+ prelude::{Component, Entity},
+ query::{Changed, With, Without},
+ system::{Commands, EntityCommand, Query},
+ world::{EntityMut, World},
+};
+use derive_more::{Deref, DerefMut};
+use log::warn;
+use parking_lot::RwLock;
+
+use crate::Local;
+
+/// An [`EntityCommand`] that applies a "relative update" to an entity, which
+/// means this update won't be run multiple times by different clients in the
+/// same world.
+///
+/// This is used to avoid a bug where when there's multiple clients in the same
+/// world and an entity sends a relative move packet to all clients, its
+/// position gets desynced since the relative move is applied multiple times.
+///
+/// Don't use this unless you actually got an entity update packet that all
+/// other clients within render distance will get too. You usually don't need
+/// this when the change isn't relative either.
+pub struct RelativeEntityUpdate {
+ pub partial_world: Arc<RwLock<PartialInstance>>,
+ // a function that takes the entity and updates it
+ pub update: Box<dyn FnOnce(&mut EntityMut) + Send + Sync>,
+}
+
+/// A component that counts the number of times this entity has been modified.
+/// This is used for making sure two clients don't do the same relative update
+/// on an entity.
+///
+/// If an entity is local (i.e. it's a client/localplayer), this component
+/// should NOT be present in the entity.
+#[derive(Component, Debug, Deref, DerefMut)]
+pub struct UpdatesReceived(u32);
+
+impl EntityCommand for RelativeEntityUpdate {
+ fn apply(self, entity: Entity, world: &mut World) {
+ let partial_entity_infos = &mut self.partial_world.write().entity_infos;
+
+ let mut entity_mut = world.entity_mut(entity);
+
+ if Some(entity) == partial_entity_infos.owner_entity {
+ // if the entity owns this partial world, it's always allowed to update itself
+ (self.update)(&mut entity_mut);
+ return;
+ };
+
+ let entity_id = *entity_mut.get::<MinecraftEntityId>().unwrap();
+ let Some(updates_received) = entity_mut.get_mut::<UpdatesReceived>() else {
+ // a client tried to update another client, which isn't allowed
+ return;
+ };
+
+ let this_client_updates_received = partial_entity_infos
+ .updates_received
+ .get(&entity_id)
+ .copied();
+
+ let can_update = this_client_updates_received.unwrap_or(1) == **updates_received;
+ if can_update {
+ let new_updates_received = this_client_updates_received.unwrap_or(0) + 1;
+ partial_entity_infos
+ .updates_received
+ .insert(entity_id, new_updates_received);
+
+ **entity_mut.get_mut::<UpdatesReceived>().unwrap() = new_updates_received;
+
+ let mut entity = world.entity_mut(entity);
+ (self.update)(&mut entity);
+ }
+ }
+}
+
+#[allow(clippy::type_complexity)]
+pub fn add_updates_received(
+ mut commands: Commands,
+ query: Query<
+ Entity,
+ (
+ Changed<MinecraftEntityId>,
+ (Without<UpdatesReceived>, Without<Local>),
+ ),
+ >,
+) {
+ for entity in query.iter() {
+ // entities always start with 1 update received
+ commands.entity(entity).insert(UpdatesReceived(1));
+ }
+}
+
+/// The [`UpdatesReceived`] component should never be on [`Local`] entities.
+/// This warns if an entity has both components.
+pub fn debug_detect_updates_received_on_local_entities(
+ query: Query<Entity, (With<Local>, With<UpdatesReceived>)>,
+) {
+ for entity in &query {
+ warn!("Entity {:?} has both Local and UpdatesReceived", entity);
+ }
+}