steel_core/command/builtins/
operator.rs1use 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}