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
83        .game_profile_argument("targets")
84        .cloned()
85        .ok_or_else(|| CommandSyntaxError::dynamic("Missing game profile argument 'targets'"))?;
86    let source = context.source().clone();
87    let task_source = source.clone();
88    let (sender, receiver) = oneshot::channel();
89    let task = tokio::spawn(async move {
90        let result = run_operation(&task_source, argument, action).await;
91        let _ = sender.send(result);
92    });
93    Ok(OperatorCommandSuspension {
94        source,
95        action,
96        receiver,
97        task: Some(task),
98    })
99}
100
101struct OperatorCommandResult {
102    changed_names: Vec<String>,
103}
104
105async fn run_operation(
106    source: &CommandSource,
107    argument: GameProfileArgument,
108    action: OperatorAction,
109) -> Result<OperatorCommandResult, CommandSyntaxError> {
110    let targets = argument.resolve(source).await?;
111    let mut changed_names = Vec::new();
112    for target in targets {
113        if update_operator_group(source.server(), target.uuid, action).await? {
114            changed_names.push(target.name);
115        }
116    }
117    if changed_names.is_empty() {
118        return Err(CommandSyntaxError::dynamic(action.failed()));
119    }
120    Ok(OperatorCommandResult { changed_names })
121}
122
123async fn update_operator_group(
124    server: &Arc<Server>,
125    uuid: uuid::Uuid,
126    action: OperatorAction,
127) -> Result<bool, CommandSyntaxError> {
128    let result = server
129        .try_update_player_permissions(uuid, move |state| {
130            let (mut groups, overrides, metadata) = state.into_parts();
131            let changed = update_groups(&mut groups, action);
132            Ok::<_, Infallible>((
133                PermissionSubjectState::new_with_metadata(groups, overrides, metadata),
134                changed,
135            ))
136        })
137        .await
138        .map_err(|error| CommandSyntaxError::dynamic(error.to_string()))?;
139    Ok(result.1)
140}
141
142fn update_groups(groups: &mut Vec<String>, action: OperatorAction) -> bool {
143    match action {
144        OperatorAction::Grant => {
145            if groups.iter().any(|group| group == OP_GROUP) {
146                false
147            } else {
148                groups.push(OP_GROUP.to_owned());
149                true
150            }
151        }
152        OperatorAction::Revoke => {
153            let old_len = groups.len();
154            groups.retain(|group| group != OP_GROUP);
155            groups.len() != old_len
156        }
157    }
158}
159
160struct OperatorCommandSuspension {
161    source: CommandSource,
162    action: OperatorAction,
163    receiver: oneshot::Receiver<Result<OperatorCommandResult, CommandSyntaxError>>,
164    task: Option<JoinHandle<()>>,
165}
166
167impl CommandResultSuspension for OperatorCommandSuspension {
168    fn order(&self) -> CommandSuspensionOrder {
169        CommandSuspensionOrder::Global
170    }
171
172    fn poll(&mut self) -> CommandResultSuspensionPoll {
173        match self.receiver.try_recv() {
174            Ok(result) => {
175                self.task = None;
176                CommandResultSuspensionPoll::Ready(result.map(|result| {
177                    let changed = result.changed_names.len().min(i32::MAX as usize) as i32;
178                    for name in result.changed_names {
179                        self.source.send_success(&self.action.success(name), true);
180                    }
181                    changed
182                }))
183            }
184            Err(oneshot::error::TryRecvError::Empty) => CommandResultSuspensionPoll::Pending,
185            Err(oneshot::error::TryRecvError::Closed) => {
186                self.task = None;
187                CommandResultSuspensionPoll::Ready(Err(CommandSyntaxError::dynamic(format!(
188                    "{} command task ended without a result",
189                    self.action.command_name()
190                ))))
191            }
192        }
193    }
194
195    fn cancel(&mut self) {
196        if let Some(task) = self.task.take() {
197            task.abort();
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use steel_protocol::packets::game::{
205        ArgumentType as ProtocolArgumentType, SuggestionType as ProtocolSuggestionType,
206    };
207    use steel_registry::init_vanilla_registry;
208
209    use super::{OperatorAction, update_groups};
210    use crate::command::builtins::create_dispatcher;
211    use crate::command::execution::SteelArgumentType;
212
213    #[test]
214    fn operator_group_updates_are_idempotent_and_preserve_other_groups() {
215        let mut groups = vec!["builder".to_owned()];
216        assert!(update_groups(&mut groups, OperatorAction::Grant));
217        assert_eq!(groups, ["builder", "op"]);
218        assert!(!update_groups(&mut groups, OperatorAction::Grant));
219        assert!(update_groups(&mut groups, OperatorAction::Revoke));
220        assert_eq!(groups, ["builder"]);
221        assert!(!update_groups(&mut groups, OperatorAction::Revoke));
222    }
223
224    #[test]
225    fn operator_targets_use_vanillas_game_profile_argument() {
226        init_vanilla_registry();
227        let dispatcher = create_dispatcher();
228        let Ok(dispatcher) = dispatcher else {
229            panic!("built-in dispatcher should build");
230        };
231        for command_name in ["op", "deop"] {
232            let root = dispatcher.children(dispatcher.root()).and_then(|children| {
233                children.iter().copied().find(|child| {
234                    dispatcher
235                        .node(*child)
236                        .is_some_and(|node| node.name() == command_name)
237                })
238            });
239            let Some(root) = root else {
240                panic!("{command_name} root should exist");
241            };
242            let target = dispatcher
243                .children(root)
244                .and_then(|children| children.first())
245                .and_then(|target| dispatcher.node(*target));
246            let Some(target) = target else {
247                panic!("{command_name} target should exist");
248            };
249            let protocol = target
250                .argument_type()
251                .map(SteelArgumentType::protocol_argument);
252            assert!(matches!(
253                protocol,
254                Some((
255                    ProtocolArgumentType::GameProfile,
256                    Some(ProtocolSuggestionType::AskServer),
257                ))
258            ));
259            assert!(target.is_executable());
260        }
261    }
262}