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.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}