Skip to main content

steel_core/command/builtins/
gamemode.rs

1//! Player game-mode command and permission projection.
2
3use 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}