Skip to main content

steel_core/command/builtins/
operator.rs

1//! Vanilla operator commands backed by Steel's built-in `op` permission group.
2
3use std::{convert::Infallible, sync::Arc};
4
5use steel_utils::{Identifier, translations};
6use text_components::TextComponent;
7use tokio::{sync::oneshot, task::JoinHandle};
8
9use super::super::{
10    brigadier::{CommandNodeBuilder, CommandSyntaxError},
11    execution::{
12        CommandResultSuspension, CommandResultSuspensionPoll, CommandSource,
13        CommandSuspensionOrder, GameProfileArgument, SteelArgumentType, SteelCommandContext,
14        SteelCommandRuntime, argument, literal,
15    },
16    registration::CommandRegistration,
17};
18use crate::{
19    permission::{OP_GROUP, PermissionSubjectState},
20    server::Server,
21};
22
23pub(super) fn op_registration() -> CommandRegistration<CommandSource> {
24    CommandRegistration::new(Identifier::vanilla_static("op"), |_| op_command())
25}
26
27pub(super) fn deop_registration() -> CommandRegistration<CommandSource> {
28    CommandRegistration::new(Identifier::vanilla_static("deop"), |_| deop_command())
29}
30
31fn op_command() -> CommandNodeBuilder<CommandSource, SteelCommandRuntime> {
32    literal("op").then(
33        argument("targets", SteelArgumentType::non_operator_profile())
34            .executes_suspended(|context| start_operation(context, OperatorAction::Grant)),
35    )
36}
37
38fn deop_command() -> CommandNodeBuilder<CommandSource, SteelCommandRuntime> {
39    literal("deop").then(
40        argument("targets", SteelArgumentType::operator_profile())
41            .executes_suspended(|context| start_operation(context, OperatorAction::Revoke)),
42    )
43}
44
45#[derive(Clone, Copy)]
46enum OperatorAction {
47    Grant,
48    Revoke,
49}
50
51impl OperatorAction {
52    const fn command_name(self) -> &'static str {
53        match self {
54            Self::Grant => "op",
55            Self::Revoke => "deop",
56        }
57    }
58
59    fn failed(self) -> TextComponent {
60        match self {
61            Self::Grant => TextComponent::from(&translations::COMMANDS_OP_FAILED),
62            Self::Revoke => TextComponent::from(&translations::COMMANDS_DEOP_FAILED),
63        }
64    }
65
66    fn success(self, name: String) -> TextComponent {
67        match self {
68            Self::Grant => translations::COMMANDS_OP_SUCCESS
69                .message([TextComponent::plain(name)])
70                .component(),
71            Self::Revoke => translations::COMMANDS_DEOP_SUCCESS
72                .message([TextComponent::plain(name)])
73                .component(),
74        }
75    }
76}
77
78fn start_operation(
79    context: &SteelCommandContext<CommandSource>,
80    action: OperatorAction,
81) -> Result<OperatorCommandSuspension, CommandSyntaxError> {
82    let argument = context.game_profile_argument("targets").cloned()?;
83    let source = context.source().clone();
84    let task_source = source.clone();
85    let (sender, receiver) = oneshot::channel();
86    let task = tokio::spawn(async move {
87        let result = run_operation(&task_source, argument, action).await;
88        let _ = sender.send(result);
89    });
90    Ok(OperatorCommandSuspension {
91        source,
92        action,
93        receiver,
94        task: Some(task),
95    })
96}
97
98struct OperatorCommandResult {
99    changed_names: Vec<String>,
100}
101
102async fn run_operation(
103    source: &CommandSource,
104    argument: GameProfileArgument,
105    action: OperatorAction,
106) -> Result<OperatorCommandResult, CommandSyntaxError> {
107    let targets = argument.resolve(source).await?;
108    let mut changed_names = Vec::new();
109    for target in targets {
110        if update_operator_group(source.server(), target.uuid, action).await? {
111            changed_names.push(target.name);
112        }
113    }
114    if changed_names.is_empty() {
115        return Err(CommandSyntaxError::dynamic(action.failed()));
116    }
117    Ok(OperatorCommandResult { changed_names })
118}
119
120async fn update_operator_group(
121    server: &Arc<Server>,
122    uuid: uuid::Uuid,
123    action: OperatorAction,
124) -> Result<bool, CommandSyntaxError> {
125    let result = server
126        .try_update_player_permissions(uuid, move |state| {
127            let (mut groups, overrides, metadata) = state.into_parts();
128            let changed = update_groups(&mut groups, action);
129            Ok::<_, Infallible>((
130                PermissionSubjectState::new_with_metadata(groups, overrides, metadata),
131                changed,
132            ))
133        })
134        .await
135        .map_err(|error| CommandSyntaxError::dynamic(error.to_string()))?;
136    Ok(result.1)
137}
138
139fn update_groups(groups: &mut Vec<String>, action: OperatorAction) -> bool {
140    match action {
141        OperatorAction::Grant => {
142            if groups.iter().any(|group| group == OP_GROUP) {
143                false
144            } else {
145                groups.push(OP_GROUP.to_owned());
146                true
147            }
148        }
149        OperatorAction::Revoke => {
150            let old_len = groups.len();
151            groups.retain(|group| group != OP_GROUP);
152            groups.len() != old_len
153        }
154    }
155}
156
157struct OperatorCommandSuspension {
158    source: CommandSource,
159    action: OperatorAction,
160    receiver: oneshot::Receiver<Result<OperatorCommandResult, CommandSyntaxError>>,
161    task: Option<JoinHandle<()>>,
162}
163
164impl CommandResultSuspension for OperatorCommandSuspension {
165    fn order(&self) -> CommandSuspensionOrder {
166        CommandSuspensionOrder::Global
167    }
168
169    fn poll(&mut self) -> CommandResultSuspensionPoll {
170        match self.receiver.try_recv() {
171            Ok(result) => {
172                self.task = None;
173                CommandResultSuspensionPoll::Ready(result.map(|result| {
174                    let changed = result.changed_names.len().min(i32::MAX as usize) as i32;
175                    for name in result.changed_names {
176                        self.source.send_success(&self.action.success(name), true);
177                    }
178                    changed
179                }))
180            }
181            Err(oneshot::error::TryRecvError::Empty) => CommandResultSuspensionPoll::Pending,
182            Err(oneshot::error::TryRecvError::Closed) => {
183                self.task = None;
184                CommandResultSuspensionPoll::Ready(Err(CommandSyntaxError::dynamic(format!(
185                    "{} command task ended without a result",
186                    self.action.command_name()
187                ))))
188            }
189        }
190    }
191
192    fn cancel(&mut self) {
193        if let Some(task) = self.task.take() {
194            task.abort();
195        }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use steel_protocol::packets::game::{
202        ArgumentType as ProtocolArgumentType, SuggestionType as ProtocolSuggestionType,
203    };
204    use steel_registry::init_vanilla_registry;
205
206    use super::{OperatorAction, update_groups};
207    use crate::command::builtins::create_dispatcher;
208    use crate::command::execution::SteelArgumentType;
209
210    #[test]
211    fn operator_group_updates_are_idempotent_and_preserve_other_groups() {
212        let mut groups = vec!["builder".to_owned()];
213        assert!(update_groups(&mut groups, OperatorAction::Grant));
214        assert_eq!(groups, ["builder", "op"]);
215        assert!(!update_groups(&mut groups, OperatorAction::Grant));
216        assert!(update_groups(&mut groups, OperatorAction::Revoke));
217        assert_eq!(groups, ["builder"]);
218        assert!(!update_groups(&mut groups, OperatorAction::Revoke));
219    }
220
221    #[test]
222    fn operator_targets_use_vanillas_game_profile_argument() {
223        init_vanilla_registry();
224        let dispatcher = create_dispatcher();
225        let Ok(dispatcher) = dispatcher else {
226            panic!("built-in dispatcher should build");
227        };
228        for command_name in ["op", "deop"] {
229            let root = dispatcher.children(dispatcher.root()).and_then(|children| {
230                children.iter().copied().find(|child| {
231                    dispatcher
232                        .node(*child)
233                        .is_some_and(|node| node.name() == command_name)
234                })
235            });
236            let Some(root) = root else {
237                panic!("{command_name} root should exist");
238            };
239            let target = dispatcher
240                .children(root)
241                .and_then(|children| children.first())
242                .and_then(|target| dispatcher.node(*target));
243            let Some(target) = target else {
244                panic!("{command_name} target should exist");
245            };
246            let protocol = target
247                .argument_type()
248                .map(SteelArgumentType::protocol_argument);
249            assert!(matches!(
250                protocol,
251                Some((
252                    ProtocolArgumentType::GameProfile,
253                    Some(ProtocolSuggestionType::AskServer),
254                ))
255            ));
256            assert!(target.is_executable());
257        }
258    }
259}