Skip to main content

steel_core/command/builtins/
domain.rs

1//! Steel domain switch command.
2
3use std::sync::Arc;
4
5use steel_registry::{
6    data_components::vanilla_components::{CUSTOM_NAME, ENCHANTMENT_GLINT_OVERRIDE},
7    vanilla_dimension_types, vanilla_items, vanilla_menu_types,
8};
9use steel_utils::Identifier;
10use text_components::TextComponent;
11
12use crate::{inventory::prelude::*, server::Server, world::World};
13
14use super::super::{
15    brigadier::{CommandNodeBuilder, CommandSyntaxError},
16    execution::{
17        CommandSource, SteelArgumentType, SteelCommandContext, SteelCommandRuntime, argument,
18        literal,
19    },
20    registration::CommandRegistration,
21};
22
23pub(super) fn registration() -> CommandRegistration<CommandSource> {
24    CommandRegistration::new(Identifier::from_steel("domain"), |_| command())
25}
26
27fn command() -> CommandNodeBuilder<CommandSource, SteelCommandRuntime> {
28    literal("domain")
29        .executes(|ctx: &SteelCommandContext<CommandSource>| {
30            let Some(player) = ctx.source().player() else {
31                return Err(CommandSyntaxError::dynamic(
32                    "you cannot use this command from the console",
33                ));
34            };
35            let server = Arc::clone(ctx.source().server());
36            let player = Arc::clone(player);
37            let menu_player = Arc::clone(&player);
38
39            player.open_menu("Domains", move |context| {
40                domain_menu(context.container_id, menu_player, context.world, &server)
41            });
42
43            Ok(1)
44        })
45        .then(argument("world", SteelArgumentType::world()).executes(switch_world))
46}
47
48fn switch_world(context: &SteelCommandContext<CommandSource>) -> Result<i32, CommandSyntaxError> {
49    let source = context.source();
50    let Some(player) = source.player() else {
51        return Err(CommandSyntaxError::dynamic(
52            "This command can only be used by a player",
53        ));
54    };
55    let Some(world) = context.world_argument("world") else {
56        return Err(CommandSyntaxError::dynamic(
57            "Parsed world is missing from the command context",
58        ));
59    };
60    let world = world.resolve(source)?;
61    source
62        .server()
63        .queue_player_world_selection(Arc::clone(player), Arc::clone(&world))
64        .map_err(CommandSyntaxError::dynamic)?;
65
66    source.send_success(
67        &TextComponent::plain(format!("Switching to world {}", world.key)),
68        true,
69    );
70    Ok(1)
71}
72
73fn domain_menu(
74    container_id: u8,
75    player: Arc<Player>,
76    current_world: &Arc<World>,
77    server: &Arc<Server>,
78) -> Menu {
79    let mut b = MenuBuilder::new(&vanilla_menu_types::GENERIC_9X6, container_id);
80
81    let domain_names: Vec<String> = server
82        .worlds
83        .domain_names()
84        .map(ToOwned::to_owned)
85        .collect();
86
87    let map: Vec<(Section, Vec<Arc<World>>)> = b.grid(6, |g| {
88        g.paint_all(ItemStack::empty());
89        g.paint(
90            Rect::cols(..).rows(0),
91            &vanilla_items::GRAY_STAINED_GLASS_PANE,
92        );
93        g.paint(
94            Rect::cols(..).rows(5),
95            &vanilla_items::GRAY_STAINED_GLASS_PANE,
96        );
97        g.paint(
98            Rect::cols(8).rows(..),
99            &vanilla_items::GRAY_STAINED_GLASS_PANE,
100        );
101        g.paint(
102            Rect::cols(0).rows(1..5),
103            &vanilla_items::GRAY_STAINED_GLASS_PANE,
104        );
105
106        // TODO: Add pagination for domains and worlds instead of truncating to the grid capacity.
107        domain_names
108            .iter()
109            .take(4)
110            .enumerate()
111            .map(|(i, domain_name)| {
112                g.subgrid(Rect::cols(..8).rows(i + 1), |g| {
113                    g.paint_all(ItemStack::empty());
114
115                    let mut sign = ItemStack::new(&vanilla_items::OAK_SIGN);
116                    sign.set(CUSTOM_NAME, domain_name.clone().into());
117
118                    g.paint(Rect::cell(0, 0), sign);
119
120                    let worlds = server.worlds.worlds_in_domain(domain_name);
121
122                    let icons: Vec<ItemStack> =
123                        worlds.iter().map(|w| icon(w, current_world)).collect();
124
125                    let len = icons.len();
126
127                    let container = SimpleContainer::from_items(icons).into_shared();
128
129                    (
130                        g.place(Rect::cols(1..(len + 1).min(6)).rows(0), container)
131                            .display()
132                            .section(),
133                        worlds,
134                    )
135                })
136            })
137            .collect()
138    });
139    b.player_inventory(&player.inventory);
140
141    b.build(DomainMenuKind {
142        map,
143        server: server.clone(),
144        player,
145    })
146}
147
148fn icon(world: &Arc<World>, current_world: &Arc<World>) -> ItemStack {
149    let item = match world.dimension_type {
150        b if b == &vanilla_dimension_types::OVERWORLD
151            || b == &vanilla_dimension_types::OVERWORLD_CAVES =>
152        {
153            &vanilla_items::GRASS_BLOCK
154        }
155        b if b == &vanilla_dimension_types::THE_NETHER => &vanilla_items::NETHERRACK,
156        b if b == &vanilla_dimension_types::THE_END => &vanilla_items::END_STONE,
157        _ => &vanilla_items::BEDROCK,
158    };
159    let mut icon = ItemStack::new(item);
160    icon.set(CUSTOM_NAME, world.key.path.to_string().into());
161    if world.key == current_world.key {
162        icon.set(ENCHANTMENT_GLINT_OVERRIDE, true);
163    }
164    icon
165}
166
167struct DomainMenuKind {
168    map: Vec<(Section, Vec<Arc<World>>)>,
169    server: Arc<Server>,
170    player: Arc<Player>,
171}
172
173// SAFETY: This Steel-owned key uniquely identifies the concrete menu kind
174// within the process.
175unsafe impl steel_utils::DowncastType for DomainMenuKind {
176    const TYPE_KEY: steel_utils::DowncastTypeKey =
177        steel_utils::DowncastTypeKey::new("steel:menu/domain");
178}
179
180impl MenuKind for DomainMenuKind {
181    fn on_slot_clicked(
182        &mut self,
183        _behavior: &mut MenuBehavior,
184        _guard: &mut ContainerLockGuard,
185        click: Click,
186        _player: &Player,
187    ) -> ClickOutcome {
188        let Some(index) = click.slot() else {
189            return ClickOutcome::Fallthrough;
190        };
191
192        let Some((section, worlds)) = self
193            .map
194            .iter()
195            .find(|(section, _worlds)| section.contains(index))
196        else {
197            return ClickOutcome::Fallthrough;
198        };
199
200        if worlds.is_empty() {
201            return ClickOutcome::Consume;
202        }
203
204        let Some(world) = worlds.get(index - section.start()) else {
205            return ClickOutcome::Consume;
206        };
207
208        if !matches!(click, Click::Pickup { .. }) {
209            return ClickOutcome::Consume;
210        }
211
212        if let Err(error) = self
213            .server
214            .queue_player_world_selection(Arc::clone(&self.player), Arc::clone(world))
215        {
216            tracing::debug!(%error, target_world = %world.key, "domain menu selection was rejected");
217        }
218        self.player.close_container();
219        ClickOutcome::Consume
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::super::create_dispatcher;
226    use crate::command::{
227        brigadier::{CommandDispatcher, NodeId},
228        execution::{CommandSource, SteelArgumentType, SteelCommandRuntime},
229    };
230    use steel_registry::init_vanilla_registry;
231
232    type Dispatcher = CommandDispatcher<CommandSource, SteelCommandRuntime>;
233
234    fn child(dispatcher: &Dispatcher, parent: NodeId, name: &str) -> NodeId {
235        let Some(children) = dispatcher.children(parent) else {
236            panic!("parent node should exist");
237        };
238        let Some(child) = children.iter().copied().find(|child| {
239            dispatcher
240                .node(*child)
241                .is_some_and(|node| node.name() == name)
242        }) else {
243            panic!("child {name} should exist");
244        };
245        child
246    }
247
248    #[test]
249    fn domain_graph_uses_the_loaded_world_argument() {
250        init_vanilla_registry();
251        let Ok(dispatcher) = create_dispatcher() else {
252            panic!("built-in commands should register");
253        };
254        let root = child(&dispatcher, dispatcher.root(), "domain");
255        let world = child(&dispatcher, root, "world");
256        assert_eq!(
257            dispatcher.node(world).and_then(|node| node.argument_type()),
258            Some(&SteelArgumentType::world())
259        );
260        let Some(world) = dispatcher.node(world) else {
261            panic!("world argument should exist");
262        };
263        assert!(world.is_executable());
264    }
265}