steel_core/command/builtins/
gamemode.rs1use std::{slice, sync::Arc};
4
5use steel_registry::vanilla_game_rules::SEND_COMMAND_FEEDBACK;
6use steel_utils::{Identifier, translations, types::GameType};
7use text_components::{TextComponent, translation::Translation};
8
9use super::super::{
10 brigadier::{CommandNodeBuilder, CommandSyntaxError},
11 execution::{
12 CommandPermissionSource, CommandSource, SteelArgumentType, SteelCommandContext,
13 SteelCommandRuntime, argument, literal,
14 },
15 registration::{CommandRegistration, CommandRegistrationError},
16};
17use crate::command::sender::CommandSender;
18use crate::entity::Entity;
19use crate::permission::{
20 PermissionContext, PermissionExpr, PermissionKey, PermissionKeyError, PermissionSegment,
21};
22use crate::player::Player;
23use crate::server::Server;
24use crate::world::World;
25
26const GAME_MODES: [GameType; 4] = [
27 GameType::Survival,
28 GameType::Creative,
29 GameType::Adventure,
30 GameType::Spectator,
31];
32
33pub(super) fn registration() -> Result<CommandRegistration<CommandSource>, CommandRegistrationError>
34{
35 let permission = visible_permission().map_err(|source| {
36 CommandRegistrationError::InvalidExplicitPermission {
37 id: Identifier::vanilla_static("gamemode"),
38 source,
39 }
40 })?;
41 Ok(
42 CommandRegistration::new(Identifier::vanilla_static("gamemode"), |_| command())
43 .permission(permission),
44 )
45}
46
47fn command() -> CommandNodeBuilder<CommandSource, SteelCommandRuntime> {
48 literal("gamemode").then(
49 argument("gamemode", SteelArgumentType::game_mode())
50 .executes(set_own_game_mode)
51 .then(argument("target", SteelArgumentType::players()).executes(set_target_game_mode)),
52 )
53}
54
55fn set_own_game_mode(
56 context: &SteelCommandContext<CommandSource>,
57) -> Result<i32, CommandSyntaxError> {
58 let game_mode = required_game_mode(context)?;
59 require_game_mode_permission(context.source(), game_mode)?;
60 let Some(player) = context.source().player() else {
61 return Err(CommandSyntaxError::dynamic(TextComponent::from(
62 &translations::PERMISSIONS_REQUIRES_PLAYER,
63 )));
64 };
65 set_game_mode(context.source(), slice::from_ref(player), game_mode)
66}
67
68fn set_target_game_mode(
69 context: &SteelCommandContext<CommandSource>,
70) -> Result<i32, CommandSyntaxError> {
71 let game_mode = required_game_mode(context)?;
72 require_game_mode_permission(context.source(), game_mode)?;
73 let targets = context.players("target")?;
74 set_game_mode(context.source(), &targets, game_mode)
75}
76
77fn required_game_mode(
78 context: &SteelCommandContext<CommandSource>,
79) -> Result<GameType, CommandSyntaxError> {
80 context.game_mode("gamemode").ok_or_else(|| {
81 CommandSyntaxError::dynamic("Parsed gamemode is missing from the command context")
82 })
83}
84
85fn require_game_mode_permission(
86 source: &CommandSource,
87 game_mode: GameType,
88) -> Result<(), CommandSyntaxError> {
89 if source_can_change_game_mode(source, game_mode) {
90 return Ok(());
91 }
92 Err(CommandSyntaxError::dynamic(format!(
93 "You do not have permission to use game mode {}",
94 game_mode.name()
95 )))
96}
97
98fn set_game_mode(
99 source: &CommandSource,
100 targets: &[Arc<Player>],
101 game_mode: GameType,
102) -> Result<i32, CommandSyntaxError> {
103 let mut changed = 0usize;
104 let send_target_feedback = source.world().get_game_rule(&SEND_COMMAND_FEEDBACK);
105
106 for target in targets {
107 if !target.set_game_mode(game_mode) {
108 continue;
109 }
110 changed += 1;
111
112 if source
113 .entity()
114 .is_some_and(|entity| entity.uuid() == target.uuid())
115 {
116 let message = translations::COMMANDS_GAMEMODE_SUCCESS_SELF
117 .message([TextComponent::from(game_mode_translation(game_mode))])
118 .component();
119 source.send_success(&message, true);
120 continue;
121 }
122
123 if send_target_feedback {
124 let message = translations::GAME_MODE_CHANGED
125 .message([TextComponent::from(game_mode_translation(game_mode))])
126 .component();
127 target.send_message(&message);
128 }
129 let message = translations::COMMANDS_GAMEMODE_SUCCESS_OTHER
130 .message([
131 TextComponent::plain(target.plain_text_name()),
132 TextComponent::from(game_mode_translation(game_mode)),
133 ])
134 .component();
135 source.send_success(&message, true);
136 }
137
138 i32::try_from(changed).map_err(|_| {
139 CommandSyntaxError::dynamic("Changed player count exceeds the command result range")
140 })
141}
142
143pub(crate) fn handle_client_request(
144 player: &Arc<Player>,
145 server: &Arc<Server>,
146 game_mode: GameType,
147) {
148 let world = player.get_world();
149 if !player_can_change_game_mode(player, &world, game_mode) {
150 log::warn!(
151 "Player {} tried to change game mode to {} without permission",
152 player.gameprofile.name,
153 game_mode.name()
154 );
155 return;
156 }
157
158 let source = CommandSource::new(
159 CommandSender::Player(Arc::clone(player)),
160 Arc::clone(server),
161 );
162 if let Err(error) = set_game_mode(&source, slice::from_ref(player), game_mode) {
163 log::error!(
164 "Failed to apply client game-mode change for {}: {error}",
165 player.gameprofile.name
166 );
167 }
168}
169
170pub(crate) fn player_can_use_client_switcher(player: &Player, world: &World) -> bool {
171 any_game_mode_allowed(|game_mode| player_can_change_game_mode(player, world, game_mode))
172}
173
174fn any_game_mode_allowed(mut allows_game_mode: impl FnMut(GameType) -> bool) -> bool {
175 GAME_MODES.into_iter().any(&mut allows_game_mode)
176}
177
178fn source_can_change_game_mode(source: &CommandSource, game_mode: GameType) -> bool {
179 let permission = match game_mode_permission(game_mode) {
180 Ok(permission) => permission,
181 Err(error) => {
182 log::error!(
183 "invalid built-in gamemode permission key for {}: {error}",
184 game_mode.name()
185 );
186 return false;
187 }
188 };
189 source.has_permission(&permission)
190}
191
192fn player_can_change_game_mode(player: &Player, world: &World, game_mode: GameType) -> bool {
193 let permission = match game_mode_permission(game_mode) {
194 Ok(permission) => permission,
195 Err(error) => {
196 log::error!(
197 "invalid built-in gamemode permission key for {}: {error}",
198 game_mode.name()
199 );
200 return false;
201 }
202 };
203 let context = PermissionContext::for_world(world.key.clone());
204 player.has_permission_in(&permission, &context)
205}
206
207fn visible_permission() -> Result<PermissionExpr, PermissionKeyError> {
208 GAME_MODES
209 .into_iter()
210 .map(game_mode_permission)
211 .collect::<Result<Vec<_>, _>>()
212 .map(PermissionExpr::Any)
213}
214
215fn game_mode_permission(game_mode: GameType) -> Result<PermissionExpr, PermissionKeyError> {
216 let root = PermissionKey::parse("minecraft.command.gamemode")?;
217 let segment = PermissionSegment::parse(game_mode.name())?;
218 let mode = root.child(&segment)?;
219 Ok(PermissionExpr::scoped_key(root, mode))
220}
221
222const fn game_mode_translation(game_mode: GameType) -> &'static Translation<0> {
223 match game_mode {
224 GameType::Survival => &translations::GAME_MODE_SURVIVAL,
225 GameType::Creative => &translations::GAME_MODE_CREATIVE,
226 GameType::Adventure => &translations::GAME_MODE_ADVENTURE,
227 GameType::Spectator => &translations::GAME_MODE_SPECTATOR,
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use steel_registry::init_vanilla_registry;
234
235 use super::super::create_dispatcher;
236 use super::*;
237 use crate::permission::{PermissionEntry, PermissionSet};
238
239 fn permission_key(value: &str) -> PermissionKey {
240 PermissionKey::parse(value).expect("test permission key should parse")
241 }
242
243 fn permissions(entries: impl IntoIterator<Item = PermissionEntry>) -> PermissionSet {
244 PermissionSet::from_entries(entries)
245 }
246
247 fn allow(value: &str) -> PermissionEntry {
248 PermissionEntry::allow(permission_key(value))
249 }
250
251 fn deny(value: &str) -> PermissionEntry {
252 PermissionEntry::deny(permission_key(value))
253 }
254
255 fn allows_mode(permissions: &PermissionSet, game_mode: GameType) -> bool {
256 permissions.allows(&game_mode_permission(game_mode).expect("permission should build"))
257 }
258
259 #[test]
260 fn scoped_game_mode_permissions_preserve_root_and_child_rules() {
261 let root = permissions([allow("minecraft.command.gamemode")]);
262 assert!(GAME_MODES.into_iter().all(|mode| allows_mode(&root, mode)));
263
264 let creative_only = permissions([allow("minecraft.command.gamemode.creative")]);
265 assert!(allows_mode(&creative_only, GameType::Creative));
266 assert!(!allows_mode(&creative_only, GameType::Survival));
267
268 let creative_denied = permissions([
269 allow("minecraft.command.gamemode"),
270 deny("minecraft.command.gamemode.creative"),
271 ]);
272 assert!(!allows_mode(&creative_denied, GameType::Creative));
273 assert!(allows_mode(&creative_denied, GameType::Survival));
274
275 let visible = visible_permission().expect("visibility permission should build");
276 assert!(creative_only.allows(&visible));
277
278 let all_denied = permissions([
279 allow("minecraft.command.gamemode"),
280 deny("minecraft.command.gamemode.survival"),
281 deny("minecraft.command.gamemode.creative"),
282 deny("minecraft.command.gamemode.adventure"),
283 deny("minecraft.command.gamemode.spectator"),
284 ]);
285 assert!(!all_denied.allows(&visible));
286 }
287
288 #[test]
289 fn client_switcher_requires_at_least_one_allowed_mode() {
290 let creative_only = permissions([allow("minecraft.command.gamemode.creative")]);
291 assert!(any_game_mode_allowed(|mode| allows_mode(
292 &creative_only,
293 mode
294 )));
295
296 let none = PermissionSet::default();
297 assert!(!any_game_mode_allowed(|mode| allows_mode(&none, mode)));
298
299 let all_denied = permissions([
300 allow("minecraft.command.gamemode"),
301 deny("minecraft.command.gamemode.survival"),
302 deny("minecraft.command.gamemode.creative"),
303 deny("minecraft.command.gamemode.adventure"),
304 deny("minecraft.command.gamemode.spectator"),
305 ]);
306 assert!(!any_game_mode_allowed(|mode| allows_mode(
307 &all_denied,
308 mode
309 )));
310 }
311
312 #[test]
313 fn gamemode_graph_matches_vanilla_shape() {
314 init_vanilla_registry();
315 let Ok(dispatcher) = create_dispatcher() else {
316 panic!("built-in commands should register");
317 };
318 let Some(root) = dispatcher.children(dispatcher.root()).and_then(|children| {
319 children.iter().copied().find(|child| {
320 dispatcher
321 .node(*child)
322 .is_some_and(|node| node.name() == "gamemode")
323 })
324 }) else {
325 panic!("gamemode root should exist");
326 };
327 let Some(game_mode) = dispatcher
328 .children(root)
329 .and_then(|children| children.first())
330 .copied()
331 else {
332 panic!("gamemode argument should exist");
333 };
334 assert!(matches!(
335 dispatcher.node(game_mode),
336 Some(node)
337 if node.is_executable()
338 && node.argument_type() == Some(&SteelArgumentType::game_mode())
339 ));
340 let Some(target) = dispatcher
341 .children(game_mode)
342 .and_then(|children| children.first())
343 else {
344 panic!("target argument should exist");
345 };
346 assert!(matches!(
347 dispatcher.node(*target),
348 Some(node)
349 if node.name() == "target"
350 && node.is_executable()
351 && node.argument_type() == Some(&SteelArgumentType::players())
352 ));
353 }
354}