From 4d63afb20b1a54f4ca84dad03da45da8cda6fd8f Mon Sep 17 00:00:00 2001 From: Charles Bournhonesque Date: Wed, 24 Jun 2026 23:36:14 -0400 Subject: [PATCH 1/4] simplify bei --- crates/inputs/input_bei/src/marker.rs | 70 +++--------- crates/inputs/input_bei/src/plugin.rs | 37 ++++--- crates/inputs/input_bei/src/setup.rs | 146 +++++++++++++++++++------- 3 files changed, 147 insertions(+), 106 deletions(-) diff --git a/crates/inputs/input_bei/src/marker.rs b/crates/inputs/input_bei/src/marker.rs index 4218207b5..d26722089 100644 --- a/crates/inputs/input_bei/src/marker.rs +++ b/crates/inputs/input_bei/src/marker.rs @@ -78,15 +78,15 @@ pub(crate) fn add_input_marker_from_parent( /// If Bindings or ActionMock is added to an Action entity, add the InputMarker to that Action entity. /// Only add the marker on locally controlled action entities that already have a network-facing -/// action mapping. This avoids emitting inputs for entities that the server cannot resolve yet. +/// action mapping, or on confirmed prespawned actions that already resolved through replication. +/// This avoids emitting inputs for entities that the server cannot resolve yet. pub(crate) fn add_input_marker_from_binding( trigger: On, action: Query< &ActionOf, ( - With>, - With>, - Without, + Or<(With>, With)>, + Without>, ), >, contexts: Query, With>, @@ -102,17 +102,21 @@ pub(crate) fn add_input_marker_from_binding( } } -/// If authority is granted after the action entity already exists, add the InputMarker -/// once the entity becomes locally controlled. -pub(crate) fn add_input_marker_from_authority( - trigger: On, +/// If an existing bound action becomes network-ready, authority-ready, or is +/// confirmed through prespawn matching, add the InputMarker once it targets the +/// local client. +pub(crate) fn add_input_marker_when_action_becomes_ready( + trigger: On, HasAuthority, ConfirmHistory)>, action: Query< &ActionOf, ( - With>, - With>, + // This is only a real guard for the HasAuthority trigger. The + // NetworkActionOf and ConfirmHistory triggers satisfy it by + // construction, but HasAuthority alone is not enough to send + // inputs: the action also needs a target the receiver can resolve. + Or<(With>, With)>, Or<(With, With)>, - Without, + Without>, ), >, contexts: Query, With>, @@ -127,47 +131,3 @@ pub(crate) fn add_input_marker_from_authority( .insert(InputMarker::::default()); } } - -/// If the network-facing action mapping becomes available after the entity already has bindings, -/// start treating it as a local input source at that point. -pub(crate) fn add_input_marker_from_network_action( - trigger: On>, - action: Query< - &ActionOf, - ( - With>, - With>, - Or<(With, With)>, - Without, - ), - >, - contexts: Query, With>, - clients: Query<(), With>, - mut commands: Commands, -) { - if let Ok(action_of) = action.get(trigger.entity) - && action_targets_local_client(action_of, &contexts, &clients) - { - commands - .entity(trigger.entity) - .insert(InputMarker::::default()); - } -} - -/// If a prespawned action entity is confirmed but still targets a locally controlled context, -/// keep using it as a local input source. -pub(crate) fn add_input_marker_from_confirmed_controlled_action( - trigger: On, - action: Query<&ActionOf, (With, Or<(With, With)>)>, - contexts: Query, With>, - clients: Query<(), With>, - mut commands: Commands, -) { - if let Ok(action_of) = action.get(trigger.entity) - && action_targets_local_client(action_of, &contexts, &clients) - { - commands - .entity(trigger.entity) - .insert(InputMarker::::default()); - } -} diff --git a/crates/inputs/input_bei/src/plugin.rs b/crates/inputs/input_bei/src/plugin.rs index fef2e148a..476b37548 100644 --- a/crates/inputs/input_bei/src/plugin.rs +++ b/crates/inputs/input_bei/src/plugin.rs @@ -46,12 +46,31 @@ use serde::de::DeserializeOwned; /// /// BEI uses separate "action entities" with [`ActionOf`] to represent /// individual actions. These entities need to exist on both client and server. -/// The recommended approach is to use [`PreSpawned`] so both sides spawn them -/// independently and match via a deterministic hash — this avoids the need -/// for client-to-server entity replication. +/// Spawn them on both sides with [`PreSpawned`] so they can be matched via a +/// deterministic hash; this plugin does not create action entities through +/// client-to-server entity replication. /// +/// Server-to-client action entity replication is still useful. It confirms the +/// owner's prespawned action entity and populates the entity maps used by input +/// messages. If input rebroadcasting is enabled, it also lets other clients +/// discover the remote player's action entity, its typed [`Action`] component, +/// and its [`ActionOf`] relationship so rebroadcasted input state has a +/// local action buffer to target. +/// +/// The replicated [`Action`] component is structural: it recreates the typed BEI +/// action entity on the receiver, but does not carry runtime input state. The +/// action relationship is replicated through an internal network wrapper for +/// [`ActionOf`], and live action state is sent by [`BEIStateSequence`] input +/// messages. The owning client adds [`InputMarker`] to local action entities, +/// buffers BEI trigger state/value/time each tick, and sends those snapshots to +/// the server. If input rebroadcasting is enabled, the server forwards those +/// input messages to other clients so they can update remote action buffers for +/// prediction. +/// +/// [`Action`]: bevy_enhanced_input::prelude::Action /// [`BEIStateSequence`]: crate::input_message::BEIStateSequence /// [`ActionOf`]: bevy_enhanced_input::prelude::ActionOf +/// [`InputMarker`]: crate::marker::InputMarker /// [`PreSpawned`]: lightyear_replication::prelude::PreSpawned pub struct InputPlugin { pub config: InputConfig, @@ -118,10 +137,8 @@ impl< #[cfg(feature = "client")] { use crate::marker::{ - add_input_marker_from_authority, add_input_marker_from_binding, - add_input_marker_from_confirmed_controlled_action, - add_input_marker_from_network_action, add_input_marker_from_parent, - propagate_input_marker, + add_input_marker_from_binding, add_input_marker_from_parent, + add_input_marker_when_action_becomes_ready, propagate_input_marker, }; // for rebroadcasting inputs, we insert TriggerState (which inserts the InputBuffer) when ActionOf is added // on an entity @@ -130,9 +147,7 @@ impl< app.add_observer(propagate_input_marker::); app.add_observer(add_input_marker_from_parent::); app.add_observer(add_input_marker_from_binding::); - app.add_observer(add_input_marker_from_authority::); - app.add_observer(add_input_marker_from_network_action::); - app.add_observer(add_input_marker_from_confirmed_controlled_action::); + app.add_observer(add_input_marker_when_action_becomes_ready::); if self.config.rebroadcast_inputs { app.add_observer(InputRegistryPlugin::on_rebroadcast_action_received::); @@ -147,8 +162,6 @@ impl< ); } - app.add_observer(InputRegistryPlugin::add_action_of_replicate::); - app.add_plugins(lightyear_inputs::client::ClientInputPlugin::< BEIStateSequence, >::new(self.config)); diff --git a/crates/inputs/input_bei/src/setup.rs b/crates/inputs/input_bei/src/setup.rs index 128e06555..07d333121 100644 --- a/crates/inputs/input_bei/src/setup.rs +++ b/crates/inputs/input_bei/src/setup.rs @@ -10,7 +10,7 @@ use bevy_replicon::shared::server_entity_map::ServerEntityMap; use { bevy_enhanced_input::context::ExternallyMocked, lightyear_connection::client::Client, - lightyear_replication::prelude::{Controlled, ControlledBy, Replicate}, + lightyear_replication::prelude::{Controlled, ControlledBy}, }; use bevy_enhanced_input::prelude::*; @@ -41,6 +41,27 @@ use { pub struct InputRegistryPlugin; +/// Replication-facing mirror of [`ActionOf`]. +/// +/// BEI's [`ActionOf`] stores the context entity in the local world's entity +/// namespace. That is not always the entity the receiver needs: a client-side +/// action may point at a client-local predicted or prespawned context, while +/// the server must receive the corresponding server entity. +/// +/// Before replication, [`mirror_action_of_for_replication`] resolves the local +/// context through the available entity maps and stores the remote-side context +/// entity here. After replication, [`insert_action_of_from_network`] maps this +/// value back into the receiver's local world and recreates [`ActionOf`]. +/// +/// This component intentionally does not implement `MapEntities`. Replicon's +/// serializer only has Replicon's mapping context, while this path may need +/// Lightyear's message entity map or the server entity map, and some action +/// entities are created before those maps are populated. Mapping is therefore +/// done explicitly by the mirror/resolve systems instead of by component-level +/// `MapEntities`. +/// +/// [`mirror_action_of_for_replication`]: InputRegistryPlugin::mirror_action_of_for_replication +/// [`insert_action_of_from_network`]: InputRegistryPlugin::insert_action_of_from_network #[derive(Component, Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct NetworkActionOf { entity: Entity, @@ -61,6 +82,19 @@ impl NetworkActionOf { } impl InputRegistryPlugin { + /// Mirrors a local [`ActionOf`] into [`NetworkActionOf`] so action + /// entities can be replicated with an entity reference the receiver can + /// resolve. + /// + /// The mirrored entity is deliberately the remote-side context entity, not + /// the raw [`ActionOf`] target from this world. For example, when a + /// client spawns a prespawned action for a replicated context, its + /// [`ActionOf`] points at the client's local context entity, but the + /// server needs the server entity. If that mapping is not available when + /// the component is added, [`resolve_pending_network_action_of`] retries + /// after replication receive has had a chance to populate the maps. + /// + /// [`resolve_pending_network_action_of`]: InputRegistryPlugin::resolve_pending_network_action_of pub(crate) fn mirror_action_of_for_replication( trigger: On>, action_of: Query<&ActionOf, Without>, @@ -89,6 +123,31 @@ impl InputRegistryPlugin { .insert(NetworkActionOf::::new(remote_entity)); } + /// Retries [`ActionOf`] -> [`NetworkActionOf`] mirroring for local + /// action entities whose context could not be mapped when [`ActionOf`] + /// was first added. + /// + /// This commonly happens when the action is spawned against a replicated or + /// prespawned context before Replicon/Lightyear have populated the relevant + /// entity maps for that context. The observer path only runs once, so this + /// system runs after replication receive and fills in [`NetworkActionOf`] + /// as soon as the remote-side context entity becomes known. + /// + /// For purely local contexts we may mirror the context entity directly. For + /// contexts carrying [`Remote`], identity fallback is not allowed because the + /// local entity id is explicitly a receiver-local id and would not be + /// meaningful to the peer. + /// + /// This retry was less important in the old client-to-server action + /// replication flow, where [`ActionOf`] itself was serialized as part of + /// a concrete outgoing replication message. In that setup, entity mapping + /// happened at send time with that connection's mapper, and the receiver + /// created the action entity from the replicated component. With the + /// prespawned flow, both worlds can spawn the action entity before the + /// prespawn/context mapping has settled, while [`NetworkActionOf`] is an + /// ordinary component that must exist before the action relationship can be + /// replicated or rebroadcast. The add-observer only fires once, so this + /// system fills in the mirror once the mapping appears. pub(crate) fn resolve_pending_network_action_of( pending: Query<(Entity, &ActionOf), (Without>, Without)>, remote_contexts: Query<(), With>, @@ -114,6 +173,20 @@ impl InputRegistryPlugin { } } + /// Recreates BEI's local [`ActionOf`] relationship when a replicated + /// action entity receives [`NetworkActionOf`]. + /// + /// [`NetworkActionOf`] stores the sender/authoritative context entity. + /// This observer maps that entity into this world's local context entity and + /// inserts [`ActionOf`], allowing BEI's relationship hooks to add the + /// action to the context's [`Actions`] collection. + /// + /// If the context mapping is not known yet, the observer leaves the entity + /// unchanged and [`resolve_pending_action_of`] retries after replication + /// receive. The query is limited to replicated action entities (`With`) + /// because local action entities already have their own [`ActionOf`]. + /// + /// [`resolve_pending_action_of`]: InputRegistryPlugin::resolve_pending_action_of pub(crate) fn insert_action_of_from_network( trigger: On>, query: Query<&NetworkActionOf, (Without>, With)>, @@ -143,6 +216,20 @@ impl InputRegistryPlugin { } } + /// Retries [`NetworkActionOf`] -> [`ActionOf`] reconstruction for + /// replicated action entities whose context could not be mapped when + /// [`NetworkActionOf`] was first received. + /// + /// The serialized [`NetworkActionOf`] stores the sender/authoritative + /// context entity. The receiver needs a local context entity before BEI can + /// attach the action to an [`Actions`] collection. Replication can create + /// the action entity before the corresponding context mapping is visible, so + /// the add-observer may have to defer and this system retries after + /// replication receive has updated the maps. + /// + /// Host-client and server worlds may use identity mapping for entities that + /// already live in the same world. Remote clients do not, because an + /// unmapped remote entity id must not be interpreted as a local context. pub(crate) fn resolve_pending_action_of( pending: Query<(Entity, &NetworkActionOf), (Without>, With)>, entity_map: Option>, @@ -242,40 +329,6 @@ impl InputRegistryPlugin { } } - /// When an [`ActionOf`] component is added to an entity (usually on the client), - /// we add Replicate to it so that the action entity is also created on the server. - /// - /// PreSpawned Actions must be replicated from server to client. - /// No need to change anything about ActionOf because the Context and Action will be received at the same time, - /// so the entity mapping in ActionOf will work properly. - #[cfg(feature = "client")] - pub(crate) fn add_action_of_replicate( - trigger: On>, - server: Query<(), (With, With)>, - // we don't want to add Replicate on action entities that were already received - // PreSpawned entities are replicated from server to client - action: Query< - &ActionOf, - ( - With>, - Without, - Without, - ), - >, - mut commands: Commands, - ) { - if server.single().is_ok() { - // we're on the server, don't do anything - return; - } - let entity = trigger.entity; - if let Ok(action_of) = action.get(entity) { - let context_entity = action_of.get(); - debug!(action_entity = ?entity, "Replicating ActionOf<{:?}> for context entity {context_entity:?} from client to server", DebugName::type_name::()); - commands.entity(entity).insert((Replicate::to_server(),)); - } - } - /// When the server receives [`ActionOf`], optionally rebroadcast to other clients if rebroadcast_inputs is enabled #[cfg(feature = "server")] pub(crate) fn on_action_of_replicated( @@ -359,6 +412,10 @@ fn resolve_remote_entity<'a>( return Some(*remote_entity); } + // There can be several connections in one world: a server has one + // MessageManager per ClientOf, and host-server worlds also have the + // HostClient manager. The action-context entity may have been mapped by any + // active connection, so use the first manager that knows it. managers.find_map(|manager| manager.entity_mapper.get_remote(local_entity)) } @@ -385,9 +442,10 @@ fn resolve_local_entity<'a>( return Some(*local_entity); } - if let Some(local_entity) = - managers.find_map(|manager| manager.entity_mapper.get_local(remote_entity)) - { + // See resolve_remote_entity: this system is topology-agnostic and can run + // in worlds with multiple per-connection MessageManagers. + let local_entity = managers.find_map(|manager| manager.entity_mapper.get_local(remote_entity)); + if let Some(local_entity) = local_entity { return Some(local_entity); } @@ -396,7 +454,17 @@ fn resolve_local_entity<'a>( .flatten() } -// we don't care about the actual data in Action, so nothing to serialize +/// Serializes only the presence and type of [`Action`]. +/// +/// The value stored inside BEI's [`Action`] is local runtime state. Lightyear +/// sends that state through [`BEIStateSequence`](crate::input_message::BEIStateSequence), +/// whose snapshots include the trigger state, action value, events, and timing. +/// Relationship data is handled separately by mirroring [`ActionOf`] into +/// [`NetworkActionOf`]. Therefore component replication only needs to create +/// the correctly typed action component on the receiver, and +/// [`deserialize_action`] can rebuild it from `Default`. +/// +/// [`ActionOf`]: bevy_enhanced_input::prelude::ActionOf fn serialize_action( _ctx: &mut SerializeCtx, _: &Action, From 71e19ae5f1fb47bad73ea42fe532c09765d9c1ab Mon Sep 17 00:00:00 2001 From: Charles Bournhonesque Date: Thu, 25 Jun 2026 22:02:08 -0400 Subject: [PATCH 2/4] simplify BEI replication --- crates/inputs/input_bei/src/marker.rs | 68 ++-- crates/inputs/input_bei/src/plugin.rs | 72 ++-- crates/inputs/input_bei/src/setup.rs | 326 +----------------- crates/inputs/inputs/src/client.rs | 1 - crates/inputs/inputs/src/input_message.rs | 2 +- crates/inputs/inputs/src/server.rs | 7 +- crates/replication/prediction/src/plugin.rs | 13 +- .../prediction/src/predicted_history.rs | 22 -- crates/tests/src/client_server/input/bei.rs | 125 ++++++- .../src/client_server/prediction/rollback.rs | 56 +++ .../bevy_enhanced_inputs/src/automation.rs | 1 + examples/bevy_enhanced_inputs/src/client.rs | 68 ++-- examples/bevy_enhanced_inputs/src/renderer.rs | 40 --- examples/bevy_enhanced_inputs/src/server.rs | 32 +- examples/bevy_enhanced_inputs/src/shared.rs | 47 --- examples/projectiles/src/client.rs | 64 +--- examples/projectiles/src/server.rs | 21 +- examples/projectiles/src/shared.rs | 126 +------ 18 files changed, 340 insertions(+), 751 deletions(-) diff --git a/crates/inputs/input_bei/src/marker.rs b/crates/inputs/input_bei/src/marker.rs index d26722089..ede392589 100644 --- a/crates/inputs/input_bei/src/marker.rs +++ b/crates/inputs/input_bei/src/marker.rs @@ -1,12 +1,11 @@ //! Add an [`InputMarker`] component automatically to [`Action`] entities that need it -use crate::setup::NetworkActionOf; use bevy_ecs::prelude::*; use bevy_ecs::relationship::Relationship; use bevy_enhanced_input::prelude::*; use bevy_replicon::client::confirm_history::ConfirmHistory; use lightyear_connection::client::Client; -use lightyear_replication::prelude::{Controlled, ControlledBy, HasAuthority}; +use lightyear_replication::prelude::{Controlled, ControlledBy}; /// Marker component that indicates that the entity is actively listening for physical user inputs. /// @@ -40,18 +39,17 @@ fn action_targets_local_client( /// Propagate the InputMarker component from the Context entity to the Action entities /// whenever an InputMarker is added to a Context entity. -/// Skip replicated action entities (those received from remote clients). +/// +/// `InputMarker` on the context is the explicit local-input signal, so +/// confirmed prespawned owner actions should still receive it. Remote +/// rebroadcasted actions should be attached to contexts without this marker. pub(crate) fn propagate_input_marker( trigger: On>, actions: Query<&Actions>, - confirm: Query<(), With>, mut commands: Commands, ) { if let Ok(actions) = actions.get(trigger.entity) { actions.iter().for_each(|action| { - if confirm.get(action).is_ok() { - return; - } commands.entity(action).insert(InputMarker::::default()); }); } @@ -59,62 +57,53 @@ pub(crate) fn propagate_input_marker( /// When an Action entity is added to a Context entity that has an InputMarker, /// add the InputMarker to the Action entity as well. -/// Skip replicated entities — those are received from remote clients and should not -/// be marked as local input sources. pub(crate) fn add_input_marker_from_parent( trigger: On>, - action_of: Query<&ActionOf, Without>, + action_of: Query<&ActionOf>, context: Query<(), With>>, mut commands: Commands, ) { - if let Ok(action_of) = action_of.get(trigger.entity) - && context.get(action_of.get()).is_ok() - { + let Ok(action_of) = action_of.get(trigger.entity) else { + return; + }; + if context.get(action_of.get()).is_ok() { commands .entity(trigger.entity) .insert(InputMarker::::default()); } } -/// If Bindings or ActionMock is added to an Action entity, add the InputMarker to that Action entity. -/// Only add the marker on locally controlled action entities that already have a network-facing -/// action mapping, or on confirmed prespawned actions that already resolved through replication. -/// This avoids emitting inputs for entities that the server cannot resolve yet. +/// If Bindings or ActionMock is added to an Action entity, add the InputMarker +/// to that Action entity once the action has a network-resolvable identity. +/// +/// Replicated/prespawned actions become resolvable when [`ConfirmHistory`] is +/// present, because the client's action entity can then be mapped back to the +/// server action entity when an input message is sent. pub(crate) fn add_input_marker_from_binding( trigger: On, - action: Query< - &ActionOf, - ( - Or<(With>, With)>, - Without>, - ), - >, + action: Query<&ActionOf, (With, Without>)>, contexts: Query, With>, clients: Query<(), With>, mut commands: Commands, ) { - if let Ok(action_of) = action.get(trigger.entity) - && action_targets_local_client(action_of, &contexts, &clients) - { + let Ok(action_of) = action.get(trigger.entity) else { + return; + }; + if action_targets_local_client(action_of, &contexts, &clients) { commands .entity(trigger.entity) .insert(InputMarker::::default()); } } -/// If an existing bound action becomes network-ready, authority-ready, or is -/// confirmed through prespawn matching, add the InputMarker once it targets the -/// local client. +/// If an existing bound action becomes network-resolvable, add the InputMarker +/// once it targets the local client. pub(crate) fn add_input_marker_when_action_becomes_ready( - trigger: On, HasAuthority, ConfirmHistory)>, + trigger: On, action: Query< &ActionOf, ( - // This is only a real guard for the HasAuthority trigger. The - // NetworkActionOf and ConfirmHistory triggers satisfy it by - // construction, but HasAuthority alone is not enough to send - // inputs: the action also needs a target the receiver can resolve. - Or<(With>, With)>, + With, Or<(With, With)>, Without>, ), @@ -123,9 +112,10 @@ pub(crate) fn add_input_marker_when_action_becomes_ready( clients: Query<(), With>, mut commands: Commands, ) { - if let Ok(action_of) = action.get(trigger.entity) - && action_targets_local_client(action_of, &contexts, &clients) - { + let Ok(action_of) = action.get(trigger.entity) else { + return; + }; + if action_targets_local_client(action_of, &contexts, &clients) { commands .entity(trigger.entity) .insert(InputMarker::::default()); diff --git a/crates/inputs/input_bei/src/plugin.rs b/crates/inputs/input_bei/src/plugin.rs index 476b37548..410b46806 100644 --- a/crates/inputs/input_bei/src/plugin.rs +++ b/crates/inputs/input_bei/src/plugin.rs @@ -1,9 +1,11 @@ #[cfg(any(feature = "client", feature = "server"))] use crate::input_message::{BEIBuffer, BEIStateSequence}; +#[cfg(any(feature = "client", feature = "server"))] use crate::setup::InputRegistryPlugin; -use bevy_app::{PreUpdate, prelude::*}; +use bevy_app::prelude::*; use bevy_ecs::prelude::*; +#[cfg(any(feature = "client", feature = "server"))] use bevy_ecs::schedule::IntoScheduleConfigs; #[cfg(all(feature = "client", feature = "server"))] use bevy_ecs::schedule::common_conditions::not; @@ -12,10 +14,9 @@ use bevy_enhanced_input::EnhancedInputSystems; #[cfg(feature = "client")] use bevy_enhanced_input::action::TriggerState; use bevy_enhanced_input::context::InputContextAppExt; -#[cfg(any(feature = "client", feature = "server"))] use bevy_enhanced_input::prelude::ActionOf; use bevy_reflect::TypePath; -use bevy_replicon::prelude::{AppRuleExt, ReplicationMode, RuleFns}; +use bevy_replicon::prelude::AppRuleExt; use bevy_replicon::shared::replication::registry::receive_fns::MutWrite; use core::fmt::Debug; #[cfg(feature = "client")] @@ -23,8 +24,6 @@ use lightyear_core::prelude::is_in_rollback; #[cfg(feature = "client")] use lightyear_inputs::client::InputSystems; use lightyear_inputs::config::InputConfig; -use lightyear_messages::plugin::MessageSystems; -use lightyear_replication::ReplicationSystems; use serde::Serialize; use serde::de::DeserializeOwned; @@ -45,33 +44,35 @@ use serde::de::DeserializeOwned; /// # Action entities /// /// BEI uses separate "action entities" with [`ActionOf`] to represent -/// individual actions. These entities need to exist on both client and server. -/// Spawn them on both sides with [`PreSpawned`] so they can be matched via a -/// deterministic hash; this plugin does not create action entities through -/// client-to-server entity replication. +/// individual actions. In the server-authoritative flow, spawn those action +/// entities on the server and replicate them to clients along with the context +/// entity. The owning client should add local-only [`Bindings`] once its local +/// controlled context has the replicated [`Action`] entity in its +/// [`ActionOf`]/`Actions` relationship. /// -/// Server-to-client action entity replication is still useful. It confirms the -/// owner's prespawned action entity and populates the entity maps used by input -/// messages. If input rebroadcasting is enabled, it also lets other clients -/// discover the remote player's action entity, its typed [`Action`] component, -/// and its [`ActionOf`] relationship so rebroadcasted input state has a -/// local action buffer to target. +/// Replicating the action entity is also what lets remote clients receive +/// rebroadcasted BEI input. Rebroadcasted [`BEIStateSequence`] messages target +/// action entities, so a remote client needs a corresponding replicated action +/// entity to resolve the target and buffer the remote player's input state. /// /// The replicated [`Action`] component is structural: it recreates the typed BEI /// action entity on the receiver, but does not carry runtime input state. The -/// action relationship is replicated through an internal network wrapper for -/// [`ActionOf`], and live action state is sent by [`BEIStateSequence`] input -/// messages. The owning client adds [`InputMarker`] to local action entities, -/// buffers BEI trigger state/value/time each tick, and sends those snapshots to -/// the server. If input rebroadcasting is enabled, the server forwards those -/// input messages to other clients so they can update remote action buffers for -/// prediction. +/// action relationship is replicated directly through [`ActionOf`]. Bevy's +/// relationship component maps the inner context entity through +/// [`Component::map_entities`], and Replicon's default deserialize path calls +/// that mapping after replication has populated the entity map. +/// Live action state is sent by [`BEIStateSequence`] input messages. The owning +/// client adds [`InputMarker`] to local action entities, buffers BEI trigger +/// state/value/time each tick, and sends those snapshots to the server. If input +/// rebroadcasting is enabled, the server forwards those input messages to other +/// clients so they can update remote action buffers for prediction. /// /// [`Action`]: bevy_enhanced_input::prelude::Action +/// [`Bindings`]: bevy_enhanced_input::prelude::Bindings /// [`BEIStateSequence`]: crate::input_message::BEIStateSequence /// [`ActionOf`]: bevy_enhanced_input::prelude::ActionOf /// [`InputMarker`]: crate::marker::InputMarker -/// [`PreSpawned`]: lightyear_replication::prelude::PreSpawned +/// [`Replicate`]: lightyear_replication::prelude::Replicate pub struct InputPlugin { pub config: InputConfig, } @@ -110,30 +111,7 @@ impl< app.add_input_context_to::(); // we register the context C entity so that it can be replicated from the server to the client app.replicate::(); - - // We mirror ActionOf into a separate component that stores the authoritative - // remote entity. That avoids depending on sender-side entity mapping in replicon's - // SerializeCtx. - app.replicate_with(( - RuleFns::new( - crate::setup::serialize_network_action_of::, - crate::setup::deserialize_network_action_of::, - ), - ReplicationMode::default(), - )); - app.add_observer(InputRegistryPlugin::mirror_action_of_for_replication::); - app.add_observer(InputRegistryPlugin::insert_action_of_from_network::); - app.add_systems( - PreUpdate, - ( - InputRegistryPlugin::resolve_pending_network_action_of::, - InputRegistryPlugin::resolve_pending_action_of::, - ) - .chain() - .after(ReplicationSystems::Receive) - .before(MessageSystems::Receive), - ); - + app.replicate::>(); #[cfg(feature = "client")] { use crate::marker::{ diff --git a/crates/inputs/input_bei/src/setup.rs b/crates/inputs/input_bei/src/setup.rs index 07d333121..f1e92d92d 100644 --- a/crates/inputs/input_bei/src/setup.rs +++ b/crates/inputs/input_bei/src/setup.rs @@ -1,11 +1,12 @@ use alloc::vec::Vec; use bevy_app::App; +#[cfg(any(feature = "client", feature = "server"))] use bevy_ecs::prelude::*; +#[cfg(any(feature = "client", feature = "server"))] use bevy_ecs::relationship::Relationship; use bevy_replicon::bytes::Bytes; use bevy_replicon::prelude::*; use bevy_replicon::shared::replication::registry::ctx::{SerializeCtx, WriteCtx}; -use bevy_replicon::shared::server_entity_map::ServerEntityMap; #[cfg(feature = "client")] use { bevy_enhanced_input::context::ExternallyMocked, @@ -16,245 +17,29 @@ use { use bevy_enhanced_input::prelude::*; #[cfg(any(feature = "client", feature = "server"))] use bevy_utils::prelude::DebugName; +#[cfg(any(feature = "client", feature = "server"))] +use lightyear_connection::host::HostClient; #[cfg(all(feature = "client", feature = "server"))] use lightyear_connection::host::HostServer; -use lightyear_connection::{host::HostClient, server::Started}; +#[cfg(feature = "server")] +use lightyear_connection::server::Started; +#[cfg(feature = "server")] use lightyear_link::prelude::Server; +#[cfg(feature = "server")] use lightyear_messages::MessageManager; -#[cfg(feature = "client")] +#[cfg(all(feature = "client", feature = "server"))] use lightyear_replication::prelude::PreSpawned; #[allow(unused_imports)] -use tracing::{debug, info}; +use tracing::{debug, warn}; #[cfg(feature = "server")] use { lightyear_inputs::server::ServerInputConfig, lightyear_replication::prelude::{InterpolationTarget, PredictionTarget, ReplicateLike}, }; -// TODO: ideally we would have an entity-mapped that is PreSpawn aware. If you include an entity -// that is PreSpawned, then in the entity-mapper we use a Query> to check the hash -// of the entity and serialize it as the hash. Then the receiving entity mapper could look up the corresponding -// entity by the PreSpawn hash to apply entity mapping. -// 1. In common case, server sends P1,C1. It does NOT need to change ChildOf(P1) because client will match P1/C1 on receipt, then -// update its entity maps, then the component map entity will work correctly. We just need to make sure that C1 is also Prespawned, -// which we could do in ReplicateLike Propagation? (but how to do it on the receiver side?) -// pub struct InputRegistryPlugin; -/// Replication-facing mirror of [`ActionOf`]. -/// -/// BEI's [`ActionOf`] stores the context entity in the local world's entity -/// namespace. That is not always the entity the receiver needs: a client-side -/// action may point at a client-local predicted or prespawned context, while -/// the server must receive the corresponding server entity. -/// -/// Before replication, [`mirror_action_of_for_replication`] resolves the local -/// context through the available entity maps and stores the remote-side context -/// entity here. After replication, [`insert_action_of_from_network`] maps this -/// value back into the receiver's local world and recreates [`ActionOf`]. -/// -/// This component intentionally does not implement `MapEntities`. Replicon's -/// serializer only has Replicon's mapping context, while this path may need -/// Lightyear's message entity map or the server entity map, and some action -/// entities are created before those maps are populated. Mapping is therefore -/// done explicitly by the mirror/resolve systems instead of by component-level -/// `MapEntities`. -/// -/// [`mirror_action_of_for_replication`]: InputRegistryPlugin::mirror_action_of_for_replication -/// [`insert_action_of_from_network`]: InputRegistryPlugin::insert_action_of_from_network -#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct NetworkActionOf { - entity: Entity, - marker: core::marker::PhantomData, -} - -impl NetworkActionOf { - fn new(entity: Entity) -> Self { - Self { - entity, - marker: core::marker::PhantomData, - } - } - - fn get(&self) -> Entity { - self.entity - } -} - impl InputRegistryPlugin { - /// Mirrors a local [`ActionOf`] into [`NetworkActionOf`] so action - /// entities can be replicated with an entity reference the receiver can - /// resolve. - /// - /// The mirrored entity is deliberately the remote-side context entity, not - /// the raw [`ActionOf`] target from this world. For example, when a - /// client spawns a prespawned action for a replicated context, its - /// [`ActionOf`] points at the client's local context entity, but the - /// server needs the server entity. If that mapping is not available when - /// the component is added, [`resolve_pending_network_action_of`] retries - /// after replication receive has had a chance to populate the maps. - /// - /// [`resolve_pending_network_action_of`]: InputRegistryPlugin::resolve_pending_network_action_of - pub(crate) fn mirror_action_of_for_replication( - trigger: On>, - action_of: Query<&ActionOf, Without>, - remote_contexts: Query<(), With>, - entity_map: Option>, - managers: Query<&MessageManager>, - mut commands: Commands, - ) { - let entity = trigger.entity; - let Ok(action_of) = action_of.get(entity) else { - return; - }; - - let context_entity = action_of.get(); - let remote_entity = resolve_remote_action_context( - context_entity, - remote_contexts.contains(context_entity), - entity_map.as_deref(), - managers.iter(), - ); - let Some(remote_entity) = remote_entity else { - return; - }; - commands - .entity(entity) - .insert(NetworkActionOf::::new(remote_entity)); - } - - /// Retries [`ActionOf`] -> [`NetworkActionOf`] mirroring for local - /// action entities whose context could not be mapped when [`ActionOf`] - /// was first added. - /// - /// This commonly happens when the action is spawned against a replicated or - /// prespawned context before Replicon/Lightyear have populated the relevant - /// entity maps for that context. The observer path only runs once, so this - /// system runs after replication receive and fills in [`NetworkActionOf`] - /// as soon as the remote-side context entity becomes known. - /// - /// For purely local contexts we may mirror the context entity directly. For - /// contexts carrying [`Remote`], identity fallback is not allowed because the - /// local entity id is explicitly a receiver-local id and would not be - /// meaningful to the peer. - /// - /// This retry was less important in the old client-to-server action - /// replication flow, where [`ActionOf`] itself was serialized as part of - /// a concrete outgoing replication message. In that setup, entity mapping - /// happened at send time with that connection's mapper, and the receiver - /// created the action entity from the replicated component. With the - /// prespawned flow, both worlds can spawn the action entity before the - /// prespawn/context mapping has settled, while [`NetworkActionOf`] is an - /// ordinary component that must exist before the action relationship can be - /// replicated or rebroadcast. The add-observer only fires once, so this - /// system fills in the mirror once the mapping appears. - pub(crate) fn resolve_pending_network_action_of( - pending: Query<(Entity, &ActionOf), (Without>, Without)>, - remote_contexts: Query<(), With>, - entity_map: Option>, - managers: Query<&MessageManager>, - mut commands: Commands, - ) { - for (entity, action_of) in pending.iter() { - let context_entity = action_of.get(); - let remote_entity = resolve_remote_action_context( - context_entity, - remote_contexts.contains(context_entity), - entity_map.as_deref(), - managers.iter(), - ); - let Some(remote_entity) = remote_entity else { - continue; - }; - - commands - .entity(entity) - .insert(NetworkActionOf::::new(remote_entity)); - } - } - - /// Recreates BEI's local [`ActionOf`] relationship when a replicated - /// action entity receives [`NetworkActionOf`]. - /// - /// [`NetworkActionOf`] stores the sender/authoritative context entity. - /// This observer maps that entity into this world's local context entity and - /// inserts [`ActionOf`], allowing BEI's relationship hooks to add the - /// action to the context's [`Actions`] collection. - /// - /// If the context mapping is not known yet, the observer leaves the entity - /// unchanged and [`resolve_pending_action_of`] retries after replication - /// receive. The query is limited to replicated action entities (`With`) - /// because local action entities already have their own [`ActionOf`]. - /// - /// [`resolve_pending_action_of`]: InputRegistryPlugin::resolve_pending_action_of - pub(crate) fn insert_action_of_from_network( - trigger: On>, - query: Query<&NetworkActionOf, (Without>, With)>, - entity_map: Option>, - managers: Query<&MessageManager>, - all_entities: Query<(), ()>, - host_clients: Query<(), With>, - servers: Query<(), (With, With)>, - mut commands: Commands, - ) { - let entity = trigger.entity; - let Ok(network_action_of) = query.get(entity) else { - return; - }; - - let allow_identity = !host_clients.is_empty() || !servers.is_empty(); - if let Some(mapped) = resolve_local_entity( - network_action_of.get(), - entity_map.as_deref(), - managers.iter(), - &all_entities, - allow_identity, - ) - .filter(|mapped| *mapped != entity) - { - commands.entity(entity).insert(ActionOf::::new(mapped)); - } - } - - /// Retries [`NetworkActionOf`] -> [`ActionOf`] reconstruction for - /// replicated action entities whose context could not be mapped when - /// [`NetworkActionOf`] was first received. - /// - /// The serialized [`NetworkActionOf`] stores the sender/authoritative - /// context entity. The receiver needs a local context entity before BEI can - /// attach the action to an [`Actions`] collection. Replication can create - /// the action entity before the corresponding context mapping is visible, so - /// the add-observer may have to defer and this system retries after - /// replication receive has updated the maps. - /// - /// Host-client and server worlds may use identity mapping for entities that - /// already live in the same world. Remote clients do not, because an - /// unmapped remote entity id must not be interpreted as a local context. - pub(crate) fn resolve_pending_action_of( - pending: Query<(Entity, &NetworkActionOf), (Without>, With)>, - entity_map: Option>, - managers: Query<&MessageManager>, - all_entities: Query<(), ()>, - host_clients: Query<(), With>, - servers: Query<(), (With, With)>, - mut commands: Commands, - ) { - let allow_identity = !host_clients.is_empty() || !servers.is_empty(); - for (entity, network_action_of) in pending.iter() { - if let Some(mapped) = resolve_local_entity( - network_action_of.get(), - entity_map.as_deref(), - managers.iter(), - &all_entities, - allow_identity, - ) - .filter(|mapped| *mapped != entity) - { - commands.entity(entity).insert(ActionOf::::new(mapped)); - } - } - } - /// For Host-Server, if an ActionOf is spawned directly on the HostClient. /// (without being received from replication, or with Prespawned) /// Then we initiate rebroadcast @@ -401,70 +186,20 @@ impl InputRegistryPlugin { } } -fn resolve_remote_entity<'a>( - local_entity: Entity, - entity_map: Option<&ServerEntityMap>, - mut managers: impl Iterator, -) -> Option { - if let Some(entity_map) = entity_map - && let Some(remote_entity) = entity_map.to_server().get(&local_entity) - { - return Some(*remote_entity); - } - - // There can be several connections in one world: a server has one - // MessageManager per ClientOf, and host-server worlds also have the - // HostClient manager. The action-context entity may have been mapped by any - // active connection, so use the first manager that knows it. - managers.find_map(|manager| manager.entity_mapper.get_remote(local_entity)) -} - -fn resolve_remote_action_context<'a>( - local_entity: Entity, - remote_context: bool, - entity_map: Option<&ServerEntityMap>, - managers: impl Iterator, -) -> Option { - resolve_remote_entity(local_entity, entity_map, managers) - .or_else(|| (!remote_context).then_some(local_entity)) -} - -fn resolve_local_entity<'a>( - remote_entity: Entity, - entity_map: Option<&ServerEntityMap>, - mut managers: impl Iterator, - all_entities: &Query<(), ()>, - allow_identity: bool, -) -> Option { - if let Some(entity_map) = entity_map - && let Some(local_entity) = entity_map.to_client().get(&remote_entity) - { - return Some(*local_entity); - } - - // See resolve_remote_entity: this system is topology-agnostic and can run - // in worlds with multiple per-connection MessageManagers. - let local_entity = managers.find_map(|manager| manager.entity_mapper.get_local(remote_entity)); - if let Some(local_entity) = local_entity { - return Some(local_entity); - } - - allow_identity - .then(|| all_entities.get(remote_entity).ok().map(|()| remote_entity)) - .flatten() -} - /// Serializes only the presence and type of [`Action`]. /// /// The value stored inside BEI's [`Action`] is local runtime state. Lightyear /// sends that state through [`BEIStateSequence`](crate::input_message::BEIStateSequence), /// whose snapshots include the trigger state, action value, events, and timing. -/// Relationship data is handled separately by mirroring [`ActionOf`] into -/// [`NetworkActionOf`]. Therefore component replication only needs to create -/// the correctly typed action component on the receiver, and -/// [`deserialize_action`] can rebuild it from `Default`. +/// The [`Action`] component also does not carry the context relationship: +/// [`ActionOf`] is replicated as its own component, and Replicon's default +/// deserialize path calls [`Component::map_entities`] so Bevy's relationship +/// entity is mapped through the prespawn/replication entity map. Therefore +/// component replication only needs to create the correctly typed action +/// component on the receiver, and [`deserialize_action`] can rebuild it from +/// `Default`. /// -/// [`ActionOf`]: bevy_enhanced_input::prelude::ActionOf +/// [`ActionOf`]: bevy_enhanced_input::prelude::ActionOf fn serialize_action( _ctx: &mut SerializeCtx, _: &Action, @@ -479,31 +214,6 @@ fn deserialize_action( Ok(Action::::default()) } -/// Serialize the authoritative remote entity for an action context. -/// -/// Entity mapping is handled out-of-band before replication by mirroring [`ActionOf`] -/// into [`NetworkActionOf`]. -pub(crate) fn serialize_network_action_of( - _ctx: &mut SerializeCtx, - action_of: &NetworkActionOf, - message: &mut Vec, -) -> bevy_ecs::error::Result<()> { - bevy_replicon::postcard_utils::entity_to_extend_mut(&action_of.get(), message)?; - Ok(()) -} - -/// Deserialize the authoritative remote entity for an action context. -/// -/// We intentionally do not apply replicon's entity mapping here because the authoritative -/// entity may come from either the replicon server map or lightyear's message entity map. -pub(crate) fn deserialize_network_action_of( - _: &mut WriteCtx, - message: &mut Bytes, -) -> bevy_ecs::error::Result> { - let entity = bevy_replicon::postcard_utils::entity_from_buf(message)?; - Ok(NetworkActionOf::::new(entity)) -} - pub trait InputRegistryExt { /// Registers a new input action type and returns its kind. fn register_input_action(self) -> Self; diff --git a/crates/inputs/inputs/src/client.rs b/crates/inputs/inputs/src/client.rs index 2016ef9e7..e1697a7b8 100644 --- a/crates/inputs/inputs/src/client.rs +++ b/crates/inputs/inputs/src/client.rs @@ -696,7 +696,6 @@ fn prepare_input_message( input_buffer ); - // PreSpawned: the include the Prespawned Hash let target = if let Some(prespawned) = pre_spawned && let Some(hash) = prespawned.hash { diff --git a/crates/inputs/inputs/src/input_message.rs b/crates/inputs/inputs/src/input_message.rs index 05b5a36a1..7fc52f9c1 100644 --- a/crates/inputs/inputs/src/input_message.rs +++ b/crates/inputs/inputs/src/input_message.rs @@ -30,7 +30,7 @@ pub enum InputTarget { /// (Also when rebroadcast from server to client) Entity(Entity), /// The input is for a prespawned entity. - /// We wan the client to be able to send inputs for a prespawned entity before it gets matched with a server entity. + /// We want the client to be able to send inputs for a prespawned entity before it gets matched with a server entity. /// To achieve this, the client sends the PreSpawned hash and the server will map it to the correct server entity. /// When rebroadcasting from server to other client, we rebroadcast it as a normal Entity? PreSpawned(u64), diff --git a/crates/inputs/inputs/src/server.rs b/crates/inputs/inputs/src/server.rs index 4afcfecf0..2f8cf438b 100644 --- a/crates/inputs/inputs/src/server.rs +++ b/crates/inputs/inputs/src/server.rs @@ -470,11 +470,8 @@ fn receive_input_message( }, InputTarget::PreSpawned(hash) => { debug!(?hash, "Received input for prespawned entity"); - // we cannot match using the PreSpawnedReceiver since it only stores hashes for entities - // with no Replicate component. - // Instead, since there shouldn't be many entities with inputs and PreSpawned, we just iterate - // through the query. We don't need to do entity-mapping because as soon as the entity is mapped - // on the client, PreSpawned is removed and the input will contain the mapped entity. + // We cannot match using the PreSpawnedReceiver since it only stores hashes for entities + // with no Replicate component, so resolve the input target against server-side input entities. prespawned .iter() .filter_map(|(e, p)| p.hash.is_some_and(|h| h == hash).then_some(e)).next() diff --git a/crates/replication/prediction/src/plugin.rs b/crates/replication/prediction/src/plugin.rs index 57be98658..219ff4ed9 100644 --- a/crates/replication/prediction/src/plugin.rs +++ b/crates/replication/prediction/src/plugin.rs @@ -5,9 +5,9 @@ use crate::diagnostics::PredictionDiagnosticsPlugin; use crate::manager::PredictionManager; use crate::predicted_history::{ PredictionHistory, add_history_diff_receiver, add_prediction_history, - apply_component_removal_predicted, handle_tick_event_confirmed_history, - handle_tick_event_history_diff_receiver, handle_tick_event_prediction_history, - prune_history_diff_receiver, snap_to_confirmed_during_rollback, update_prediction_history, + apply_component_removal_predicted, handle_tick_event_history_diff_receiver, + handle_tick_event_prediction_history, prune_history_diff_receiver, + snap_to_confirmed_during_rollback, update_prediction_history, }; use crate::registry::PredictionRegistry; use crate::rollback::DisabledDuringRollback; @@ -83,8 +83,12 @@ pub fn add_non_networked_rollback_systems + C // would not get its tick values shifted on timeline-sync, so any // history entries accumulated pre-sync would point to stale // (pre-sync) ticks after the client clock jumps forward. + // + // Do not shift `ConfirmedHistory` here: confirmed samples are resolved + // through `ReplicationCheckpointMap` and are already in authoritative + // server tick space. Shifting them can move an init-message seed into the + // future and make rollback prefer stale state over later server updates. app.add_observer(handle_tick_event_prediction_history::); - app.add_observer(handle_tick_event_confirmed_history::); app.add_systems( PreUpdate, prepare_rollback::.in_set(RollbackSystems::Prepare), @@ -152,7 +156,6 @@ pub(crate) fn add_prediction_systems(app: &mut App) { app.add_observer(apply_component_removal_predicted::); app.add_observer(handle_tick_event_prediction_history::); - app.add_observer(handle_tick_event_confirmed_history::); app.add_observer(add_prediction_history::); app.add_systems( diff --git a/crates/replication/prediction/src/predicted_history.rs b/crates/replication/prediction/src/predicted_history.rs index 1fde546e6..bf991ae87 100644 --- a/crates/replication/prediction/src/predicted_history.rs +++ b/crates/replication/prediction/src/predicted_history.rs @@ -144,27 +144,6 @@ pub(crate) fn handle_tick_event_prediction_history( } } -/// If there is a TickEvent and the client tick suddenly changes, update confirmed-history ticks too. -pub(crate) fn handle_tick_event_confirmed_history( - trigger: On>, - mut query: Query<&mut ConfirmedHistory>, -) { - for mut history in query.iter_mut() { - history.update_ticks(trigger.tick_delta); - trace!( - target: "lightyear_debug::prediction", - kind = "confirmed_history_tick_delta", - schedule = "PostUpdate", - sample_point = "PostUpdate", - entity = ?trigger.entity, - component = ?DebugName::type_name::(), - tick_delta = trigger.tick_delta, - history_len = history.len(), - "shifted confirmed history ticks" - ); - } -} - pub(crate) fn handle_tick_event_history_diff_receiver( trigger: On>, mut storage: ResMut, @@ -212,7 +191,6 @@ pub(crate) fn prune_history_diff_receiver( } } } - /// If a predicted component is removed on the [`Predicted`] entity, add the removal to the history. pub(crate) fn apply_component_removal_predicted( trigger: On, diff --git a/crates/tests/src/client_server/input/bei.rs b/crates/tests/src/client_server/input/bei.rs index 6478f0ec8..f99cdc5b9 100644 --- a/crates/tests/src/client_server/input/bei.rs +++ b/crates/tests/src/client_server/input/bei.rs @@ -17,7 +17,9 @@ use lightyear_link::Link; use lightyear_link::prelude::LinkConditionerConfig; use lightyear_messages::MessageManager; use lightyear_prediction::diagnostics::PredictionMetrics; -use lightyear_replication::prelude::{PreSpawned, PredictionTarget, Replicate}; +use lightyear_replication::prelude::{ + ControlledBy, PreSpawned, PredictionTarget, Replicate, ReplicateLike, +}; use lightyear_sync::prelude::client::{InputDelayConfig, InputTimelineConfig}; use test_log::test; use tracing::info; @@ -77,26 +79,35 @@ fn spawn_action_pair( (client_action, server_action) } -/// Check that we can insert actions on the client entity using PreSpawned +/// Check that we can insert actions on a replicated client context using PreSpawned #[test] -fn test_actions_on_client_entity() { +fn test_actions_on_replicated_context_entity() { let mut stepper = ClientServerStepper::from_config(StepperConfig::single()); - let client_entity = stepper.client(0).id(); - let client_of_entity = stepper.client_of(0).id(); + let server_entity = stepper + .server_app + .world_mut() + .spawn((BEIContext, Replicate::to_clients(NetworkTarget::All))) + .id(); + stepper.frame_step(3); + + let client_entity = stepper + .client(0) + .get::() + .unwrap() + .entity_mapper + .get_local(server_entity) + .expect("context entity should be replicated to client"); let (client_action, server_action) = - spawn_action_pair(&mut stepper, client_entity, client_of_entity, TEST_HASH); - stepper.frame_step(1); + spawn_action_pair(&mut stepper, client_entity, server_entity, TEST_HASH); + stepper.frame_step(2); // Add an InputMarker to the Context entity on the client stepper .client_app() .world_mut() .entity_mut(client_entity) - .insert(( - BEIContext, - bei::prelude::InputMarker::::default(), - )); + .insert(bei::prelude::InputMarker::::default()); // Check that the ActionOf component points to the correct entity on the server assert_eq!( @@ -107,7 +118,19 @@ fn test_actions_on_client_entity() { .get::>() .unwrap() .get(), - client_of_entity + server_entity + ); + + // Check that the replicated ActionOf component maps back to the local context on the client + assert_eq!( + stepper + .client_app() + .world() + .entity(client_action) + .get::>() + .unwrap() + .get(), + client_entity ); info!("Mocking press on BEIAction1"); @@ -263,6 +286,84 @@ fn test_bound_action_spawned_from_received_context_sends_inputs_after_mapping() ); } +/// Check that the owner can drive a server-spawned replicated action entity. +/// +/// The client receives the action entity from replication, adds local-only +/// bindings/input mock to that replicated entity, and input messages target the +/// action entity through the normal client-to-server entity map. +#[test] +fn test_server_replicated_action_sends_inputs_after_client_adds_bindings() { + let mut stepper = ClientServerStepper::from_config(StepperConfig::single()); + stepper.server_app.init_resource::(); + stepper.server_app.add_systems( + FixedPreUpdate, + record_server_fired_action + .before(lightyear::input::server::InputSystems::UpdateActionState), + ); + + let client_of_entity = stepper.client_of(0).id(); + let client_id = stepper + .client_of(0) + .get::() + .unwrap() + .0; + let server_entity = stepper + .server_app + .world_mut() + .spawn(( + BEIContext, + Replicate::to_clients(NetworkTarget::All), + PredictionTarget::to_clients(NetworkTarget::Single(client_id)), + ControlledBy { + owner: client_of_entity, + lifetime: Default::default(), + }, + )) + .id(); + + let server_action = stepper + .server_app + .world_mut() + .spawn(( + ActionOf::::new(server_entity), + Action::::default(), + ReplicateLike { + root: server_entity, + }, + )) + .id(); + + stepper.frame_step(5); + + let client_action = stepper + .client(0) + .get::() + .unwrap() + .entity_mapper + .get_local(server_action) + .expect("action entity should be replicated to client"); + + stepper + .client_app() + .world_mut() + .entity_mut(client_action) + .insert(( + ActionMock::once(TriggerState::Fired, true), + bindings![KeyCode::Space,], + )); + + stepper.frame_step(5); + + assert!( + stepper + .server_app + .world() + .resource::() + .0, + "server should eventually receive the fired action state via mapped entity input messages" + ); +} + /// Check that ActionStates are stored correctly in the InputBuffer with PreSpawned #[test] fn test_buffer_inputs_with_delay() { diff --git a/crates/tests/src/client_server/prediction/rollback.rs b/crates/tests/src/client_server/prediction/rollback.rs index ef492931a..f52416821 100644 --- a/crates/tests/src/client_server/prediction/rollback.rs +++ b/crates/tests/src/client_server/prediction/rollback.rs @@ -552,6 +552,62 @@ fn test_future_confirmed_insert_is_not_checked_by_unchanged_completed_tick() { ); } +#[test] +fn test_input_timeline_sync_does_not_shift_confirmed_history() { + use lightyear_sync::prelude::InputTimelineConfig; + + let (mut stepper, predicted) = setup(); + + let confirmed_tick = Tick(10); + insert_confirmed( + stepper.client_app().world_mut(), + predicted, + confirmed_tick, + Some(CompFull(10.0)), + ); + + let predicted_tick = Tick(5); + let mut prediction_history = PredictionHistory::::default(); + prediction_history.add_predicted(predicted_tick, Some(CompFull(5.0))); + stepper + .client_app() + .world_mut() + .entity_mut(predicted) + .insert(prediction_history); + + stepper + .client_app() + .world_mut() + .trigger(lightyear_core::timeline::SyncEvent::::new(predicted, 100)); + + let world = stepper.client_app().world(); + let confirmed_history = world + .get::>(predicted) + .expect("confirmed history should still be present"); + assert!( + confirmed_history.get_state_at(confirmed_tick).is_some(), + "confirmed history is already in authoritative server tick space and must not shift" + ); + assert!( + confirmed_history + .get_state_at(confirmed_tick + 100) + .is_none(), + "timeline sync must not move authoritative confirmed samples into the future" + ); + + let prediction_history = world + .get::>(predicted) + .expect("prediction history should still be present"); + assert!( + prediction_history.get_state(predicted_tick).is_none(), + "local prediction samples from before sync should be shifted" + ); + assert!( + prediction_history.get_state(predicted_tick + 100).is_some(), + "prediction history should follow the input timeline sync" + ); +} + /// A completed mutate tick is a global confirmation point. If one entity receives an /// explicit update at that tick and another entity does not, the unchanged entity should /// still be checked against its last confirmed value at the completed tick. diff --git a/examples/bevy_enhanced_inputs/src/automation.rs b/examples/bevy_enhanced_inputs/src/automation.rs index 1348c944e..14c7358fb 100644 --- a/examples/bevy_enhanced_inputs/src/automation.rs +++ b/examples/bevy_enhanced_inputs/src/automation.rs @@ -1,5 +1,6 @@ use bevy::prelude::*; use lightyear::prelude::*; +#[cfg(feature = "client")] use lightyear_examples_common::automation::{env_string, sync_pressed_keys, HeadlessInputPlugin}; use crate::protocol::{PlayerId, PlayerPosition}; diff --git a/examples/bevy_enhanced_inputs/src/client.rs b/examples/bevy_enhanced_inputs/src/client.rs index 1f67087e8..f8bea4488 100644 --- a/examples/bevy_enhanced_inputs/src/client.rs +++ b/examples/bevy_enhanced_inputs/src/client.rs @@ -8,12 +8,10 @@ use crate::automation::AutomationClientPlugin; use crate::protocol::*; use crate::shared; -use bevy::ecs::relationship::Relationship; use bevy::prelude::*; use lightyear::connection::host::HostServer; -use lightyear::input::bei::prelude::{Action, ActionOf, Fire}; +use lightyear::input::bei::prelude::{Action, Actions, Bindings, Cardinal, Fire}; use lightyear::prelude::client::{InputDelayConfig, InputTimelineConfig}; -use lightyear::prelude::input::bei::InputMarker; use lightyear::prelude::*; pub struct ExampleClientPlugin; @@ -23,7 +21,7 @@ impl Plugin for ExampleClientPlugin { app.add_plugins(AutomationClientPlugin); app.add_systems(Startup, configure_input_delay); app.add_observer(handle_predicted_spawn); - app.add_observer(handle_controlled_spawn); + app.add_observer(add_bindings_to_controlled_actions); app.add_observer(handle_interpolated_spawn); app.add_observer(player_movement); } @@ -41,14 +39,18 @@ fn configure_input_delay(client: Single>, mut commands: Com fn player_movement( trigger: On>, synced_client: Query<(), (With, With>)>, - host_server: Query<(), With>, - server_actions: Query<(), (With>, With)>, + _host_server: Query<(), With>, + #[cfg(feature = "server")] server_actions: Query< + (), + (With>, With), + >, mut position_query: Query<&mut PlayerPosition, With>, ) { if synced_client.is_empty() { return; } - if !host_server.is_empty() && server_actions.contains(trigger.action) { + #[cfg(feature = "server")] + if !_host_server.is_empty() && server_actions.contains(trigger.action) { return; } if let Ok(position) = position_query.get_mut(trigger.context) { @@ -74,48 +76,40 @@ pub(crate) fn handle_predicted_spawn( } } -/// Spawn local action entities once the local player is actually controlled by this client. -/// -/// We intentionally key this off `Add`, not `Add`. -/// In dedicated client/server mode the replicated entity often ends up with both markers, -/// but in host-server mode the local entity is created in the same world through local -/// insertion paths, not through Replicon's deferred receive bundle. In that mode there is no -/// reliable guarantee that `Controlled` will already exist when `Predicted` is added, so -/// checking `Has` inside `Add` is brittle. +/// Add local movement bindings when a player becomes controlled by this client. /// -/// `Controlled` is the actual semantic signal we care about for local input setup: once this -/// marker appears, the entity belongs to this local client and it is safe to attach the -/// `InputMarker` and spawn the local BEI action entities. -fn handle_controlled_spawn( +/// The movement action is spawned on the server and replicated with the player. +/// Once the local predicted player is marked `Controlled`, its BEI `Actions` +/// relationship contains the replicated movement action that should receive +/// local-only bindings. Binding from `ActionOf`'s add event is too +/// early for the local prediction/control path: the relationship can already be +/// mapped while the predicted context has not yet been marked as controlled. +fn add_bindings_to_controlled_actions( trigger: On, - controlled_players: Query< - (&PlayerId, Has>, Option<&ControlledBy>), - With, - >, + controlled_players: Query<(Option<&ControlledBy>, Option<&Actions>), With>, + unbound_movement_actions: Query<(), (With>, Without)>, clients: Query<(), With>, - actions: Query<&ActionOf, With>>, mut commands: Commands, ) { - let entity = trigger.entity; - let Ok((player_id, has_input_marker, controlled_by)) = controlled_players.get(entity) else { + let Ok((controlled_by, Some(actions))) = controlled_players.get(trigger.entity) else { return; }; - if let Some(controlled_by) = controlled_by { - if clients.get(controlled_by.owner).is_err() { - return; - } - } - if has_input_marker { + if controlled_by.is_some_and(|controlled_by| clients.get(controlled_by.owner).is_err()) { return; } - commands - .entity(entity) - .insert(InputMarker::::default()); - if !actions.iter().any(|action_of| action_of.get() == entity) { - shared::spawn_action_entities(&mut commands, entity, player_id.0, false); + for action_entity in actions.iter() { + if unbound_movement_actions.contains(action_entity) { + add_bindings(action_entity, &mut commands); + } } } +fn add_bindings(action_entity: Entity, commands: &mut Commands) { + commands + .entity(action_entity) + .insert(Bindings::spawn(Cardinal::wasd_keys())); +} + /// When the predicted copy of the client-owned entity is spawned, do stuff /// - assign it a different saturation /// - keep track of it in the Global resource diff --git a/examples/bevy_enhanced_inputs/src/renderer.rs b/examples/bevy_enhanced_inputs/src/renderer.rs index ead4e003a..5b4f991b0 100644 --- a/examples/bevy_enhanced_inputs/src/renderer.rs +++ b/examples/bevy_enhanced_inputs/src/renderer.rs @@ -7,8 +7,6 @@ pub struct ExampleRendererPlugin; impl Plugin for ExampleRendererPlugin { fn build(&self, app: &mut App) { app.add_systems(Startup, init); - #[cfg(feature = "client")] - app.add_systems(Startup, rollback_button); app.add_systems(Update, draw_boxes); } } @@ -28,41 +26,3 @@ pub(crate) fn draw_boxes(mut gizmos: Gizmos, players: Query<(&PlayerPosition, &P ); } } - -#[cfg(feature = "client")] -pub(crate) fn rollback_button(mut commands: Commands) { - use lightyear::prelude::{LocalTimeline, PredictionManager, Rollback}; - commands - .spawn(( - Text("Rollback".to_string()), - TextColor(Color::srgb(0.9, 0.9, 0.9)), - TextFont::from_font_size(20.0), - Node { - width: Val::Px(150.0), - height: Val::Px(65.0), - border: UiRect::all(Val::Px(5.0)), - left: Val::Percent(45.0), - // horizontally center child text - justify_content: JustifyContent::Center, - // vertically center child text - align_items: AlignItems::Center, - ..default() - }, - Button, - )) - .observe( - |_: On>, - mut commands: Commands, - timeline: Res, - client: Single<(Entity, &PredictionManager)>| { - let (client, prediction_manager) = client.into_inner(); - - // rollback the client to 5 ticks before the current tick - let tick = timeline.tick(); - let rollback_tick = tick - 5; - info!("Manual rollback to tick {rollback_tick:?}. Current tick: {tick:?}"); - commands.entity(client).insert(Rollback::FromInputs); - prediction_manager.set_rollback_tick(rollback_tick); - }, - ); -} diff --git a/examples/bevy_enhanced_inputs/src/server.rs b/examples/bevy_enhanced_inputs/src/server.rs index 5bf3b8a60..22375f603 100644 --- a/examples/bevy_enhanced_inputs/src/server.rs +++ b/examples/bevy_enhanced_inputs/src/server.rs @@ -10,7 +10,7 @@ use crate::automation::AutomationServerPlugin; use crate::protocol::*; use crate::shared; use bevy::prelude::*; -use bevy_enhanced_input::prelude::{Action, Fire}; +use bevy_enhanced_input::prelude::{Action, ActionOf, Fire}; use lightyear::connection::client::Connected; use lightyear::connection::host::{HostClient, HostServer}; use lightyear::prelude::server::*; @@ -19,6 +19,9 @@ use lightyear_examples_common::shared::SEND_INTERVAL; pub struct ExampleServerPlugin; +#[derive(Component)] +pub(crate) struct ServerAction; + impl Plugin for ExampleServerPlugin { fn build(&self, app: &mut App) { app.add_plugins(AutomationServerPlugin); @@ -65,7 +68,7 @@ pub(crate) fn handle_connected( // Add the context component on the server; it will be replicated to the client Player, PlayerId(client_id), - PlayerPosition(Vec2::ZERO), + PlayerPosition(initial_player_position(client_id)), PlayerColor(color), // we replicate the Player entity to all clients that are connected to this server Replicate::to_clients(NetworkTarget::All), @@ -81,14 +84,35 @@ pub(crate) fn handle_connected( "Create player entity {:?} for client {:?}", player_entity, client_id ); - shared::spawn_action_entities(&mut commands, player_entity, client_id, true); + spawn_action_entities(&mut commands, player_entity); +} + +fn initial_player_position(client_id: PeerId) -> Vec2 { + let slot = (client_id.to_bits() % 6) as f32; + Vec2::new((slot - 2.5) * 90.0, 0.0) +} + +/// Spawn the BEI action entities for a player. +/// +/// The server owns and replicates action entities so the owning client can +/// target them in input messages and remote clients can receive rebroadcasted +/// input for prediction. +fn spawn_action_entities(commands: &mut Commands, player_entity: Entity) { + commands.spawn(( + ActionOf::::new(player_entity), + Action::::new(), + ReplicateLike { + root: player_entity, + }, + ServerAction, + )); } /// Read client inputs and move players in server therefore giving a basis for other clients fn movement( trigger: On>, host_server: Query<(), With>, - server_actions: Query<(), (With>, With)>, + server_actions: Query<(), (With>, With)>, controlled_by: Query<&ControlledBy>, host_clients: Query<(), With>, mut position_query: Query<&mut PlayerPosition>, diff --git a/examples/bevy_enhanced_inputs/src/shared.rs b/examples/bevy_enhanced_inputs/src/shared.rs index ce3fc03ed..303fc1754 100644 --- a/examples/bevy_enhanced_inputs/src/shared.rs +++ b/examples/bevy_enhanced_inputs/src/shared.rs @@ -4,7 +4,6 @@ //! mispredictions/rollbacks. use crate::protocol::*; use bevy::prelude::*; -use lightyear::input::bei::prelude::{Action, ActionOf, Bindings, Cardinal}; use lightyear::prelude::*; use lightyear_examples_common::shared::SharedSettings; @@ -19,52 +18,6 @@ impl Plugin for SharedPlugin { } } -/// Deterministic hash for PreSpawned action entities. -/// Uses the client's PeerId and a salt to produce the same hash on both client and server, -/// regardless of spawn tick. -pub(crate) fn action_prespawn_hash(client_id: PeerId, salt: u64) -> u64 { - client_id - .to_bits() - .wrapping_mul(6364136223846793005) - .wrapping_add(salt) -} - -#[derive(Component)] -pub(crate) struct ServerAction; - -/// Spawn action entities for a player. Called on both client and server. -pub(crate) fn spawn_action_entities( - commands: &mut Commands, - player_entity: Entity, - client_id: PeerId, - is_server: bool, -) { - let hash = action_prespawn_hash(client_id, 1); - let prespawned = if is_server { - PreSpawned::new(hash) - } else { - // The local action entity uses the hash as a stable input target, but it - // is not a predicted gameplay object that should be cleaned up if the - // server action replication arrived before the local action was spawned. - PreSpawned::new(hash).for_receiver(player_entity) - }; - let mut action = commands.spawn(( - ActionOf::::new(player_entity), - Action::::new(), - Bindings::spawn(Cardinal::wasd_keys()), - prespawned, - )); - if is_server { - #[cfg(feature = "server")] - action.insert(( - Replicate::to_clients(NetworkTarget::Single(client_id)), - ServerAction, - )); - } else { - action.insert(lightyear::prelude::input::bei::InputMarker::::default()); - } -} - pub const SHARED_SETTINGS: SharedSettings = SharedSettings { protocol_id: 0, private_key: [0; 32], diff --git a/examples/projectiles/src/client.rs b/examples/projectiles/src/client.rs index 49b261c81..4c2f6c4fa 100644 --- a/examples/projectiles/src/client.rs +++ b/examples/projectiles/src/client.rs @@ -8,7 +8,6 @@ use bevy::ecs::relationship::Relationship; use bevy::prelude::*; use bevy_enhanced_input::EnhancedInputSystems; use bevy_enhanced_input::action::TriggerState; -use bevy_enhanced_input::bindings; use bevy_enhanced_input::context::ExternallyMocked; use bevy_enhanced_input::prelude::{ ActionMock, ActionValue, ActionValueDim, Binding, Bindings, Cardinal, MockSpan, @@ -27,7 +26,6 @@ impl Plugin for ExampleClientPlugin { app.add_observer(handle_predicted_spawn); app.add_observer(handle_interpolated_spawn); app.add_observer(handle_deterministic_spawn); - app.add_observer(add_global_actions); app.add_systems( FixedPreUpdate, ( @@ -45,13 +43,10 @@ impl Plugin for ExampleClientPlugin { // - add physics components so that its movement can be predicted pub(crate) fn handle_predicted_spawn( trigger: On, - client: Single<&LocalId, With>, - host_client: Option>, mut commands: Commands, - mut player_query: Query<(&PlayerId, &Position, &GameReplicationMode), With>, + mut player_query: Query<&GameReplicationMode, With>, ) { - let client_id = client.into_inner().0; - if let Ok((player_id, pos, mode)) = player_query.get_mut(trigger.entity) { + if let Ok(mode) = player_query.get_mut(trigger.entity) { if mode == &GameReplicationMode::AllInterpolated { return; }; @@ -66,57 +61,29 @@ pub(crate) fn handle_predicted_spawn( } _ => {} }; - if player_id.0 != client_id { - return; - } - // add actions on the local entity (remote predicted entities will have actions propagated by the server) - if should_spawn_client_actions(&host_client) { - info!( - ?pos, - "Adding actions to predicted player {:?}", trigger.entity - ); - shared::spawn_player_actions(&mut commands, trigger.entity, player_id.0, *mode, false); - } } } pub(crate) fn handle_interpolated_spawn( trigger: On, - client: Single<&LocalId, With>, - host_client: Option>, - mut interpolated: Query< - (&PlayerId, &Interpolated, &GameReplicationMode), - (With, With), - >, + mut interpolated: Query<&GameReplicationMode, (With, With)>, mut commands: Commands, ) { - let client_id = client.into_inner(); - if let Ok((player_id, interpolated, mode)) = interpolated.get_mut(trigger.entity) { + if let Ok(mode) = interpolated.get_mut(trigger.entity) { if mode == &GameReplicationMode::ClientSideHitDetection { // add these so we can do hit-detection on the client commands .entity(trigger.entity) .insert((Collider::rectangle(PLAYER_SIZE, PLAYER_SIZE),)); } - // In the interpolated case, the client sends inputs but doesn't apply them. - // Only the server applies the inputs, and the position changes are replicated back - if let GameReplicationMode::AllInterpolated = mode - && client_id.0 == player_id.0 - && should_spawn_client_actions(&host_client) - { - shared::spawn_player_actions(&mut commands, trigger.entity, player_id.0, *mode, false); - } } } pub(crate) fn handle_deterministic_spawn( trigger: On, query: Query<(&PlayerId, &GameReplicationMode)>, - client: Single<&LocalId, With>, - host_client: Option>, mut commands: Commands, ) { - let client_id = client.into_inner(); if let Ok((player_id, mode)) = query.get(trigger.entity) && mode == &GameReplicationMode::OnlyInputsReplicated { @@ -129,32 +96,9 @@ pub(crate) fn handle_deterministic_spawn( }, )); info!("Adding PlayerContext for player {:?}", player_id); - - // add actions for the local client - if player_id.0 == client_id.0 && should_spawn_client_actions(&host_client) { - info!( - "Spawning actions for DeterministicPredicted player {:?}", - player_id - ); - shared::spawn_player_actions(&mut commands, trigger.entity, player_id.0, *mode, false); - } } } -pub(crate) fn add_global_actions( - trigger: On, - host_client: Option>, - mut commands: Commands, -) { - if should_spawn_client_actions(&host_client) { - shared::spawn_global_actions(&mut commands, trigger.entity, false); - } -} - -fn should_spawn_client_actions(host_client: &Option>) -> bool { - host_client.is_none() -} - fn update_active_player_action_markers( client: Query<&LocalId, With>, global_mode: Query<&GameReplicationMode, With>, diff --git a/examples/projectiles/src/server.rs b/examples/projectiles/src/server.rs index b79df277d..56727819d 100644 --- a/examples/projectiles/src/server.rs +++ b/examples/projectiles/src/server.rs @@ -22,6 +22,7 @@ use lightyear::crossbeam::CrossbeamIo; use lightyear::input::config::InputConfig; use lightyear::input::server::{InputSystems as ServerInputSystems, ServerInputConfig}; use lightyear::interpolation::plugin::InterpolationDelay; +#[cfg(feature = "client")] use lightyear::netcode::NetcodeClient; use lightyear::prelude::server::*; use lightyear::prelude::*; @@ -70,13 +71,9 @@ pub(crate) fn handle_new_client(trigger: On, mut commands: Commands "Adding ReplicationSender to new ClientOf entity: {:?}", trigger.entity ); - commands.entity(trigger.entity).insert(( - ReplicationSender, - // We need a ReplicationReceiver on the server side because the Action entities are spawned - // on the client and replicated to the server. - ReplicationReceiver, - Name::from("ClientOf"), - )); + commands + .entity(trigger.entity) + .insert((ReplicationSender, Name::from("ClientOf"))); } pub(crate) fn spawn_global_control(mut commands: Commands) { @@ -99,7 +96,7 @@ pub(crate) fn spawn_global_control(mut commands: Commands) { Name::new("ClientContext"), )) .id(); - shared::spawn_global_actions(&mut commands, global, true); + shared::spawn_global_actions(&mut commands, global); } fn apply_initial_input_config( @@ -330,13 +327,7 @@ pub(crate) fn spawn_player( if is_bot { commands.entity(player_entity).insert(Bot); } - shared::spawn_player_actions( - &mut commands, - player_entity, - client_id, - replication_mode, - true, - ); + shared::spawn_player_actions(&mut commands, player_entity); info!("Spawning player {player_entity:?} for room: {room_id:?}"); } } diff --git a/examples/projectiles/src/shared.rs b/examples/projectiles/src/shared.rs index 6fc8ddb13..6fbab77d4 100644 --- a/examples/projectiles/src/shared.rs +++ b/examples/projectiles/src/shared.rs @@ -5,8 +5,6 @@ use bevy::platform::collections::HashMap; use bevy::prelude::*; use bevy::time::Stopwatch; use bevy_enhanced_input::action::Action; -use bevy_enhanced_input::action::TriggerState; -use bevy_enhanced_input::action::mock::ActionMock; use bevy_enhanced_input::prelude::*; use core::ops::DerefMut; use core::time::Duration; @@ -103,136 +101,48 @@ pub(crate) fn color_from_id(client_id: PeerId) -> Color { Color::hsl(h, s, l) } -/// Deterministic hash for BEI action entities spawned on both client and server. -pub(crate) fn player_action_prespawn_hash( - client_id: PeerId, - replication_mode: GameReplicationMode, - salt: u64, -) -> u64 { - client_id - .to_bits() - .wrapping_mul(6364136223846793005) - .wrapping_add((replication_mode.room_id() as u64).wrapping_mul(31)) - .wrapping_add(salt) -} - -pub(crate) fn global_action_prespawn_hash(salt: u64) -> u64 { - 0xC11E_1175_0000_0000u64.wrapping_add(salt) -} - -pub(crate) fn spawn_global_actions(commands: &mut Commands, context: Entity, is_server: bool) { - let mut projectile_mode = commands.spawn(( +pub(crate) fn spawn_global_actions(commands: &mut Commands, context: Entity) { + commands.spawn(( ActionOf::::new(context), Action::::new(), - bindings![KeyCode::KeyE], - PreSpawned::new(global_action_prespawn_hash(1)), + ReplicateLike { root: context }, )); - if is_server { - projectile_mode.insert(( - Replicate::to_clients(NetworkTarget::All), - PredictionTarget::manual(Vec::new()), - InterpolationTarget::manual(Vec::new()), - )); - } else { - projectile_mode - .insert(lightyear::prelude::input::bei::InputMarker::::default()); - } - let mut replication_mode = commands.spawn(( + commands.spawn(( ActionOf::::new(context), Action::::new(), - bindings![KeyCode::KeyR], - PreSpawned::new(global_action_prespawn_hash(2)), + ReplicateLike { root: context }, )); - if is_server { - replication_mode.insert(( - Replicate::to_clients(NetworkTarget::All), - PredictionTarget::manual(Vec::new()), - InterpolationTarget::manual(Vec::new()), - )); - } else { - replication_mode - .insert(lightyear::prelude::input::bei::InputMarker::::default()); - } - let mut weapon = commands.spawn(( + commands.spawn(( ActionOf::::new(context), Action::::new(), - bindings![KeyCode::KeyQ], - PreSpawned::new(global_action_prespawn_hash(3)), + ReplicateLike { root: context }, )); - if is_server { - weapon.insert(( - Replicate::to_clients(NetworkTarget::All), - PredictionTarget::manual(Vec::new()), - InterpolationTarget::manual(Vec::new()), - )); - } else { - weapon.insert(lightyear::prelude::input::bei::InputMarker::::default()); - } } -/// Spawn the BEI player action entities on both sides so input messages can use PreSpawned targets. -pub(crate) fn spawn_player_actions( - commands: &mut Commands, - player: Entity, - client_id: PeerId, - replication_mode: GameReplicationMode, - is_server: bool, -) { - let mut movement = commands.spawn(( +/// Spawn the server-owned BEI player action entities. +/// +/// Clients receive these through replication and add local-only bindings/input +/// markers to the active local player's actions. +pub(crate) fn spawn_player_actions(commands: &mut Commands, player: Entity) { + commands.spawn(( ActionOf::::new(player), Action::::new(), - Bindings::spawn(Cardinal::wasd_keys()), - PreSpawned::new(player_action_prespawn_hash(client_id, replication_mode, 1)), + ReplicateLike { root: player }, )); - if is_server { - movement.insert(( - Replicate::to_clients(NetworkTarget::All), - PredictionTarget::manual(Vec::new()), - InterpolationTarget::manual(Vec::new()), - )); - } else { - movement.insert(lightyear::prelude::input::bei::InputMarker::::default()); - } - let mut cursor = commands.spawn(( + commands.spawn(( ActionOf::::new(player), Action::::new(), - PreSpawned::new(player_action_prespawn_hash(client_id, replication_mode, 2)), + ReplicateLike { root: player }, )); - if is_server { - cursor.insert(( - Replicate::to_clients(NetworkTarget::All), - PredictionTarget::manual(Vec::new()), - InterpolationTarget::manual(Vec::new()), - )); - } else { - cursor.insert(( - ActionMock::new( - TriggerState::Fired, - ActionValue::zero(ActionValueDim::Axis2D), - MockSpan::Manual, - ), - lightyear::prelude::input::bei::InputMarker::::default(), - )); - } - let mut shoot = commands.spawn(( + commands.spawn(( ActionOf::::new(player), Action::::new(), - Bindings::spawn_one((Binding::from(KeyCode::Space), Name::from("Binding"))), - PreSpawned::new(player_action_prespawn_hash(client_id, replication_mode, 3)), + ReplicateLike { root: player }, )); - if is_server { - shoot.insert(( - Replicate::to_clients(NetworkTarget::All), - PredictionTarget::manual(Vec::new()), - InterpolationTarget::manual(Vec::new()), - )); - } else { - shoot.insert(lightyear::prelude::input::bei::InputMarker::::default()); - } } fn projectile_prespawn_salt(player_id: PeerId, salt: u64) -> u64 { From 657e809364c0ffa8fb8eef24b0088b5003d4445d Mon Sep 17 00:00:00 2001 From: Charles Bournhonesque Date: Thu, 25 Jun 2026 23:22:07 -0400 Subject: [PATCH 3/4] fix projectiles --- crates/inputs/input_bei/src/plugin.rs | 26 +++- crates/inputs/input_bei/src/setup.rs | 120 ++++++++++++++++-- .../replication/replication/src/hierarchy.rs | 104 ++++++++++++++- crates/replication/replication/src/lib.rs | 13 +- examples/projectiles/src/renderer.rs | 60 ++++++++- examples/projectiles/src/server.rs | 2 +- examples/projectiles/src/shared.rs | 10 +- 7 files changed, 311 insertions(+), 24 deletions(-) diff --git a/crates/inputs/input_bei/src/plugin.rs b/crates/inputs/input_bei/src/plugin.rs index 410b46806..99e018ab5 100644 --- a/crates/inputs/input_bei/src/plugin.rs +++ b/crates/inputs/input_bei/src/plugin.rs @@ -1,8 +1,13 @@ #[cfg(any(feature = "client", feature = "server"))] use crate::input_message::{BEIBuffer, BEIStateSequence}; +#[cfg(feature = "client")] +use crate::setup::resolve_pending_action_of; #[cfg(any(feature = "client", feature = "server"))] -use crate::setup::InputRegistryPlugin; +use crate::setup::{ + InputRegistryPlugin, deserialize_action_of, remove_action_of, serialize_action_of, + write_action_of, +}; use bevy_app::prelude::*; use bevy_ecs::prelude::*; #[cfg(any(feature = "client", feature = "server"))] @@ -16,7 +21,7 @@ use bevy_enhanced_input::action::TriggerState; use bevy_enhanced_input::context::InputContextAppExt; use bevy_enhanced_input::prelude::ActionOf; use bevy_reflect::TypePath; -use bevy_replicon::prelude::AppRuleExt; +use bevy_replicon::prelude::{AppMarkerExt, AppRuleExt, ReplicationMode, RuleFns}; use bevy_replicon::shared::replication::registry::receive_fns::MutWrite; use core::fmt::Debug; #[cfg(feature = "client")] @@ -57,10 +62,12 @@ use serde::de::DeserializeOwned; /// /// The replicated [`Action`] component is structural: it recreates the typed BEI /// action entity on the receiver, but does not carry runtime input state. The -/// action relationship is replicated directly through [`ActionOf`]. Bevy's -/// relationship component maps the inner context entity through -/// [`Component::map_entities`], and Replicon's default deserialize path calls -/// that mapping after replication has populated the entity map. +/// action relationship is replicated directly through [`ActionOf`]. +/// Lightyear uses a custom receive path for [`ActionOf`] so the context +/// entity is only inserted into Bevy's relationship component once Replicon's +/// server-to-client entity map already contains it. If the action arrives first, +/// the relationship is held as a local pending marker and resolved after the +/// context mapping is available. /// Live action state is sent by [`BEIStateSequence`] input messages. The owning /// client adds [`InputMarker`] to local action entities, buffers BEI trigger /// state/value/time each tick, and sends those snapshots to the server. If input @@ -111,7 +118,11 @@ impl< app.add_input_context_to::(); // we register the context C entity so that it can be replicated from the server to the client app.replicate::(); - app.replicate::>(); + app.replicate_with(( + RuleFns::new(serialize_action_of::, deserialize_action_of::), + ReplicationMode::Once, + )) + .set_receive_fns::>(write_action_of::, remove_action_of::); #[cfg(feature = "client")] { use crate::marker::{ @@ -126,6 +137,7 @@ impl< app.add_observer(add_input_marker_from_parent::); app.add_observer(add_input_marker_from_binding::); app.add_observer(add_input_marker_when_action_becomes_ready::); + app.add_systems(PreUpdate, resolve_pending_action_of::); if self.config.rebroadcast_inputs { app.add_observer(InputRegistryPlugin::on_rebroadcast_action_received::); diff --git a/crates/inputs/input_bei/src/setup.rs b/crates/inputs/input_bei/src/setup.rs index f1e92d92d..1eb503ea8 100644 --- a/crates/inputs/input_bei/src/setup.rs +++ b/crates/inputs/input_bei/src/setup.rs @@ -2,11 +2,13 @@ use alloc::vec::Vec; use bevy_app::App; #[cfg(any(feature = "client", feature = "server"))] use bevy_ecs::prelude::*; -#[cfg(any(feature = "client", feature = "server"))] use bevy_ecs::relationship::Relationship; -use bevy_replicon::bytes::Bytes; use bevy_replicon::prelude::*; -use bevy_replicon::shared::replication::registry::ctx::{SerializeCtx, WriteCtx}; +use bevy_replicon::shared::replication::deferred_entity::DeferredEntity; +use bevy_replicon::shared::replication::registry::ctx::{RemoveCtx, SerializeCtx, WriteCtx}; +#[cfg(feature = "client")] +use bevy_replicon::shared::server_entity_map::ServerEntityMap; +use bevy_replicon::{bytes::Bytes, postcard_utils}; #[cfg(feature = "client")] use { bevy_enhanced_input::context::ExternallyMocked, @@ -37,6 +39,27 @@ use { lightyear_replication::prelude::{InterpolationTarget, PredictionTarget, ReplicateLike}, }; +/// Client-side placeholder for a replicated [`ActionOf`] whose context +/// entity has not been mapped yet. +/// +/// This is deliberately local-only. Once the context entity appears in +/// Replicon's server-to-client entity map, [`resolve_pending_action_of`] +/// replaces this component with the real BEI relationship. +#[derive(Component)] +pub(crate) struct PendingActionOf { + server_context: Entity, + marker: core::marker::PhantomData, +} + +impl PendingActionOf { + fn new(server_context: Entity) -> Self { + Self { + server_context, + marker: core::marker::PhantomData, + } + } +} + pub struct InputRegistryPlugin; impl InputRegistryPlugin { @@ -186,6 +209,86 @@ impl InputRegistryPlugin { } } +/// Serializes the server context entity targeted by [`ActionOf`]. +/// +/// This uses a custom rule instead of Replicon's default component +/// serialization because [`ActionOf`] is a relationship component. The +/// default receive path would call [`EntityMapper::get_mapped`] for the context +/// entity and create a placeholder if the context has not been mapped yet. That +/// placeholder is unsafe for a relationship component: Bevy relationship hooks +/// can observe the target during insertion, and Replicon also asserts if that +/// buffered placeholder is still pending when the next component in the same +/// entity bundle is decoded. +pub(crate) fn serialize_action_of( + _ctx: &SerializeCtx, + action_of: &ActionOf, + message: &mut Vec, +) -> bevy_ecs::error::Result<()> { + postcard_utils::entity_to_extend_mut(&action_of.get(), message)?; + Ok(()) +} + +/// Deserializes the raw server context entity for stale-message consumption. +/// +/// The active receive path uses [`write_action_of`] below. This function exists +/// for Replicon's `RuleFns` contract and for consuming stale updates without +/// creating mapped placeholder entities. +pub(crate) fn deserialize_action_of( + _ctx: &mut WriteCtx, + message: &mut Bytes, +) -> bevy_ecs::error::Result> { + let server_context = postcard_utils::entity_from_buf(message)?; + Ok(ActionOf::new(server_context)) +} + +/// Receives [`ActionOf`] without using Replicon's placeholder entity mapper. +/// +/// If the context entity is already mapped, this inserts the real BEI +/// relationship immediately. If not, it stores [`PendingActionOf`] so the +/// relationship can be attached later by [`resolve_pending_action_of`]. +pub(crate) fn write_action_of( + ctx: &mut WriteCtx, + _rule_fns: &RuleFns>, + entity: &mut DeferredEntity, + message: &mut Bytes, +) -> bevy_ecs::error::Result<()> { + let server_context = postcard_utils::entity_from_buf(message)?; + if let Some(&client_context) = ctx.entity_map.to_client().get(&server_context) { + entity.insert(ActionOf::::new(client_context)); + entity.remove::>(); + } else { + entity.insert(PendingActionOf::::new(server_context)); + entity.remove::>(); + } + Ok(()) +} + +pub(crate) fn remove_action_of(_ctx: &mut RemoveCtx, entity: &mut DeferredEntity) { + entity.remove::>(); + entity.remove::>(); +} + +/// Attach delayed BEI relationships once Replicon has mapped their context. +#[cfg(feature = "client")] +pub(crate) fn resolve_pending_action_of( + entity_map: Option>, + pending: Query<(Entity, &PendingActionOf)>, + mut commands: Commands, +) { + let Some(entity_map) = entity_map else { + return; + }; + for (entity, pending) in &pending { + let Some(&client_context) = entity_map.to_client().get(&pending.server_context) else { + continue; + }; + commands + .entity(entity) + .insert(ActionOf::::new(client_context)) + .remove::>(); + } +} + /// Serializes only the presence and type of [`Action`]. /// /// The value stored inside BEI's [`Action`] is local runtime state. Lightyear @@ -193,11 +296,12 @@ impl InputRegistryPlugin { /// whose snapshots include the trigger state, action value, events, and timing. /// The [`Action`] component also does not carry the context relationship: /// [`ActionOf`] is replicated as its own component, and Replicon's default -/// deserialize path calls [`Component::map_entities`] so Bevy's relationship -/// entity is mapped through the prespawn/replication entity map. Therefore -/// component replication only needs to create the correctly typed action -/// component on the receiver, and [`deserialize_action`] can rebuild it from -/// `Default`. +/// mapping path is avoided there because it can create placeholder relationship +/// targets when the context has not been mapped yet. Instead, Lightyear defers +/// inserting [`ActionOf`] until the context entity is present in the +/// server-to-client map. Therefore component replication only needs to create +/// the correctly typed action component on the receiver, and +/// [`deserialize_action`] can rebuild it from `Default`. /// /// [`ActionOf`]: bevy_enhanced_input::prelude::ActionOf fn serialize_action( diff --git a/crates/replication/replication/src/hierarchy.rs b/crates/replication/replication/src/hierarchy.rs index af350902d..c6a0a0a4c 100644 --- a/crates/replication/replication/src/hierarchy.rs +++ b/crates/replication/replication/src/hierarchy.rs @@ -14,7 +14,13 @@ use bevy_ecs::query::QueryData; use bevy_ecs::reflect::ReflectMapEntities; use bevy_ecs::relationship::Relationship; use bevy_reflect::Reflect; -use bevy_replicon::prelude::SyncRelatedAppExt; +use bevy_replicon::bytes::Bytes; +use bevy_replicon::postcard_utils; +use bevy_replicon::prelude::{RuleFns, SyncRelatedAppExt}; +use bevy_replicon::shared::replication::deferred_entity::DeferredEntity; +use bevy_replicon::shared::replication::registry::ctx::{RemoveCtx, SerializeCtx, WriteCtx}; +#[cfg(feature = "client")] +use bevy_replicon::shared::server_entity_map::ServerEntityMap; use core::fmt::Debug; use serde::Serialize; use serde::de::DeserializeOwned; @@ -33,6 +39,26 @@ pub enum RelationshipSystems { pub(crate) struct HierarchyPlugin; +/// Client-side placeholder for a replicated [`ChildOf`] whose parent entity +/// has not been mapped yet. +/// +/// Replicon's default entity mapping creates a buffered placeholder when a +/// replicated component references an entity that has not appeared in the +/// server-to-client map yet. That is unsafe for relationship components like +/// [`ChildOf`], because inserting the relationship immediately runs Bevy's +/// relationship hooks and leaves Replicon's placeholder buffer alive while the +/// next component in the same entity bundle is decoded. +#[derive(Component)] +pub(crate) struct PendingChildOf { + server_parent: Entity, +} + +impl PendingChildOf { + fn new(server_parent: Entity) -> Self { + Self { server_parent } + } +} + #[derive(QueryData)] struct PropagationQuery { replicate: &'static Replicate, @@ -91,6 +117,82 @@ impl Plugin for HierarchyPlugin { } } +/// Serializes the server parent entity targeted by [`ChildOf`]. +/// +/// Lightyear registers a custom rule for [`ChildOf`] so the receive path can +/// inspect the raw server entity before mapping it. If the parent is not mapped +/// yet, the receiver defers inserting the relationship instead of letting +/// Replicon create a placeholder entity inside the relationship component. +pub(crate) fn serialize_child_of( + _ctx: &SerializeCtx, + child_of: &ChildOf, + message: &mut Vec, +) -> bevy_ecs::error::Result<()> { + postcard_utils::entity_to_extend_mut(&child_of.parent(), message)?; + Ok(()) +} + +/// Deserializes the raw server parent entity for stale-message consumption. +/// +/// The active receive path uses [`write_child_of`] so it can defer insertion +/// until the parent is mapped. +pub(crate) fn deserialize_child_of( + _ctx: &mut WriteCtx, + message: &mut Bytes, +) -> bevy_ecs::error::Result { + let server_parent = postcard_utils::entity_from_buf(message)?; + Ok(ChildOf(server_parent)) +} + +/// Receives [`ChildOf`] without using Replicon's placeholder entity mapper. +/// +/// If the parent has already been mapped, this inserts the real Bevy hierarchy +/// relationship. If not, it stores [`PendingChildOf`] and waits for +/// [`resolve_pending_child_of`] to attach the relationship once the parent +/// mapping is available. +pub(crate) fn write_child_of( + ctx: &mut WriteCtx, + _rule_fns: &RuleFns, + entity: &mut DeferredEntity, + message: &mut Bytes, +) -> bevy_ecs::error::Result<()> { + let server_parent = postcard_utils::entity_from_buf(message)?; + if let Some(&client_parent) = ctx.entity_map.to_client().get(&server_parent) { + entity.insert(ChildOf(client_parent)); + entity.remove::(); + } else { + entity.insert(PendingChildOf::new(server_parent)); + entity.remove::(); + } + Ok(()) +} + +pub(crate) fn remove_child_of(_ctx: &mut RemoveCtx, entity: &mut DeferredEntity) { + entity.remove::(); + entity.remove::(); +} + +/// Attach delayed hierarchy relationships once Replicon has mapped the parent. +#[cfg(feature = "client")] +pub(crate) fn resolve_pending_child_of( + entity_map: Option>, + pending: Query<(Entity, &PendingChildOf)>, + mut commands: Commands, +) { + let Some(entity_map) = entity_map else { + return; + }; + for (entity, pending) in &pending { + let Some(&client_parent) = entity_map.to_client().get(&pending.server_parent) else { + continue; + }; + commands + .entity(entity) + .insert(ChildOf(client_parent)) + .remove::(); + } +} + /// When the `DisableReplicateHierarchy` marker component is added to an entity, we will stop replicating their children. #[derive(Component, Clone, Copy, Debug, Default, PartialEq, Reflect)] #[reflect(Component)] diff --git a/crates/replication/replication/src/lib.rs b/crates/replication/replication/src/lib.rs index 8ddb19d75..af08c0691 100644 --- a/crates/replication/replication/src/lib.rs +++ b/crates/replication/replication/src/lib.rs @@ -175,7 +175,8 @@ struct SharedComponentRegistrationPlugin; impl Plugin for SharedComponentRegistrationPlugin { fn build(&self, app: &mut bevy_app::prelude::App) { - use bevy_replicon::prelude::AppRuleExt; + use bevy_ecs::prelude::ChildOf; + use bevy_replicon::prelude::{AppMarkerExt, AppRuleExt, RuleFns}; // The order of app.replicate() calls must be identical on client and server. // These marker components are sent from server to client as part of entity replication. #[cfg(feature = "prediction")] @@ -185,7 +186,11 @@ impl Plugin for SharedComponentRegistrationPlugin { app.replicate::(); // ChildOf is registered for replication in HierarchySendPlugin (server-only), // but must also be registered on the client so FnsIds match. - app.replicate::(); + app.replicate_with(RuleFns::new( + hierarchy::serialize_child_of, + hierarchy::deserialize_child_of, + )) + .set_receive_fns::(hierarchy::write_child_of, hierarchy::remove_child_of); // ServerMutateTicks is normally only initialized by bevy_replicon's ClientPlugin, // but prediction systems on server-only builds also reference it. Init it here @@ -244,5 +249,9 @@ impl Plugin for LightyearRepliconClientBackend { fn build(&self, app: &mut bevy_app::prelude::App) { app.add_plugins(bevy_replicon::client::ClientPlugin); app.add_plugins(client::RepliconClientPlugin); + app.add_systems( + bevy_app::prelude::PreUpdate, + hierarchy::resolve_pending_child_of, + ); } } diff --git a/examples/projectiles/src/renderer.rs b/examples/projectiles/src/renderer.rs index 06fa9061e..c77432960 100644 --- a/examples/projectiles/src/renderer.rs +++ b/examples/projectiles/src/renderer.rs @@ -50,7 +50,15 @@ impl Plugin for ExampleRendererPlugin { // mock the action before BEI evaluates it. BEI evaluated actions mocks in FixedPreUpdate update_cursor_state_from_window, ); - app.add_systems(Update, (display_score, render_hitscan_lines, display_info)); + app.add_systems( + Update, + ( + display_score, + render_hitscan_lines, + display_info, + sync_active_mode_visibility.after(add_player_visuals), + ), + ); } #[cfg(feature = "server")] @@ -153,9 +161,24 @@ fn display_info( ); } +fn is_active_mode(mode: Option<&GameReplicationMode>, active_mode: &GameReplicationMode) -> bool { + mode.is_some_and(|mode| mode == active_mode) +} + #[cfg(feature = "client")] -fn render_hitscan_lines(query: Query<(&HitscanVisual, &ColorComponent)>, mut gizmos: Gizmos) { - for (visual, color) in query.iter() { +fn render_hitscan_lines( + active_mode: Query<&GameReplicationMode, With>, + shooters: Query<&GameReplicationMode, With>, + query: Query<(&HitscanVisual, &ColorComponent, &BulletMarker)>, + mut gizmos: Gizmos, +) { + let Ok(active_mode) = active_mode.single() else { + return; + }; + for (visual, color, marker) in query.iter() { + if !is_active_mode(shooters.get(marker.shooter).ok(), active_mode) { + continue; + } let progress = visual.lifetime / visual.max_lifetime; let alpha = (1.0 - progress).max(0.0); let line_color = color.0.with_alpha(alpha); @@ -163,6 +186,37 @@ fn render_hitscan_lines(query: Query<(&HitscanVisual, &ColorComponent)>, mut giz } } +#[cfg(feature = "client")] +fn sync_active_mode_visibility( + active_mode: Query<&GameReplicationMode, With>, + shooters: Query<&GameReplicationMode, With>, + mut players: Query< + (&GameReplicationMode, &mut Visibility), + (With, With, Without), + >, + mut projectiles: Query<(&BulletMarker, &mut Visibility), Without>, +) { + let Ok(active_mode) = active_mode.single() else { + return; + }; + + for (mode, mut visibility) in &mut players { + *visibility = if mode == active_mode { + Visibility::Visible + } else { + Visibility::Hidden + }; + } + + for (marker, mut visibility) in &mut projectiles { + *visibility = if is_active_mode(shooters.get(marker.shooter).ok(), active_mode) { + Visibility::Visible + } else { + Visibility::Hidden + }; + } +} + #[cfg(feature = "server")] fn draw_aabb_envelope(query: Query<&ColliderAabb, With>, mut gizmos: Gizmos) { query.iter().for_each(|collider_aabb| { diff --git a/examples/projectiles/src/server.rs b/examples/projectiles/src/server.rs index 56727819d..4d4164349 100644 --- a/examples/projectiles/src/server.rs +++ b/examples/projectiles/src/server.rs @@ -327,7 +327,7 @@ pub(crate) fn spawn_player( if is_bot { commands.entity(player_entity).insert(Bot); } - shared::spawn_player_actions(&mut commands, player_entity); + shared::spawn_player_actions(&mut commands, player_entity, room_id); info!("Spawning player {player_entity:?} for room: {room_id:?}"); } } diff --git a/examples/projectiles/src/shared.rs b/examples/projectiles/src/shared.rs index 6fbab77d4..f276b1ef0 100644 --- a/examples/projectiles/src/shared.rs +++ b/examples/projectiles/src/shared.rs @@ -124,24 +124,30 @@ pub(crate) fn spawn_global_actions(commands: &mut Commands, context: Entity) { /// Spawn the server-owned BEI player action entities. /// /// Clients receive these through replication and add local-only bindings/input -/// markers to the active local player's actions. -pub(crate) fn spawn_player_actions(commands: &mut Commands, player: Entity) { +/// markers to the active local player's actions. The actions carry the same +/// room filter as the player because [`ReplicateLike`] copies replication +/// targets, but not room-based visibility filter components. +#[cfg(feature = "server")] +pub(crate) fn spawn_player_actions(commands: &mut Commands, player: Entity, room_id: RoomId) { commands.spawn(( ActionOf::::new(player), Action::::new(), ReplicateLike { root: player }, + Rooms::single(room_id), )); commands.spawn(( ActionOf::::new(player), Action::::new(), ReplicateLike { root: player }, + Rooms::single(room_id), )); commands.spawn(( ActionOf::::new(player), Action::::new(), ReplicateLike { root: player }, + Rooms::single(room_id), )); } From f5598fff4ac8808e3a1b71fce5f7b46dd5e46bb0 Mon Sep 17 00:00:00 2001 From: Charles Bournhonesque Date: Thu, 25 Jun 2026 23:36:55 -0400 Subject: [PATCH 4/4] fix --- crates/inputs/input_bei/src/setup.rs | 2 +- crates/replication/replication/src/hierarchy.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/inputs/input_bei/src/setup.rs b/crates/inputs/input_bei/src/setup.rs index 1eb503ea8..ec6f2b76f 100644 --- a/crates/inputs/input_bei/src/setup.rs +++ b/crates/inputs/input_bei/src/setup.rs @@ -220,7 +220,7 @@ impl InputRegistryPlugin { /// buffered placeholder is still pending when the next component in the same /// entity bundle is decoded. pub(crate) fn serialize_action_of( - _ctx: &SerializeCtx, + _ctx: &mut SerializeCtx, action_of: &ActionOf, message: &mut Vec, ) -> bevy_ecs::error::Result<()> { diff --git a/crates/replication/replication/src/hierarchy.rs b/crates/replication/replication/src/hierarchy.rs index c6a0a0a4c..92a982d9c 100644 --- a/crates/replication/replication/src/hierarchy.rs +++ b/crates/replication/replication/src/hierarchy.rs @@ -124,7 +124,7 @@ impl Plugin for HierarchyPlugin { /// yet, the receiver defers inserting the relationship instead of letting /// Replicon create a placeholder entity inside the relationship component. pub(crate) fn serialize_child_of( - _ctx: &SerializeCtx, + _ctx: &mut SerializeCtx, child_of: &ChildOf, message: &mut Vec, ) -> bevy_ecs::error::Result<()> {