Skip to main content

lumen_core/
command.rs

1//! Bounded command queue for off-main-thread mutations of ECS state.
2//!
3//! - Capacity defaults to [`DEFAULT_QUEUE_CAPACITY`] (8192) and is configurable via [`CommandQueue::with_capacity`].
4//! - Overflow drops the command, logs, and emits a [`CommandQueueOverflow`] message.
5//! - The [`TickStage::CommandDrain`](crate::tick::TickStage::CommandDrain) stage drains the queue on the main thread between `Input` and `Systems`.
6
7use crate::property_store::{PropertyKey, PropertyStore, PropertyValue};
8use bevy_ecs::prelude::*;
9use crossbeam_channel::{Receiver, Sender, TrySendError, bounded};
10use std::any::{Any, TypeId};
11use std::collections::HashMap;
12use std::sync::Arc;
13
14/// Default queue capacity. Override via [`CommandQueue::with_capacity`].
15pub const DEFAULT_QUEUE_CAPACITY: usize = 8192;
16
17/// A deferred command applied during [`TickStage::CommandDrain`](crate::tick::TickStage::CommandDrain).
18pub enum Command {
19    /// Carries an asynchronously decoded asset payload.
20    AssetLoaded {
21        /// Identifier supplied by the requester when the asset was requested.
22        callback_id: u64,
23        /// Decoded payload (image bytes, etc.) typed by the consumer.
24        payload: Box<dyn Any + Send>,
25    },
26    /// Carries an HTTP response payload.
27    NetworkResponse {
28        /// Identifier supplied at request time.
29        callback_id: u64,
30        /// HTTP status code.
31        status: u16,
32        /// Response body bytes.
33        body: Vec<u8>,
34    },
35    /// Carries a script-produced state mutation.
36    ScriptUpdate(Box<dyn Any + Send>),
37    /// Writes a typed value into the [`PropertyStore`].
38    ///
39    /// Drained in [`TickStage::CommandDrain`](crate::tick::TickStage::CommandDrain) via [`apply_property_commands`].
40    /// The per-`App` queue is the intended home for external property writes, but the global `EXTERNAL_TX` /
41    /// `EXTERNAL_RX` ring in `property_store` is still what the C-ABI crate and the async tasks use; this variant has
42    /// no producer yet.
43    SetProperty {
44        /// Target property key.
45        key: PropertyKey,
46        /// New value to write.
47        value: PropertyValue,
48    },
49    /// Strongly-typed custom command, dispatched via [`CommandRegistry`].
50    ///
51    /// Replaces blind `Custom(Box<dyn Any>)` downcasts: the receiving plugin registers a handler
52    /// for its concrete payload type with [`crate::app::App::register_command`] and the drain
53    /// dispatches by [`TypeId`].
54    Typed {
55        /// Concrete payload type id used to route the command to its registered handler.
56        type_id: TypeId,
57        /// Boxed payload.
58        payload: Box<dyn Any + Send>,
59    },
60    /// Free-form variant retained for backward compatibility with callers that have not yet migrated to [`Self::Typed`].
61    Custom(Box<dyn Any + Send>),
62}
63
64/// Message emitted when [`CommandQueue::try_push`] returns `Err` because the queue is full.
65#[derive(Message, Clone, Copy, Debug)]
66pub struct CommandQueueOverflow;
67
68/// Producer-side ECS resource. Cloneable for sharing across worker threads.
69#[derive(Resource, Clone)]
70pub struct CommandQueue {
71    tx: Sender<Command>,
72}
73
74impl CommandQueue {
75    /// Returns a `(CommandQueue, CommandReceiver)` pair with [`DEFAULT_QUEUE_CAPACITY`].
76    /// Insert the queue as a resource; pass the receiver to the drain system.
77    pub fn new() -> (Self, CommandReceiver) {
78        Self::with_capacity(DEFAULT_QUEUE_CAPACITY)
79    }
80
81    /// Returns a `(CommandQueue, CommandReceiver)` pair with the explicit capacity `cap`.
82    pub fn with_capacity(cap: usize) -> (Self, CommandReceiver) {
83        let (tx, rx) = bounded(cap);
84        (Self { tx }, CommandReceiver { rx })
85    }
86
87    /// Sends `cmd` non-blockingly. Returns `Err(TrySendError::Full)` when the channel is full; never blocks.
88    pub fn try_push(&self, cmd: Command) -> Result<(), TrySendError<Command>> {
89        self.tx.try_send(cmd)
90    }
91
92    /// Borrows the underlying sender for adapter code (the C-ABI crate, async tasks) that needs to clone the channel across threads.
93    pub fn sender(&self) -> &Sender<Command> {
94        &self.tx
95    }
96}
97
98/// Consumer-side ECS resource, registered as a non-Send resource since the drain runs on the main thread.
99#[derive(Resource)]
100pub struct CommandReceiver {
101    rx: Receiver<Command>,
102}
103
104impl CommandReceiver {
105    /// Returns an iterator that yields commands via `try_recv` and stops as soon as the channel is empty; never blocks.
106    pub fn drain(&mut self) -> impl Iterator<Item = Command> + '_ {
107        std::iter::from_fn(|| self.rx.try_recv().ok())
108    }
109
110    /// Borrows the underlying receiver for adapter code.
111    pub fn receiver(&self) -> &Receiver<Command> {
112        &self.rx
113    }
114}
115
116/// Handler invoked when a [`Command::Typed`] with a matching [`TypeId`] is drained.
117pub type CommandHandlerFn = Arc<dyn Fn(&mut World, Box<dyn Any + Send>) + Send + Sync>;
118
119/// Resource holding the per-`TypeId` handler table for [`Command::Typed`].
120///
121/// Populated by [`crate::app::App::register_command`]. Plugins that author new command kinds register their handler once
122/// at build-time and then push `Command::Typed { type_id: TypeId::of::<MyCmd>(), payload }` from any thread.
123#[derive(Resource, Default, Clone)]
124pub struct CommandRegistry {
125    handlers: HashMap<TypeId, CommandHandlerFn>,
126}
127
128impl CommandRegistry {
129    /// Registers `handler` for payloads of type `T`. Subsequent registrations for the same type overwrite the prior entry.
130    pub fn register<T, F>(&mut self, handler: F)
131    where
132        T: Any + Send,
133        F: Fn(&mut World, Box<T>) + Send + Sync + 'static,
134    {
135        let f: CommandHandlerFn = Arc::new(move |world, payload| {
136            if let Ok(typed) = payload.downcast::<T>() {
137                handler(world, typed);
138            }
139        });
140        self.handlers.insert(TypeId::of::<T>(), f);
141    }
142
143    /// Returns the registered handler for `type_id`, if any.
144    pub fn lookup(&self, type_id: &TypeId) -> Option<CommandHandlerFn> {
145        self.handlers.get(type_id).cloned()
146    }
147}
148
149/// Drains [`Command::SetProperty`] and [`Command::Typed`] entries from the [`CommandReceiver`] and applies them to the
150/// [`PropertyStore`] / [`CommandRegistry`] respectively.
151///
152/// Other [`Command`] variants are intentionally dropped by this drain - their owning plugins install dedicated drains.
153///
154/// Not auto-installed by [`crate::app::App::new`]. `lumen-runtime` registers it in the
155/// [`crate::tick::TickStage::CommandDrain`] stage when it builds an app; an embedder assembling its own `App`
156/// adds it the same way.
157pub fn apply_property_commands(world: &mut World) {
158    let mut to_apply: Vec<(PropertyKey, PropertyValue)> = Vec::new();
159    let mut typed_dispatch: Vec<(TypeId, Box<dyn Any + Send>)> = Vec::new();
160    if let Some(mut recv) = world.get_resource_mut::<CommandReceiver>() {
161        for cmd in recv.drain() {
162            match cmd {
163                Command::SetProperty { key, value } => to_apply.push((key, value)),
164                Command::Typed { type_id, payload } => typed_dispatch.push((type_id, payload)),
165                _ => {}
166            }
167        }
168    }
169    if let Some(mut store) = world.get_resource_mut::<PropertyStore>() {
170        for (k, v) in to_apply {
171            store.set(k, v);
172        }
173    }
174    let registry = world.get_resource::<CommandRegistry>().cloned();
175    if let Some(registry) = registry {
176        for (tid, payload) in typed_dispatch {
177            if let Some(handler) = registry.lookup(&tid) {
178                handler(world, payload);
179            }
180        }
181    }
182}