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