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