Skip to main content

rig_harness/
commands.rs

1//! Slash commands. A command is an entity: its [`Name`] is the `/name`
2//! typed, its [`SlashCommand`] says what it does, and it is the one-shot
3//! system that runs it, so despawning it unregisters both. A plugin adds
4//! one with [`AppCommandsExt::add_command`], a system that reads the
5//! [`CommandArgs`], such as one that triggers an event.
6//!
7//! What the user types that starts with `/` runs as a command: a Gate
8//! system in [`CommandSystems`] takes the user's line over
9//! ([`Incoming::command`]) and triggers a [`RunCommand`] with it. A plugin
10//! that takes such a line over itself orders its Gate system
11//! `.before(CommandSystems)`. [`RunCommand`] runs a line at once, with
12//! everything it triggers; the notices written meanwhile are its reply. An
13//! unknown command, or an error notice about the agent, such as one
14//! refusing arguments, refuses the line: it comes back whole as
15//! [`Recalled`] with the reason, in place of that notice, so nothing typed
16//! is lost.
17
18use std::mem;
19
20use bevy_app::prelude::*;
21use bevy_ecs::message::Messages;
22use bevy_ecs::prelude::*;
23use bevy_ecs::system::SystemId;
24use bevy_log::warn;
25use bevy_reflect::prelude::*;
26use rig_core::effect::Decider;
27use rig_ecs::agent::{Notice, NoticeLevel};
28use rig_ecs::inbox::{DeliveryMode, Incoming, Recalled};
29use rig_ecs::stage::{
30    AgentPass, Decision, Effect, ForAgent, IncomingMessages, Outcome, ReadyToStart, Stage,
31};
32
33/// What a command system receives: the agent the command was typed for,
34/// the text after the command name, and how its line was sent.
35#[derive(Clone, Debug)]
36pub struct CommandArgs {
37    /// The agent.
38    pub agent: Entity,
39    /// The arguments, trimmed.
40    pub args: String,
41    /// How the line was sent, such as queued, for a command that sends
42    /// text on: as [`RunCommand::mode`] says.
43    pub mode: DeliveryMode,
44}
45
46/// A slash command, on the entity of the one-shot system it runs, whose
47/// [`Name`] is `/` and the name typed.
48#[derive(Component, Reflect, Clone, Debug)]
49#[reflect(Component, Clone, Debug)]
50pub struct SlashCommand {
51    /// One line of help.
52    pub help: String,
53}
54
55/// Runs slash commands: what the user types as one, and each
56/// [`RunCommand`]. Registering a command adds it too.
57pub struct CommandPlugin;
58
59impl Plugin for CommandPlugin {
60    fn build(&self, app: &mut App) {
61        runner(app);
62    }
63}
64
65/// The Gate system that runs the user's `/` lines.
66#[derive(SystemSet, Clone, Copy, Debug, PartialEq, Eq, Hash)]
67pub struct CommandSystems;
68
69/// That the app runs commands.
70#[derive(Resource)]
71struct Runner;
72
73/// Makes the app run commands, once.
74fn runner(app: &mut App) {
75    if app.world().contains_resource::<Runner>() {
76        return;
77    }
78    app.insert_resource(Runner)
79        .configure_sets(AgentPass, CommandSystems.in_set(Stage::Gate))
80        .add_systems(AgentPass, run_typed.in_set(CommandSystems))
81        .add_observer(on_run_command);
82}
83
84/// Takes each user's `/` line about to start over, in the order they were
85/// sent, and runs it for its agent, sent as it was.
86fn run_typed(
87    mut typed: Query<
88        (&Effect, &Incoming, &ForAgent, &mut Decision),
89        (ReadyToStart, IncomingMessages),
90    >,
91    mut commands: Commands,
92) {
93    let mut lines: Vec<_> = typed
94        .iter_mut()
95        .filter_map(|(effect, message, agent, decision)| {
96            Some((
97                effect.id(),
98                message.command()?,
99                message.mode,
100                agent.0,
101                decision,
102            ))
103        })
104        .collect();
105    lines.sort_unstable_by_key(|(id, ..)| *id);
106    for (_, line, mode, agent, mut decision) in lines {
107        if decision.decide(Decider::plugin("rig-harness"), Outcome::custom("command")) {
108            commands.trigger(RunCommand {
109                mode,
110                ..RunCommand::new(agent, line)
111            });
112        }
113    }
114}
115
116/// Registers slash commands on an [`App`], which then runs them
117/// ([`CommandPlugin`]). A name already registered is refused with a
118/// warning: to replace another plugin's command, insert Bevy's `Disabled`
119/// on its entity first, which frees its name.
120pub trait AppCommandsExt {
121    /// Register `/name`, described by `help`, that runs `system` with the
122    /// [`CommandArgs`] of each use.
123    fn add_command<M>(
124        &mut self,
125        name: &str,
126        help: &str,
127        system: impl IntoSystem<In<CommandArgs>, (), M> + 'static,
128    ) -> &mut Self;
129}
130
131impl AppCommandsExt for App {
132    fn add_command<M>(
133        &mut self,
134        name: &str,
135        help: &str,
136        system: impl IntoSystem<In<CommandArgs>, (), M> + 'static,
137    ) -> &mut Self {
138        runner(self);
139        let name = format!("/{name}");
140        if find(self.world_mut(), &name).is_some() {
141            warn!("command not registered: {name} exists; insert `Disabled` on it to replace it");
142            return self;
143        }
144        let system = self.register_system(system);
145        let help = help.to_owned();
146        let command = (Name::new(name), SlashCommand { help });
147        self.world_mut().entity_mut(system.entity()).insert(command);
148        self
149    }
150}
151
152/// The command named `name`, with its `/`.
153fn find(world: &mut World, name: &str) -> Option<Entity> {
154    world
155        .query_filtered::<(Entity, &Name), With<SlashCommand>>()
156        .iter(world)
157        .find(|(_, command)| command.as_str() == name)
158        .map(|(entity, _)| entity)
159}
160
161/// Run the slash command `line` (without its `/`) for the agent: its name,
162/// then its arguments.
163#[derive(EntityEvent, Reflect, Clone, Debug)]
164#[reflect(Event, Clone, Debug)]
165pub struct RunCommand {
166    /// The agent.
167    pub entity: Entity,
168    /// The command line after the `/`.
169    pub line: String,
170    /// How the line was sent: the mode of the message typed, else
171    /// [`DeliveryMode::Steer`], as by a key or a picker.
172    pub mode: DeliveryMode,
173}
174
175impl RunCommand {
176    /// Run `line` for `agent`, as if typed and sent at once.
177    pub fn new(agent: Entity, line: impl Into<String>) -> Self {
178        Self {
179            entity: agent,
180            line: line.into(),
181            mode: DeliveryMode::Steer,
182        }
183    }
184}
185
186/// Runs the command a [`RunCommand`] names, with everything it triggers.
187fn on_run_command(run: On<RunCommand>, mut commands: Commands) {
188    let (agent, line, mode) = (run.entity, run.line.clone(), run.mode);
189    commands.queue(move |world: &mut World| run_command(world, agent, line, mode));
190}
191
192/// Runs `line` for `agent`, sent as `mode`, keeping the notices written
193/// meanwhile apart, and recalls it when refused.
194fn run_command(world: &mut World, agent: Entity, line: String, mode: DeliveryMode) {
195    let typed = line.trim();
196    let (name, args) = typed.split_once(char::is_whitespace).unwrap_or((typed, ""));
197    let name = format!("/{name}");
198    let refusals = match find(world, &name) {
199        None => vec![format!("Unknown command {name}.")],
200        Some(system) => {
201            let earlier = swap_notices(world, Messages::default());
202            let ran = world.run_system_with(
203                SystemId::<In<CommandArgs>>::from_entity(system),
204                CommandArgs {
205                    agent,
206                    args: args.trim().to_owned(),
207                    mode,
208                },
209            );
210            let mut reply = swap_notices(world, earlier);
211            let mut refusals: Vec<String> =
212                ran.err().map(|ran| ran.to_string()).into_iter().collect();
213            for notice in reply.drain() {
214                let refusal = notice.level == NoticeLevel::Error
215                    && notice.agent.is_none_or(|about| about == agent);
216                if refusal {
217                    refusals.push(notice.text);
218                } else {
219                    world.write_message(notice);
220                }
221            }
222            refusals
223        }
224    };
225    if !refusals.is_empty() {
226        world.write_message(Recalled {
227            agent,
228            text: format!("/{line}"),
229            why: Some(refusals.join(" ")),
230        });
231    }
232}
233
234/// Puts `notices` in place of the world's, and returns those.
235fn swap_notices(world: &mut World, notices: Messages<Notice>) -> Messages<Notice> {
236    match world.get_resource_mut::<Messages<Notice>>() {
237        Some(mut current) => mem::replace(&mut *current, notices),
238        None => notices,
239    }
240}
241
242#[cfg(test)]
243mod tests;