diff --git a/CHANGELOG.md b/CHANGELOG.md index 84df05cf..37104b92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Removing `Replicated` from an entity now stops replication without despawning the entity on clients. Client-side despawns of `Remote` entities clean up their `ServerEntityMap` mappings. Despawning an entity still replicates as despawn. + ## [0.41.1] - 2026-06-24 ### Fixed diff --git a/src/client.rs b/src/client.rs index d462ccd4..b4c78906 100644 --- a/src/client.rs +++ b/src/client.rs @@ -64,6 +64,7 @@ impl Plugin for ClientPlugin { (ClientSystems::Send, ClientSystems::SendPackets).chain(), ) .add_observer(cleanup_storage) + .add_observer(cleanup_entity_map) .add_systems( PreUpdate, receive_replication @@ -189,6 +190,13 @@ fn cleanup_storage(remove: On, mut storage: If, mut entity_map: If>) { + entity_map.remove_by_client(despawn.entity); +} + fn reset( mut messages: ResMut, mut stats: ResMut, diff --git a/src/lib.rs b/src/lib.rs index 69c026f5..ebb586ad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -130,7 +130,10 @@ component on the server for entities you want to replicate. On clients [`Remote`] will be automatically inserted for every entity spawned by replication. -If you remove the [`Replicated`] component from an entity on the server, it will be despawned on all clients. +When an entity with the [`Replicated`] component is despawned, clients also receive a despawn. + +If you remove the [`Replicated`] component from an entity on the server, replication stops. +No replication messages will be sent, including when the entity is despawned. Entity IDs differ between clients and server. As a result, clients maps server entities to local entities on receive. These mappings are stored in the [`ServerEntityMap`](shared::server_entity_map::ServerEntityMap) diff --git a/src/server.rs b/src/server.rs index 666270d6..e33d0a49 100644 --- a/src/server.rs +++ b/src/server.rs @@ -320,13 +320,13 @@ fn buffer_removals( } fn buffer_despawn( - remove: On, + despawn: On, mut despawn_buffer: ResMut, state: Res>, ) { if *state == ServerState::Running { - trace!("buffering despawn of `{}`", remove.entity); - despawn_buffer.push(remove.entity); + trace!("buffering despawn of `{}`", despawn.entity); + despawn_buffer.push(despawn.entity); } } @@ -940,10 +940,6 @@ pub enum ServerSystems { struct ServerChangeTick(Tick); /// Buffer with all despawned entities. -/// -/// We treat removals of [`Replicated`] component as despawns -/// to avoid missing events in case the server's tick policy is -/// not [`TickPolicy::EveryFrame`]. #[derive(Resource, Deref, DerefMut, Default)] struct DespawnBuffer(Vec); diff --git a/src/shared/server_entity_map.rs b/src/shared/server_entity_map.rs index 0a48e167..2f937b8f 100644 --- a/src/shared/server_entity_map.rs +++ b/src/shared/server_entity_map.rs @@ -54,6 +54,13 @@ impl ServerEntityMap { ) } + /// Removes a mapping by its client entity. + pub(crate) fn remove_by_client(&mut self, client_entity: Entity) -> Option { + let server_entity = self.client_to_server.remove(&client_entity)?; + self.server_to_client.remove(&server_entity); + Some(server_entity) + } + /// Clears the map. pub(crate) fn clear(&mut self) { self.client_to_server.clear(); @@ -192,5 +199,9 @@ mod tests { CLIENT_ENTITY ); assert_eq!(map.server_entry(SERVER_ENTITY).get(), Some(CLIENT_ENTITY)); + + assert_eq!(map.remove_by_client(CLIENT_ENTITY), Some(SERVER_ENTITY)); + assert_eq!(map.server_entry(SERVER_ENTITY).get(), None); + assert!(!map.to_server().contains_key(&CLIENT_ENTITY)); } } diff --git a/tests/despawn.rs b/tests/despawn.rs index 22a836c5..adbafb41 100644 --- a/tests/despawn.rs +++ b/tests/despawn.rs @@ -136,7 +136,7 @@ fn after_spawn() { server_app.connect_client(&mut client_app); - // Insert and remove `Replicated` to trigger spawn and despawn for client at the same time. + // Insert and remove `Replicated` in the same tick. server_app .world_mut() .spawn((Replicated, TestComponent)) @@ -148,10 +148,63 @@ fn after_spawn() { assert_eq!( messages.drain_sent().len(), 0, - "client shouldn't receive anything for a despawned entity" + "client shouldn't receive anything for an unreplicated entity" ); } +#[test] +fn after_unreplicate() { + let mut server_app = App::new(); + let mut client_app = App::new(); + for app in [&mut server_app, &mut client_app] { + app.add_plugins(( + MinimalPlugins, + StatesPlugin, + RepliconPlugins.set(ServerPlugin::new(PostUpdate)), + )) + .finish(); + } + + server_app.connect_client(&mut client_app); + + let server_entity = server_app.world_mut().spawn(Replicated).id(); + + server_app.update(); + server_app.exchange_with_client(&mut client_app); + client_app.update(); + server_app.exchange_with_client(&mut client_app); + + let client_entity = *client_app + .world() + .resource::() + .to_client() + .get(&server_entity) + .unwrap(); + + let mut server_entity = server_app.world_mut().entity_mut(server_entity); + server_entity.remove::(); + server_entity.despawn(); + + server_app.update(); + server_app.exchange_with_client(&mut client_app); + client_app.update(); + + assert!( + client_app.world().get_entity(client_entity).is_ok(), + "client shouldn't receive a despawn for unreplicated entity" + ); + + let entity_map = client_app.world().resource::(); + assert!(!entity_map.to_client().is_empty()); + assert!(!entity_map.to_server().is_empty()); + + client_app.world_mut().despawn(client_entity); + + let entity_map = client_app.world().resource::(); + assert!(entity_map.to_client().is_empty()); + assert!(entity_map.to_server().is_empty()); +} + #[test] fn signature() { let mut server_app = App::new(); diff --git a/tests/insertion.rs b/tests/insertion.rs index 4e2bb331..2e5418ec 100644 --- a/tests/insertion.rs +++ b/tests/insertion.rs @@ -553,6 +553,63 @@ fn after_removal() { ); } +#[test] +fn after_unpause() { + let mut server_app = App::new(); + let mut client_app = App::new(); + for app in [&mut server_app, &mut client_app] { + app.add_plugins(( + MinimalPlugins, + StatesPlugin, + RepliconPlugins.set(ServerPlugin::new(PostUpdate)), + )) + .replicate::() + .replicate::() + .finish(); + } + + server_app.connect_client(&mut client_app); + + let server_entity = server_app.world_mut().spawn((Replicated, A)).id(); + + server_app.update(); + server_app.exchange_with_client(&mut client_app); + client_app.update(); + server_app.exchange_with_client(&mut client_app); + + let mut remote = client_app + .world_mut() + .query_filtered::, With, Without)>(); + let client_entity = remote.single(client_app.world()).unwrap(); + + server_app + .world_mut() + .entity_mut(server_entity) + .remove::() + .insert(B); + + server_app.update(); + server_app.exchange_with_client(&mut client_app); + client_app.update(); + + let client_entity_ref = client_app.world().entity(client_entity); + assert!(client_entity_ref.contains::()); + assert!(!client_entity_ref.contains::()); + + server_app + .world_mut() + .entity_mut(server_entity) + .insert(Replicated); + + server_app.update(); + server_app.exchange_with_client(&mut client_app); + client_app.update(); + + let client_entity_ref = client_app.world().entity(client_entity); + assert!(client_entity_ref.contains::()); + assert!(client_entity_ref.contains::()); +} + #[test] fn with_client_despawn() { let mut server_app = App::new(); @@ -589,8 +646,11 @@ fn with_client_despawn() { server_app.exchange_with_client(&mut client_app); client_app.update(); - let mut components = client_app.world_mut().query::<&A>(); - assert_eq!(components.iter(client_app.world()).len(), 0); + let mut components = client_app + .world_mut() + .query_filtered::, With)>(); + let new_client_entity = components.single(client_app.world()).unwrap(); + assert_ne!(new_client_entity, client_entity); } #[test] diff --git a/tests/removal.rs b/tests/removal.rs index f71f67f5..ce176e76 100644 --- a/tests/removal.rs +++ b/tests/removal.rs @@ -395,7 +395,7 @@ fn after_spawn() { } #[test] -fn after_despawn() { +fn after_unreplicate() { let mut server_app = App::new(); let mut client_app = App::new(); for app in [&mut server_app, &mut client_app] { @@ -417,8 +417,10 @@ fn after_despawn() { client_app.update(); server_app.exchange_with_client(&mut client_app); - let mut remote = client_app.world_mut().query::<&Remote>(); - assert_eq!(remote.iter(client_app.world()).len(), 1); + let mut remote = client_app + .world_mut() + .query_filtered::>(); + let client_entity = remote.single(client_app.world()).unwrap(); // Un-replicate and remove at the same time. server_app @@ -431,7 +433,9 @@ fn after_despawn() { server_app.exchange_with_client(&mut client_app); client_app.update(); - assert_eq!(remote.iter(client_app.world()).len(), 0); + let client_entity = client_app.world().entity(client_entity); + assert!(client_entity.contains::()); + assert!(client_entity.contains::()); } #[test]