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")
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}